Python

Another Look at Instance-Level Properties in Python

A while back, I did a post on making instance-level properties in Python where the implementation of it required inheritance as well as messing with __getattribute__() and __setattr__(), which are a little dangerous to mess with. Then I had another idea last night (as of the time of writing this): Use normal descriptors to delegate to “Delegated Properties” (name taken from Kotlin, the original inspiration). These Delegated Properties can be designed very simply in a way that they only have to worry about the value on one instance, instead of figuring out how to store the value per instance.

A note before we begin: This was going to be my first article where I put type annotations on all of my Python code in order to help make it more readable. While typing up all the code to make sure this idea was viable, I did actually add type annotations everywhere, but seeing that this idea is super generic with very few actually helpful type annotations, I decided to forgo them in this article. You can be sure that the next time I write a Python article, the code will be annotated.

Also, a disclaimer: this article assumes that you have a basic understanding of descriptors in Python. If you need to learn about them first, you can start with Raymond Hettinger’s official guide or you can get my book if you really want to learn about them.

The Delegated Property Interface

I’d first like to go over the interface of a Delegated Property, and I’ll do that by giving you the code for the Abstract Base Class:

from abc import ABCMeta, abstractmethod
 
 
class DelegatedProperty(metaclass=ABCMeta): 
    @abstractmethod
    def get(self, instance, name): ...
 
    def set(self, value, instance, name):
        raise AttributeError

Now let’s discuss why I did it the way I did. First off, we’ll look at the common parameters between the two methods. Technically, self will almost always be the only one you need, but instance is there in case the property requires something else that’s on the instance. Please don’t use this to store the value on the instance; store the value on the Delegated Property, since storing it on the instance may interfere with how the descriptor is storing the Delegated Property. name is the name that the descriptor has on the class. This will usually be used for a possible exception that needs to be raised so the message can refer to the attribute being accessed and therefore be more informative. These “metadata” parameters were also inspired by Kotlin’s Delegated Properties.

Then there’s the value parameter in set(). This, if it’s not obvious, is what you’re setting the property value to.

You probably noticed that get() is an abstractmethod while set() is not. The reason for this is that set() is optional. You only need to define set() when you want the property to be mutable. And, unlike with descriptors, Delegated Properties are initialized with their starting value in __init__(), so set() isn’t needed in order to get that. That removes all the difficulty of implementing a “read-only” descriptor, which I spent an entire chapter on in my book.

Happily, whether or not you decide to create a Delegated Property without inheriting from DelegatedProperty (inheriting from it means that you’re also inheriting the metaclass which can get in the way of multiple inheritance), making a read-only one is as simple as not implementing it. In either case, when the descriptor attempts to call set(), it’ll receive an AttributeError, which it will interpret to mean that the property is read-only, and it will raise its own appropriate AttributeError.

You may be wondering why there isn’t a delete() method. I started off having it, but later decided to oust it; I don’t think there’s a single good reason to delete an instance attribute. Any time that seems appropriate could either be replaced by setting the attribute to None (or some other type of Null Object) or refactoring to a more stable design overall. The reason that setting to None is preferable is that it works better with Python’s new compact, key-sharing dictionary used for instance attributes.

Basic Property Example

To demonstrate how to make a Delegated Property, I’ll make a basic one here:

class BasicProperty(DelegatedProperty):
    def __init__(self, value):
        self.value = value
 
    def get(self, *args):
        return self.value
 
    def set(self, value, *args):
        self.value = value

Besides showing the basic flow of how to write a Delegated Property, it also shows a little shortcut you can use if you don’t plan to use instance or name; you can replace them with *args.

Let’s say you want to redesign it so it can optionally be read-only. It would look more like this:

class BasicProperty(DelegatedProperty):
    def __init__(self, value, *, readonly=False):
        self.value = value
        self._readonly = readonly
 
    @classmethod
    def readonly(cls, value):
 
    def get(self, *args):
        return self.value
 
    def set(self, value, *args):
        if self._readonly:
            raise AttributeError
        else:
            self.value = value

There’s three changes to pay attention to. First, a readonly boolean was added to the constructor arguments. It was added as keyword-argument-only with a default value so instantiation of a BasicProperty is more obvious. The second thing, the readonly() class method makes typical instantiation even more obvious, but that’s not quite why it’s there. We’ll get there in a later part of the article when we’re looking at using the whole instance property “framework”. Thirdly, in the set() method, you can see that it decides to raise an AttributeError when self._readonly is True, instead of the alternative of setting the value.

The InstanceProperty Descriptor

Now that we know what a Delegated Property looks like and have a general understanding of how to make one, let’s look into the descriptor that makes using them super easy. What’s really interesting about this is that you could actually redesign the descriptor to tweak how it all works, but I’m just going to show you the default case and explain my reasons for certain choices.

To give you an idea of how I want to create and use them, I’ll give an example:

class Foo:
    bar = InstanceProperty(BasicProperty)
 
    def __init__(self, bar):
        self.bar.initialize(bar)

So, creating an InstanceProperty requires a callable that can be used to initialize a new Delegated Property. Nicely, simply passing the class name usually works great.

Next, you’ll see the use of initialize() to set the initial value. While it would be nice to simply write self.bar = bar there, initialize() is used to call the callable that was passed into InstanceProperty, and that callable could easily ask for more than just the starting value, such as passing in readonly=True. In this case, it can circumvented by using bar = InstanceProperty(BasicProperty.readonly) instead, but there are actually cases will require 0 or many arguments, so we can’t just set the value. It’s possible to set up __set__() to work that way when there is just the initial value, but we already have initialize() and putting in an alternative could end up with code that mixes both in one class and becomes confusing. It also makes __set__() that much more complicated to implement.

Let’s implement what we need so far:

class InstanceProperty:
    def __init__(self, instantiator):
        self._create = instantiator
 
    def initialize(self, *args, **kwargs):
        delprop = self._create(*args, **kwargs)
        # here we assign/associate delprop with its instance
 
    def __get__(self, instance, owner=None):
        return self

So, we’ve run into a problem. We currently have what we need here to make the calls in the Foo example, but it’s not even enough to make that little bit work. As the comment in initialize() says, we need to assign the Delegated Property to the instance of Foo somehow. To do that, initialize() needs the instance, so it’s rewritten as (with new code in bold)

def initialize(self, instance, *args, **kwargs):
    delprop = self._create(*args, **kwargs)
    setattr(instance, !!THE NAME!!, delprop)

That looks okay, but other than lacking the name to assign the property to (we’ll get to that in a moment), we now have a new problem. With the inclusion of the instance in the parameter list, initializing bar now looks like this: self.bar.initialize(self, bar). The extra use of self is annoying, especially since we already gave the descriptor self when we indirectly called __get__(). So, what if we returned something else from __get__() with an initialize() method that already knows the instance? Something like this?

class InstancePropertyInitializer:
    def __init__(self, instanceprop, instance):
        self.instanceprop = instanceprop
        self.instance = instance
 
    def initialize(self, *args, **kwargs):
        self.instanceprop.initialize(self.instance, *args, **kwargs)

Now, InstanceProperty‘s __get__() method looks like this:

def __get__(self, instance, owner=None):
    return InstancePropertyInitializer(self, instance)

Next, let’s look into getting that name we want. Well, the library I wrote to go along with my book includes a function called name_of() that takes a descriptor and a class that has the descriptor as an attribute (the class can be a subclass of the one that actually has it, if need be). And since we’ll need the name elsewhere, we’ll set it in __get__(). And there’s actually two different names we’ll need. We’ll need the name of the attribute the descriptor is stored under on the class, and we’ll need the name we’ll be using to store the Delegated Property on the instance. The changes look like this:

class InstanceProperty:
    def __init__(self, instantiator):
        self._create = instantiator
        self._name = None
        self._on_instance_name = None
 
    def initialize(self, instance, *args, **kwargs):
        delprop = self._create(*args, **kwargs)
        setattr(instance, self._on_instance_name, delprop)
 
    def __get__(self, instance, owner=None):
        owner = owner if owner is not None else type(instance)
        self._set_names(owner)
        return InstancePropertyInitializer(self, instance)
 
    def _set_names(self, owner):
        if self._name is None:
            self._name = name_of(self, owner)
            self._on_instance_name = '_' + self._name

We can set the names in __get__() because it has the owner, and it will be the first method called if everything is used correctly. I don’t like being that flaky with it, but I like it better than the other alternatives I came up with.

Let’s finish this up:

class InstanceProperty:
    def __init__(self, instantiator):
        self._create = instantiator
        self._name = None
        self._on_instance_name = None
 
    def initialize(self, instance, *args, **kwargs):
        delprop = self._create(*args, **kwargs)
        setattr(instance, self._on_instance_name, delprop)
 
    def __get__(self, instance, owner=None):
        if instance is None:
            return self  # also check out my article that mentions unbound attributes.
        owner = owner if owner is not None else type(instance)
        self._set_names(owner)
        try:
            delprop = self._get_deleg_prop(instance)
        except AttributeError:
            # if this happens, then the property has not yet been initialized on
            # the instance yet, which means we need to return an initializer
            return InstancePropertyInitializer(self, instance)
        return delprop.get(instance, self._name)
 
    def __set__(self, instance, value):
        delprop = self._get_deleg_prop(instance)
        try:
            delprop.set(value, instance, self._name)
        except AttributeError:
            raise AttributeError(f"Cannot change value of read-only attribute, '{self._name}'")
 
    def _set_names(self, owner):
        if self._name is None:
            self._name = name_of(self, owner)
            self._on_instance_name = '_' + self._name
 
    def _get_deleg_prop(self, instance):
        try:
            return getattr(instance, self._on_instance_name)
        except AttributeError:
            raise AttributeError(f"Instance property, '{self._name}', not yet initialized")

We added a helper method that gets the Delegated Property from the instance, since that has to be done a couple times. If there’s an AttributeError, that means that the Delegated Property hasn’t been initialized yet. Hopefully, we’re in __get__() and it’ll return an initializer, but we could be in __set__(), so we need a good error message to instruct the user on their failure to use the descriptor properly.

In __get__() we retrieve the Delegated Property, and if that fails with an AttributeError, then we know that the Delegated Property isn’t initialized yet. I could have done a hasattr() check instead of a try...except block, but since the hasattr() will only return False once per instance, as opposed to the unknown number of times that it will get accessed after creation, I decided to ask for forgiveness rather than permission and save the time required for the check. I would have liked to have kept return delprop.get(instance, self._name) inside the block, but if that raises an AttributeError, something bad is actually happening and it should propagate upwards.

The code in __set__() is pretty straightforward; it gets the Delegated Property, then delegates to it. But if set() raises an AttributeError, that means the Delegated Property wants to be a read-only one, and we raise another AttributeError with the appropriate error message.

Outro

That’s it for this week. Next week, I’ll make a few more Delegated Properties, show some other little bits to make using all this a little nicer and even look more like it does in Kotlin. See you then!

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