Do Not Make This Mistake with Python Descriptors

posax

Dmitry Posokhov

Posted on July 22, 2024

Do Not Make This Mistake with Python Descriptors

Descriptors provide a powerful way to manage attribute access (like as Django models style 😀), but when used in multiple instances of a class, they can lead to unexpected behavior. In this short article, I'll explain what happened in my work and how to avoid this common issue.

What are Descriptors?
Descriptors are special objects in Python that manage the access to another object's attributes. They implement the methods __get__, __set__, and __delete__. When used correctly, descriptors can greatly enhance the functionality of a class.

The Problem I Noticed:
Recently, while reviewing a colleague's code, I noticed an issue with use of descriptors to manage an attribute across multiple instances of a class. Here’s a simplified version of code:

class MyDescriptor:
    def __init__(self):
        self.value = None

    def __get__(self, instance, owner):
        return self.value

    def __set__(self, instance, value):
        self.value = value
Enter fullscreen mode Exit fullscreen mode

So what is wrong?
Try to use this descriptor in class:

class MyClass:
    attr = MyDescriptor()
Enter fullscreen mode Exit fullscreen mode

Then create a couple of instances of this class:

obj1 = MyClass()
obj2 = MyClass()
Enter fullscreen mode Exit fullscreen mode

And so what? Ok, try to set values to 'attr' in both instances:

obj1.attr = 10
obj2.attr = 20
Enter fullscreen mode Exit fullscreen mode

So, what we see when we try to use 'attr' from first one instance:

print(obj1.attr)  # Expected: 10, but it was 20
print(obj2.attr)  # Expected: 20
Enter fullscreen mode Exit fullscreen mode

Surprisingly (or not 😏), changing the value in obj1 also changed the value in obj2. We are expecting each instance to have its own value, but it turned out that the same descriptor instance was shared across all instances of the class.

Understanding the Issue:
The issue arises because the descriptor is a class-level attribute. This means that the same descriptor instance is used for all instances of the class. Consequently, setting the value on one instance affects all other instances.

The Solution:
To ensure that each instance has its own value, we need to store the value separately for each instance. This can be achieved using a dictionary to hold values for each instance. Here’s the revised code:

class MyDescriptor:
    def __init__(self):
        self._values = {}

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self._values.get(instance, None)

    def __set__(self, instance, value):
        self._values[id(instance)] = value

class MyClass:
    attr = MyDescriptor()

obj1 = MyClass()
obj2 = MyClass()

obj1.attr = 10
obj2.attr = 20

print(obj1.attr)  # Now: 10
print(obj2.attr)  # Now: 20
Enter fullscreen mode Exit fullscreen mode

By using a dictionary to store the values, each instance now correctly maintains its own value without affecting others.

Conclusion:
Descriptors are a powerful tool in Python, but they come with their own set of challenges. When using descriptors, especially in multiple instances of a class, it’s crucial to ensure that each instance maintains its own state. Using a separate storage mechanism like a dictionary can help avoid unexpected behavior.

💖 💪 🙅 🚩
posax
Dmitry Posokhov

Posted on July 22, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related