Python - Wrapper Classes

Introduction to Wrapper Classes

Hello there, future Python wizards! Today, we're going to embark on an exciting journey into the world of wrapper classes. Don't worry if you're new to programming – I'll guide you through this concept step by step, just like I've done for countless students over my years of teaching.

Python - Wrapper Classes

Imagine you have a beautiful gift, but you want to make it even more special by wrapping it in fancy paper. That's essentially what we do with wrapper classes in Python – we take existing objects and "wrap" them with additional functionality. Cool, right?

What are Wrapper Classes?

A wrapper class is a class that surrounds (or "wraps") an object of another class or a primitive data type. It's like putting a protective case on your smartphone – the phone still works the same, but now it has some extra features and protection.

Why Use Wrapper Classes?

  1. To add new functionality to existing objects
  2. To modify the behavior of existing methods
  3. To control access to the original object

Let's dive into some code examples to see how this works in practice!

Basic Wrapper Class Example

class StringWrapper:
    def __init__(self, string):
        self.string = string

    def get_string(self):
        return self.string

    def append(self, text):
        self.string += text

# Using our wrapper
wrapped_string = StringWrapper("Hello")
print(wrapped_string.get_string())  # Output: Hello
wrapped_string.append(" World!")
print(wrapped_string.get_string())  # Output: Hello World!

In this example, we've created a simple wrapper class for strings. Let's break it down:

  1. We define a class called StringWrapper.
  2. The __init__ method initializes our wrapper with a string.
  3. get_string() allows us to retrieve the wrapped string.
  4. append() is a new method that adds functionality – it appends text to our string.

See how we've added new functionality (appending) to a basic string? That's the power of wrapper classes!

Modifying Behavior with Wrapper Classes

Now, let's look at how we can modify the behavior of existing methods:

class ShoutingList(list):
    def __getitem__(self, index):
        return super().__getitem__(index).upper()

# Using our wrapper
normal_list = ["hello", "world", "python"]
shouting_list = ShoutingList(normal_list)

print(normal_list[0])     # Output: hello
print(shouting_list[0])   # Output: HELLO

In this example:

  1. We create a ShoutingList class that inherits from the built-in list class.
  2. We override the __getitem__ method to return uppercase strings.
  3. When we access items in our ShoutingList, they're automatically converted to uppercase.

This is like having a friend who always shouts when repeating what you say – same content, different delivery!

Controlling Access with Wrapper Classes

Wrapper classes can also be used to control access to the original object. This is particularly useful for data protection or implementing read-only objects:

class ReadOnlyWrapper:
    def __init__(self, data):
        self._data = data

    def get_data(self):
        return self._data

    def __setattr__(self, name, value):
        if name == '_data':
            super().__setattr__(name, value)
        else:
            raise AttributeError("This object is read-only")

# Using our wrapper
data = [1, 2, 3]
read_only_data = ReadOnlyWrapper(data)

print(read_only_data.get_data())  # Output: [1, 2, 3]
read_only_data.get_data().append(4)  # This works, modifies the original list
print(read_only_data.get_data())  # Output: [1, 2, 3, 4]

try:
    read_only_data.new_attribute = "Can't add this"
except AttributeError as e:
    print(e)  # Output: This object is read-only

In this example:

  1. We create a ReadOnlyWrapper class that only allows reading the data.
  2. We override __setattr__ to prevent adding new attributes to the wrapper.
  3. The original data can still be modified through get_data(), but no new attributes can be added to the wrapper itself.

This is like having a museum exhibit – you can look, but you can't touch!

Practical Applications of Wrapper Classes

Wrapper classes have numerous real-world applications. Here are a few examples:

  1. Logging: Wrap objects to log method calls or attribute access.
  2. Caching: Implement a caching layer around expensive operations.
  3. Input validation: Add checks to ensure data meets certain criteria before being used.
  4. Lazy loading: Delay the creation of an object until it's actually needed.

Let's implement a simple logging wrapper:

import time

class LoggingWrapper:
    def __init__(self, obj):
        self.wrapped_obj = obj

    def __getattr__(self, name):
        original_attr = getattr(self.wrapped_obj, name)
        if callable(original_attr):
            def wrapper(*args, **kwargs):
                start_time = time.time()
                result = original_attr(*args, **kwargs)
                end_time = time.time()
                print(f"Called {name}, took {end_time - start_time:.2f} seconds")
                return result
            return wrapper
        return original_attr

# Using our logging wrapper
class SlowCalculator:
    def add(self, x, y):
        time.sleep(1)  # Simulate a slow operation
        return x + y

calc = SlowCalculator()
logged_calc = LoggingWrapper(calc)

result = logged_calc.add(3, 4)
print(f"Result: {result}")

Output:

Called add, took 1.00 seconds
Result: 7

In this example:

  1. We create a LoggingWrapper that wraps any object.
  2. It intercepts method calls, logs the time taken, and then calls the original method.
  3. We use it to wrap a SlowCalculator object and log its method calls.

This is like having a personal assistant who times all your tasks and reports back to you!

Conclusion

Wrapper classes are a powerful tool in Python that allow you to extend, modify, and control objects in flexible ways. They're like the Swiss Army knives of object-oriented programming – versatile and incredibly useful in the right situations.

Remember, the key to mastering wrapper classes is practice. Try creating your own wrappers for different objects and see how you can enhance their functionality. Who knows? You might just wrap your way to becoming a Python master!

Happy coding, and may your code always be neatly wrapped! ??

Method Description
__init__(self, obj) Initialize the wrapper with the object to be wrapped
__getattr__(self, name) Intercept attribute access on the wrapper
__setattr__(self, name, value) Intercept attribute assignment on the wrapper
__getitem__(self, key) Intercept item access (e.g., for list-like objects)
__setitem__(self, key, value) Intercept item assignment
__call__(self, *args, **kwargs) Make the wrapper callable if the wrapped object is callable
__iter__(self) Make the wrapper iterable if the wrapped object is iterable
__len__(self) Implement length reporting for the wrapper
__str__(self) Customize string representation of the wrapper
__repr__(self) Customize repr representation of the wrapper

These methods allow you to customize almost every aspect of how your wrapper class behaves, giving you fine-grained control over the wrapped object's interactions with the rest of your code.

Credits: Image by storyset