Если обратиться к документации, то дескриптор — механизм, который позволяет объектам настраивать поиск, хранение и удаление атрибутов.

Дескрипторы используются в классах, выступая в роли атрибутов класса(не экземпляра).

Думаю, мало кто, хотя бы раз, сам писал дескрипторы в коммерческой разработке, но я уверен, что большинство программистов используют механизмы, которые являются дескрипторами, или используют их "под капотом":

  • classmethod, staticmethod

  • property

  • __slots__

  • В конечном итоге, любое обращение к атрибуту класса, связано с дескрипторами.

Некоторые из них, мы реализуем с вами немного позднее.

Протокол дескрипторов состоит из:

  • __get__ - Поиск атрибута

  • __set__ - Хранение атрибута

  • __delete__ - Удаление атрибута

Начнём с поиска атрибута - __get__ .

class StaticValueDescriptor:
    def __init__(self, value: Any) -> None:
        self._value = value
        
    def __get__(self, instance, owner=None) -> Any:
        return self._value

  
class Object:
    attr_1 = StaticValueDescriptor(100)

    
instance_object = Object()
instance_object.attr_1  # -> 100

Несмотря на то, что в attr_1 мы присвоили экземпляр StaticValueDescriptor, в ответе мы видим 100, но почему?

Дело в том, что при поиске атрибута класса(getattribute), python использует примерно такой механизм(подчеркнул одинаковые куски алгоритма):

  • attr_1 есть в __dict__ класса?

    • Да. Есть ли у него __get__ и (__set__ или __delete__)(data descriptor)?

      • Да. Для получения результата используется дескриптор.

      • Нет. attr_1 есть в __dict__ экземпляра?

        • Да. Вернуть результат из __dict__ экземпляра.

        • Нет. attr_1 есть в __dict__ класса?

          • Да. Есть ли у него __get__ (non-data descriptor)?

            • Да. Для получения результата используется дескриптор.

            • Нет. Вернуть результат из __dict__ класса.

          • Нет. Вызов у класса getattr , который по умолчанию возбудит исключение AttributeError.

    • Нет. attr_1 есть в __dict__ экземпляра?

      • Да. Вернуть результат из __dict__ экземпляра.

      • Нет. attr_1 есть в __dict__ класса?

        • Да. Есть ли у него __get__ (non-data descriptor)?

          • Да. Для получения результата используется дескриптор.

          • Нет. Вернуть результат из __dict__ класса.

        • Нет. Вызов у класса getattr , который по умолчанию возбудит исключение AttributeError.

Согласен, некоторым, такое представление алгоритма покажется сложным. Я упрощу его и покажу лишь приоритеты получения атрибута при поиске:

  1. Data descriptor - дескриптор, у которого есть __get__ и (__set__ или __delete__)

  2. Instance.__dict__

  3. Следующие два варианта имеют одинаковый приоритет.

    1. Non-data descriptor - дескриптор, у которого есть только __get__

    2. Class.__dict__

Мы ещё вернёмся к примерам, которые продемонстрируют работу приоритетов на деле, а пока давайте вернёмся к протоколу дескрипторов.

Ещё не дойдя до __set__ или __delete__ у вас, может возникнуть вопрос - "а как связать конкретный дескриптор с конкретным атрибутом? Я не хочу использовать только хардкодные статические объекты". И правда, предыдущий пример со StaticValueDescriptor имеет достаточно узкую применимость.

Решением с ходу является расширение __init__. И давайте сразу реализуем __set__:

class DataDescriptor:
    def __init__(self, attr_name: str) -> None:
        self._attr_name = attr_name
    
    def __get__(self, instance, owner=None) -> Any:
        return instance.__dict__[self._attr_name]

    def __set__(self, instance, value: Any) -> None:
        instance.__dict__[self._attr_name] = value

        
class Object:
  attr_1 = DataDescriptor("attr_1")

  
instance_object = Object()
instance_object.attr_1 = 100
instance_object.attr_1  # -> 100

В целом мы решили проблему, и смогли связать имя атрибута в рамках класса, где используется дескриптор, но можно сделать ещё лучше, используя __set_name__:

class DataDescriptor:
    def __set_name__(self, owner, attr_name: str) -> None:
        self._attr_name = attr_name

    def __get__(self, instance, owner=None) -> Any:
        return instance.__dict__[self._attr_name]

    def __set__(self, instance, value: Any) -> None:
        instance.__dict__[self._attr_name] = value

        
class Object:
    attr_1 = DataDescriptor()

    
instance_object = Object()
instance_object.attr_1 = 100
instance_object.attr_1  # -> 100

Используя метод __set_name__ нам не нужно пробрасывать название переменной снаружи. Хотя уверен, найдутся те, кому нужно это:

class DataDescriptor:
    def __init__(self, attr_name: str) -> None:
        self._attr_name = attr_name
    
    def __get__(self, instance, owner=None) -> Any:
        return instance.__dict__[self._attr_name]

    def __set__(self, instance, value: Any) -> None:
        instance.__dict__[self._attr_name] = value

        
class Object:
    attr_1 = DataDescriptor("attr_999")  # Не надо так ?

    def __init__(self):
        self.attr_1 = 123

        
instance_object = Object()
instance_object.attr_999  # -> 123

Ну и напоследок у нас остаётся __delete__ :

class DataDescriptor:
    def __set_name__(self, owner, attr_name: str) -> None:
        self._attr_name = attr_name
    
    def __get__(self, instance, owner=None) -> Any:
        return instance.__dict__[self._attr_name]

    def __set__(self, instance, value: Any) -> None:
        instance.__dict__[self._attr_name] = value
    
    def __delete__(self, instance) -> None:
        del instance.__dict__[self._attr_name]

        
class Object:
    attr_1 = DataDescriptor()

    
instance_object = Object()
instance_object.attr_1 = 100
instance_object.attr_1  # -> 100
del instance_object.attr_1
instance_object.attr_1  # -> KeyError: 'attr_1'

Ничего не смущает? А что это за KeyError при получении атрибута класса? Непорядок!

Дело в том, что при вызове __get__ , наша попытка достать результат из instance.__dict__ терпит неудачу, так как такого ключа в словаре нет.

Получается, нам нужно обработать исключение KeyError в __get__ , __set__ , __delete__ и следовать алгоритму, описанному выше. Кажется, не очень удобным делать это руками, неправда ли? Выход есть!

Первый шаг - использовать встроенные механизмы(getattr, setattr, delattr), которые дадут нам корректное поведение:

class DataDescriptor:
    def __set_name__(self, owner, attr_name: str) -> None:
        self._attr_name = attr_name
    
    def __get__(self, instance, owner=None) -> Any:
        return getattr(instance, self._attr_name)

    def __set__(self, instance, value: Any) -> None:
        setattr(instance, self._attr_name, value)
    
    def __delete__(self, instance) -> None:
        delattr(instance, self._attr_name)

        
class Object:
    attr_1 = DataDescriptor()

    
instance_object = Object()
instance_object.attr_1 = 100
instance_object.attr_1  # -> 100
del instance_object.attr_1
instance_object.attr_1  # -> RecursionError: maximum recursion depth
                        # exceeded while calling a Python object

Кажется, у нас проблемы? При попытке доступа к attr_1 происходит следующее:

  • Мы всё по тому же алгоритму выбираем источником данных дескриптор и попадаем в __get__ .

  • Внутри мы используем getattr(instance, self._attr_name, value)

  • Который, в свою очередь пытается получить из экземпляра класса атрибут attr_1(по сути, то же самое, что мы пытались сделать изначально)

  • И мы снова попадаем в дескриптор и так по кругу.

И на эту проблему есть решение!

Второй шаг - добавить защищённый(protected) вариант имени атрибута:

class DataDescriptor:
    def __set_name__(self, owner, attr_name: str) -> None:
        self._protected_attr_name = f"{attr_name}"
    
    def __get__(self, instance, owner=None) -> Any:
        return getattr(instance, self._protected_attr_name)

    def __set__(self, instance, value: Any) -> None:
        setattr(instance, self._protected_attr_name, value)
    
    def __delete__(self, instance) -> None:
        delattr(instance, self._protected_attr_name)


class Object:
    attr_1 = DataDescriptor()

    
instance_object = Object()
instance_object.attr_1 = 100
instance_object.attr_1  # -> 100
del instance_object.attr_1
instance_object.attr_1  # -> AttributeError: 'Object' object has no attribute
                        # '_attr_1'. Did you mean: 'attr_1'?

Теперь мы добились корректной ошибки!

Возможно, некоторые из вас зададутся вопросом - "Куда делась проблема с рекурсией? Кажется, что в коде нет существенных изменений."

Давайте разберём, что изменилось при обращении к attr_1:

  • Мы всё по тому же алгоритму выбираем источником данных дескриптор и попадаем в __get__ .

  • Внутри мы используем getattr(instance, self._attr_name, value)

  • Который пытается получить из экземпляра класса не attr_1, а _attr_1. _attr_1 не является дескриптором и поэтому не повлечёт за собой повторный вызов __get__ и зацикливание, приводящее к ошибке(RecursionError)

Таким образом, в экземпляре класса будет установлен атрибут _attr_1 , который не является дескриптором:

instance_object = Object()
instance_object.__dict__  # -> {}
instance_object.attr_1 = 100
instance_object.__dict__  # -> {"_attr_1": 100}

И напоследок, давайте разберём разницу между data и non-data дескрипторами. Она заключается в приоритетах поиска атрибута.

Data descriptor имеет __get__ и (__set__ или __delete__):

class StaticValueDescriptor:
    def __init__(self, value: Any) -> None:
        self._value = value
    
    def __set_name__(self, owner, attr_name: str) -> None:
        self._private_attr_name = f"_{attr_name}"

    def __get__(self, instance, owner=None) -> None:
        return self._value

    def __set__(self, instance, value: Any) -> None:
        setattr(instance, self._private_attr_name, value)

        
class Object:
    attr_1 = StaticValueDescriptor(100)
    
    def __init__(self):
        self.attr_1 = 200

        
object_ = Object()
object_.attr_1  # -> 100 - data descriptor имеет приоритет
                # над __dict__ экземпляра класса

Стоит нам закомментировать/удалить метод __set__, и мы получим non-data descriptor, у которого меньший приоритет, чем у __dict__ класса.

class StaticValueDescriptor:
    ...
    
    # def __set__(self, instance, value: Any) -> None:
    #    setattr(instance, self._private_attr_name, value)

    
class Object:
    attr_1 = StaticValueDescriptor(100)
    
    def __init__(self):
        self.attr_1 = 200

        
object_ = Object()
object_.attr_1  # -> 200

После того, как мы получили описание работы дескрипторов, предлагаю вернуться к началу статьи и обсудить механизмы, которые так или иначе связаны с дескрипторами. Я покажу одну из возможных реализаций этих механизмов на python.

staticmethod - Это non-data descriptor с одной задачей - просто вернуть тот же метод, что и был получен. Таким образом, мы избавляемся от self.

Ваша idea может указывать на ошибку, но на самом деле всё будет работать.
Ваша idea может указывать на ошибку, но на самом деле всё будет работать.
class StaticMethod:
    def __init__(self, method: Callable) -> None:
        self._method = method
    
    def __get__(self, instance, owner=None) -> Any:
        return self._method

      
class Object:
    @StaticMethod
    def attr_1():
        return 100

      
object_ = Object()
object_.attr_1()  # -> 100

classmethod - Не что иное, как non-data descriptor, который создаёт обёртку над изначальным методом, и вместо экземпляра класса(self), передаёт первым аргументом класс(cls).

class ClassMethod:
    def __init__(self, method: Callable) -> None:
        self._method = method
    
    def __get__(self, instance, owner=None) -> Any:
        @wraps(self._method)
        def wrapper(*args, **kwargs):
            return self._method(owner, *args, **kwargs)
        return wrapper

      
class Object:
    @ClassMethod
    def attr_1(cls):
        return cls

      
object_ = Object()
object_.attr_1()  # -> <class 'main.Object'>

property - В угоду пониманию, я написал упрощённый вариант, который не во всех ситуациях будет работать так же, как бы работал привычный нам property. Однако при базовом использовании, он делает именно то, что от него ожидается:

class Property:
    def __init__(
        self,
        fget: Callable | None = None,
        fset: Callable | None = None,
        fdel: Callable | None = None,
    ) -> None:
        self._fget = fget
        self._fset = fset
        self._fdel = fdel
    
    def getattr(self, fget: Callable) -> Property:
        return type(self)(fget, self._fset, self._fdel)
    
    def setattr(self, fset: Callable) -> Property:
        return type(self)(self._fget, fset, self._fdel)
    
    def deleter(self, fdel: Callable) -> Property:
        return type(self)(self._fget, self._fset, fdel)
    
    def __get__(self, instance, owner=None) -> Any:
        return self._fget(instance)
    
    def __set__(self, instance, value) -> None:
        self._fset(instance, value)
    
    def __delete__(self, instance) -> None:
        self._fdel(instance)

        
class Object:
    def __init__(self, value: Any) -> None:
        self._attr_1 = value
        
    @Property
    def attr_1(self):
        return self._attr_1

    @attr_1.setattr
    def attr_1(self, value: Any) -> None:
        self._attr_1 = value
        
    @attr_1.deleter
    def attr_1(self) -> None:
        self._attr_1 = None

        
object_ = Object(100)
object_.attr_1  # -> 100
object_.attr_1 = 200
object_.attr_1  # -> 200
del object_.attr_1
object_.attr_1  # -> None

И в завершение без возможной реализации, хочется показать, что __slots__ тоже относится к дескрипторам:

class Object:
    __slots__ = ("attr_1", "attr_2")

    
type(Object.attr_1)  # -> <class 'member_descriptor'>
type(Object.attr_2)  # -> <class 'member_descriptor'>

Спасибо всем, кто справился и дочитал статью до конца! Это была вводная статья о дескрипторах. Возможно, в будущем я напишу вторую часть, и покажу какие ещё вещи можно реализовывать с помощью этого протокола.

Комментарии (3)


  1. jc_42
    04.05.2024 10:47
    +1

    Прекрасная статья, спасибо!


    1. KarmanovichDev Автор
      04.05.2024 10:47

      Я рад, что вам понравилось )


  1. IvanZuev78
    04.05.2024 10:47

    Сложно такое читать утром, когда только проснулся. Прочитаю вечером.