lettura simple

Descriptors in Python

In Python, a descriptor is an object that lets you control what happens when an attribute of a class is read, written, or deleted.

In other words, it allows you to define "rules" for how an attribute should be accessed, modified, or removed in a class.

For example, imagine you have a class `Person` with an attribute `age`.

class Person:
    def __init__(self, name, age):
       self.name = name
       self.age = age

Create an instance of the class:

p = Person('Mario', 25)

Then print the value of an attribute:

print(p.age)

25

Now, modify the attribute’s value:

p.age = 30

Finally, delete the `age` attribute from the object using the del statement:

del p.age

So far, everything behaves as expected. But what if you want to do something special every time the `age` attribute is read or changed? This is where descriptors become useful.

How do descriptors work?

A descriptor is essentially a class designed to manage how an object’s attributes are accessed, modified, or deleted.

To do this, you can define a descriptor class that implements the following methods:

  • __get__()
    This method is called when an attribute is accessed, and allows you to customize how the value is returned.
  • __set__()
    This method is invoked when an attribute is assigned a new value, letting you control the behavior during assignment.
  • __delete__()
    Called when an attribute is deleted with the `del` statement.

These methods are not part of Python’s base object class, meaning they must be explicitly defined in a custom class to work.

If you want to use this functionality, you need to create a specific class that implements these methods, which is what descriptors are all about.

Here’s an example:

class AgeDescriptor:
    def __init__(self, initial_value=0):
        self.value = initial_value

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

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("Age cannot be negative")
        self.value = value

class Person:
    age = AgeDescriptor()

    def __init__(self, name, age):
        self.name = name
        self.age = age

In this example, we’ve created a descriptor class called `AgeDescriptor` and used it in the `Person` class for the `age` attribute.

The `__set__()` method in the descriptor ensures that negative values cannot be assigned to `age`.

Note that the `AgeDescriptor` class is used only for the `age` attribute in the `Person` class, so it won’t interfere with other attributes like `name`.

Create an instance of the `Person` class:

p = Person('Mario', 25)

Now try to assign a negative value to `age`:

p.age = -5

Each time you access or modify the `age` attribute, Python will call the corresponding method in the descriptor.

In this case, the `__set__()` method of the `AgeDescriptor` class prevents a negative value from being assigned.

Age cannot be negative

Example 2

If you want to make an attribute read-only (meaning it can be read but not modified), you can do so by raising an error in the `__set__()` method.

Create a descriptor class called `ReadOnlyDescriptor`:

class ReadOnlyDescriptor:
    def __init__(self, initial_value):
        self.value = initial_value

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

    def __set__(self, instance, value):
        raise AttributeError("This attribute is read-only")

    def __delete__(self, instance):
        raise AttributeError("This attribute cannot be deleted")

This descriptor allows only reading the attribute, while preventing writing or deleting it.

Now, use the `ReadOnlyDescriptor` class in the `Person` class to make the `type` attribute read-only:

class Person:
    type = ReadOnlyDescriptor("student")

    def __init__(self, name, age):
        self.name = name
        self.age = age

Create an instance of the `Person` class:

p = Person('Mario', 25)

Access the `type` attribute:

print(p.type)

student

Now try to modify the `type` attribute:

p.type = "teacher"

When you try to modify the `type` attribute, Python will call the `__set__()` method of the `ReadOnlyDescriptor` class, which raises an error to prevent the change.

This attribute is read-only

However, you can still modify other attributes like `age` or `name` without any issues:

p.age = 30

This is because the `ReadOnlyDescriptor` was applied only to the `type` attribute, leaving the default behavior for the other attributes.

In general, there are two types of descriptor classes:

  • Data Descriptors
    These are descriptors that implement both the `__get__()` and `__set__()` methods, meaning they can manage both reading and writing to an attribute.
  • Non-Data Descriptors
    These implement only the ` __get__()` method, so they manage only the reading of the attribute.

Attribute Lookup with Descriptors

The process of lookup in Python is how the language searches for and retrieves the value of an attribute (`foo`) when it's accessed through an object (`obj.foo`).

Simply put, it's the series of steps Python follows to locate the attribute—whether it's in the instance, the class, or the base classes—and return its value.

This process also includes checking special mechanisms like descriptors, which can override the standard behavior of attribute access.

When Python performs an attribute lookup (`obj.attribute`), it follows these steps:

  1. Descriptor: First, it checks if the attribute is a descriptor in the class dictionary. If found, the descriptor's `__get__` method is called.
  2. Instance Dictionary (`obj.__dict__`): If it's not a descriptor, Python looks for the attribute in the instance's dictionary and returns it if found.
  3. Class Dictionary (`type(obj).__dict__`): If the attribute isn't found in the instance, Python searches the class dictionary and returns it if it exists. 
  4. Base Classes (MRO): If it's still not found, Python searches the base classes in the Method Resolution Order (MRO).

Descriptors modify the usual lookup mechanism and take precedence over instance attributes.

Even if an attribute with the same name is added to the instance, the descriptor will still have priority.

Let's illustrate this with a descriptor called 'MyDescriptor'.

class MyDescriptor:
    def __init__(self, initial_value):
        self.value = initial_value

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

    def __set__(self, instance, value):
        print("In __set__()")
        self.value = value

Now, define a class 'MyClass' with an attribute 'year' managed by this descriptor.

class MyClass:
    year = MyDescriptor(2020)

Create an instance of MyClass:

obj = MyClass()

Next, add an instance attribute with the same name, 'year':

obj.__dict__['year'] = 2021

When you access the 'year' attribute, Python will invoke the descriptor's `__get__` method first, ignoring the instance attribute.

print(obj.year) 

2020

I hope this explanation clears things up! Feel free to reach out if you have any more questions.




Report a mistake or post a question




FacebookTwitterLinkedinLinkedin