Python

Python Decorator for Simplifying Delegate Pattern

Recently, I posted about how you can use composition instead of inheritance, and in that article, I mentioned the Delegate Pattern. As a quick description, the pattern has you inherit from the parent interface (or, in Python, you can just implement the protocol), but all the methods either redirect to calling the method on an injected implementation of interface/protocol, possibly surrounding the call in its own code, or it completely fills the method with its own implementation.

For the most part, it is like manually implementing inheritance, except that the superclass in injectable.

Anyway, one of the more annoying parts of implementing the Delegate Pattern is having to write all of the delegated calls. So I decided to look into the possibility of of making a decorator that will do that for you for all the methods you don’t explicitly implement.

Standard Attribute Delegation

First, we need a standard way of delegating an attribute to a delegate object’s attribute. We’ll use a descriptor for this. Its constructor takes in the name of the delegate stored on the object as well as the name of the attribute to look up. Here’s the descriptor:

class DelegatedAttribute:
    def __init__(self, delegate_name, attr_name):
        self.attr_name = attr_name
        self.delegate_name = delegate_name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        else:
            # return instance.delegate.attr
            return getattr(getattr(instance, self.delegate_name),  self.attr_name)

    def __set__(self, instance, value):
        # instance.delegate.attr = value
        setattr(getattr(instance, self.delegate_name), self.attr_name, value)

    def __delete__(self, instance):
        delattr(getattr(instance, self.delegate_name), self.attr_name)

    def __str__(self):
        return "<delegated attribute, '" + self.attr_name + "' at " + str(id(self)) + '>'

I added a nice __str__ method to make it look a little less like a boring object and more like a feature when people do some REPL diving. Normally, I don’t bother implementing the __delete__ method, but I felt it was appropriate here. I doubt it’ll ever be used, but if someone wants to use del on an attribute, I want it to be effective.

Hmm, all those main methods have a repetitive part for getting the delegate object. Let’s extract a method and hopefully make them a little bit more legible; nested getattrs, setattrs, and delattrs aren’t the easiest thing to look at.

class DelegatedAttribute:
    def __init__(self, delegate_name, attr_name):
        self.attr_name = attr_name
        self.delegate_name = delegate_name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        else:
            # return instance.delegate.attr
            return getattr(self.delegate(instance),  self.attr_name)

    def __set__(self, instance, value):
        # instance.delegate.attr = value
        setattr(self.delegate(instance), self.attr_name, value)

    def __delete__(self, instance):
        delattr(self.delegate(instance), self.attr_name)

    def delegate(self, instance):
        return getattr(instance, self.delegate_name)

    def __str__(self):
        return "<delegated attribute, '" + self.attr_name + "' at " + str(id(self)) + '>'

There, that’s a little easier to read. At the very least, we’ve removed some repetition. I wish we didn’t need the self reference to call it, as that would make it even easier to look at, but trying to get around it would likely be a lot of work, just as ugly, or both.

Let’s see an example of this in action:

class Foo:
    def __init__(self):
        self.bar = 'bar in foo'
    def baz(self):
        return 'baz in foo'

class Baz:
    def __init__(self):
        self.foo = Foo()
    bar = DelegatedAttribute('foo', 'bar')
    baz = DelegatedAttribute('foo', 'baz')

x = Baz()
print(x.bar)  # prints 'bar in foo'
print(x.baz())  # prints 'baz in foo'

It’s as simple as making sure you have an object to delegate to and adding a DelegatedAttribute with the name of that object and the attribute to delegate to. As you can see, it properly delegates both functions and object fields as we would want. It’s not only useful for the Delegate pattern but really any situation in composition where a wrapper call simply delegates the call to the wrapped object.

This is good, but we don’t want to do this by hand. Plus, I mentioned a decorator. Where’s that? Hold your horses; I’m getting there.

Starting the Decorator

See? Now we’ve moved to the decorator. What is the decorator supposed to do? How should it work? First thing you should know is that we created the DelegatedAttribute class for a reason. We’ll utilize that, assigning all the class attributes of the delegate object to the new class.

To do that, we’ll need a reference to the class. We’ll also need to know the name of the delegate object, so it can be given to the DelegatedAttribute constructor. For now, we’ll hard code that. From this, we know that we need a parameterized decorator, whose outermost signature looks like delegate_as(delegate_cls). Let’s get it started.

def delegate_as(delegate_cls):
    # gather the attributes of the delegate class to add to the decorated class
    attributes = delegate_cls.__dict__.keys()

    def inner(cls):
        # create property for storing the delegate
        setattr(cls, 'delegate', SimpleProperty())
        # set all the attributes
        for attr in attributes:
            setattr(cls, attr, DelegatedAttribute('delegate', attr))
        return cls
  
    return inner

You may have noticed the creation of something called a SimpleProperty, which won’t be shown. It is simply a descriptor that holds and returns a value given to it, the most basic of properties.

The first thing it does is collect all the attributes from the given class so it knows what to add to the decorated class, preparing it for the inner function that does the real work. The inner function is the real decorator, and it takes that sequence and adds those prepared attributes to the decorated class using DelegatedAttributes.

This may all seem alright, but it doesn’t actually work. The problem is that some of those attributes that we try to add on to the decorated class are ones that come with every class and are read only.

Skipping Some Attributes

So we need to skip over some attributes in the list. I had originally done this by creating a “blacklist” set of attribute names that come standard with every class. But then I realized that implementing a different requirement of the decorator would take care of it already. That requirement is that the decorator not write over any of the attributes that the class had explicitly implemented.

So, if the decorated class defines its own version of a method that the delegate has, the decorator should not add that method. Ignoring those attributes works the same as ignoring the ones I would put into the blacklist, since they’re already defined on the class.

So here’s what the code looks like with that requirement added in.

def delegate_as(delegate_cls):
    # gather the attributes of the delegate class to add to the decorated class
    attributes = set(delegate_cls.__dict__.keys())

    def inner(cls):
        # create property for storing the delegate
        setattr(cls, 'delegate', SimpleProperty())
        # don't bother adding attributes that the class already has
        attrs = attributes - set(cls.__dict__.keys())
        # set all the attributes
        for attr in attrs:
            setattr(cls, attr, DelegatedAttribute('delegate', attr))
        return cls
  
    return inner

As you can see, I turned the original collection of keys into a set and did the same with the attributes from cls. This is because we want to remove one set of keys from the other, and a really simple way to do that is with set‘s subtraction. It could be done with filter or or similar, but I like the cleanliness of this. It’s short, and its meaning is quite clear. I had to store the final result in attrs instead of attributes because the outer call could be reused, so we don’t want to change attributes for those cases.

Some More Parameters

Okay, so we’re now able to automatically delegate any class-level attributes from a base implementation to the decorated class, but that’s often not the only public attributes that an object of a class has. We need a way to include those attributes. We could get an instance of the class and copy all the attributes that are preceded by a single underscore, or we could have the user supply the list of attributes wanted. I personally prefer to take in a user-supplied list. So, we’ll add an additional parameter to the decorator called include, which takes in a sequence of strings that are the names of attributes to delegate to.

Next, what if there are some attributes that will be automatically copied over that we don’t actually want? We’ll add another parameter called ignore to our decorator. It’s similar to include, but is a blacklist instead of a whitelist.

Lastly, we want to get rid of that hard-coded attribute name, "delegate". Why? Because you should almost never hard code anything like that, and because there’s a chance that someone might try to double up on the delegate pattern, delegating to two different objects of different classes. We’d end up with trying to store two different delegates in the same attribute. So we need to give the user the option to make the name whatever they want. We’ll still default it to "delegate" though, so the user won’t have to set it if they don’t need to.

All this leaves us with the final product!

def delegate_as(delegate_cls, to='delegate', include=frozenset(), ignore=frozenset()):
    # turn include and ignore into a sets, if they aren't already
    if not isinstance(include, Set):
        include = set(include)
    if not isinstance(ignore, Set):
        ignore = set(ignore)
    delegate_attrs = set(delegate_cls.__dict__.keys())
    attributes = include | delegate_attrs - ignore

    def inner(cls):
        # create property for storing the delegate
        setattr(cls, to, SimpleProperty())
        # don't bother adding attributes that the class already has
        attrs = attributes - set(cls.__dict__.keys())
        # set all the attributes
        for attr in attrs:
            setattr(cls, attr, DelegatedAttribute(to, attr))
        return cls
    return inner

I also made include and ignore have default values of an empty set. They’re frozensets specifically, despite the fact that the code doesn’t try to mutate them, as a precautionary measure to prevent any future changes from doing so. You might have noticed that the checking of the default parameters is a little different than is typical. That’s because I didn’t make the default parameters default to None. I made them default to something I can use.

Also, I generally expect that people will be passing in lists or tuples more often than sets, so I just made sure that they ended up being a subclass of Set (the ABC, since neither frozenset nor set are subclasses of the other, and users may use some nonstandard Set) so I can do the union and subtraction with them later. It also ensures no doubles anywhere.

Moving Forward

While I’ve shown the final working product, there’s still some refactoring that could be done to make the code cleaner. For instance, you could take all those comments and use Extract Method to make the actual code say such things. If I were to do that, I’d probably change the whole thing to a callable decorator class, rather than a decorator function.

Outro

That was a relatively long article. I could have just shown the final product to you and explained it, but I thought that going through a thought process with it would be nice, too. Are there any changes that you would recommend? What do you guys think of this? Is it worth it?

Jacob Zimmerman

Jacob is a certified Java programmer (level 1) and Python enthusiast. He loves to solve large problems with programming and considers himself pretty good at design.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Back to top button