Когда я только начинал изучать Python
, я впервые столкнулся с дескрипторами. Глядя на примеры с кодом, я никак не мог понять, зачем это вообще нужно, и как я могу использовать это для решения моих задач. В общем, при первом знакомстве дескрипторы показались мне странной вещью, без знания которой вполне можно обойтись. Несколько месяцев назад, я вернулся к теме дескрипторов и полностью изменил свое мнение. И вот почему.
Что такое дескрипторы
Прежде чем углубляться, нам стоит понять, что такое дескрипторы в Python
. Дескрипторы в Python
- это объекты, у которых определен хотя бы один из следующих специальных методов: __get__
, __set__
или __delete__
. Существует два вида дескрипторов: дескрипторы данных и дескрипторы, которые не являются дескрипторами данных. В английском языке для второго типа дескрипторов существует лаконичный термин non-data descriptor. Далее я буду называть их non-data дескрипторами.
non-data дескрипторы
non-data дескрипторы гораздо проще дескрипторов данных. Чтобы превратить объект в non-data дескриптор, достаточно определить для него только специальный метод __get__
. Ниже приведен пример простейшего non-data дескриптора VerboseProperty
:
from typing import Optional
class VerboseProperty:
def __get__(
self,
obj: Optional[object],
objtype: Optional[type] = None,
) -> int:
print("try to get property value")
print(
f"obj is None: {obj is None}",
f"objtype is None: {objtype is None}",
sep="\n",
)
return 42
Для объекта VerboseProperty
определен специальный метод __get__
, поэтому этот объект по определению является non-data дескриптором.
Специальный метод __get__
обладает двумя параметрами: обязательным параметром obj
и необязательным параметром objtype
. Во время вызова __get__
с идентификатором обязательного параметра obj
связывается экземпляр класса, с помощью которого произошло обращение к дескриптору. Если обращение к дескриптору произошло не через экземпляр класса, а через объект-класса, с идентификатором параметра obj
будет связан объект None
.
Со вторым параметром objtype
связывается объект-класса, через экземпляр которого произошло обращение к дескриптору. Если к дескриптору обращались напрямую через объект-класса, то идентификатор параметра objtype
будет связан с этим объектом класса. Этот параметр является необязательным. По умолчанию идентификатор objtype
связан с объектом None
.
В самом методе __get__
из нашего примера не происходит ничего примечательного (еще бы, ведь это обучающий пример!). Мы просто печатаем сообщения в стандартный поток вывода и возвращаем число 42
. В частности в стандартный поток вывода на отдельных строках будут напечатаны результаты сравнения с None
объектов, связанных с идентификаторами obj
и objtype
.
В целом, понятно, что делает __get__
, но все эти описания параметров звучат довольно запутанно. Да и не особо понятно, откуда возьмутся экземпляры классов, и как связывать с ними дескрипторы. Чтобы разобраться с этими моментами, давайте воспользуемся нашим дескриптором:
class MyClass:
point_of_life: int = VerboseProperty()
instance = MyClass()
point_of_life_inst = instance.point_of_life
point_of_life_class = MyClass.point_of_life
print(
f"\n{point_of_life_inst = };\n{point_of_life_class = }",
)
В результате выполнения этого листинга, в стандартный поток вывода будут напечатаны следующие сообщения:
try to get property value
obj is None: False
objtype is None: False
try to get property value
obj is None: True
objtype is None: False
point_of_life_inst = 42;
point_of_life_class = 42
Какие выводы мы можем сделать из этого примера? Первый вывод: чтобы воспользоваться дескриптором, необходимо создать его в качестве атрибута объекта-класса. Действительно, если попытаться определить дескриптор в качестве атрибута экземпляра, никакой магии не будет:
class MyClass:
point_of_life: VerboseProperty
def __init__(self) -> None:
self.point_of_life = VerboseProperty()
instance = MyClass()
point_of_life_inst = instance.point_of_life
print(f"{point_of_life_inst = };")
Из этого примера видно, если определить дескриптор, как атрибут экземпляра класса, то с идентификатором point_of_life_inst
будет связано не целое число со значением 42
, как это было в предыдущем примере.
Отсюда следует второй вывод. При обращении к дескриптору происходит неявный вызов специальных методов. В данном случае мы видели неявный вызов метода __get__
, т.к. после обращения к атрибуту point_of_life
и идентификатор point_of_life_inst
, и идентификатор point_of_life_class
были связаны с целым числом 42
, а не с самим дескриптором.
non-data дескрипторы обычно используются для реализации полей, предназначенных только для чтения, или динамически рассчитываемых полей.
Дескрипторы данных
Для того, чтобы объект был дескриптором данных, для него должен быть определен хотя бы один из специальных методов __set__
или __delete__
. Для начала рассмотрим сигнатуры этих специальных методов:
from typing import Any
class MyDataDescriptor:
def __set__(self, instance: object, value: Any) -> None:
...
Специальный метод __set__
используется для изменения значений атрибутов экземпляров класса. С первым идентификатором параметра instance
данного метода связывается экземпляр класса, через который происходит взаимодействие с дескриптором. Со вторым идентификатором параметра value
связывается новое значение атрибута экземпляра класса, за работу с которым отвечает данный дескриптор.
class MyDataDescriptor:
def __delete__(self, instance: object) -> None:
...
Специальный метод __delete__
используется для удаления атрибутов экземпляров класса. Этот метод имеет всего один параметр - instance
. Назначение этого параметра совпадает с назначением аналогичного параметра метода __set__
.
Как мы видим, оба метода позволяют манипулировать данными экземпляров класса: перезаписывать и удалять их. Эти манипуляции сложнее, чем простое чтение данных, именно поэтому дескрипторы с методами __set__
и __delete__
называются дескрипторами данных.
Особенности работы с дескрипторами
Стоит обратить особое внимание на способ создания дескрипторов. Любой дескриптор всегда создается, как атрибут объекта-класса. Однако дескрипторы данных отвечают за работу с данными экземпляров класса. Это значит, что в самих дескрипторах не стоит хранить информацию, специфичную для экземпляров класса. Иначе вы рискуете получить логическую ошибку, на поиск которой придется потратить немало времени.
В следующем примере в дескрипторе данных DoubledNumber
намеренно допущена такая ошибка, чтобы проиллюстрировать неверную работу с дескрипторами:
from typing import Any, Optional
class DoubledNumber:
number: int
def __init__(self, number: int = 0) -> None:
self.number = number * 2
def __get__(
self,
obj: Optional[object],
objtype: Optional[type] = None,
) -> int:
return self.number
def __set__(self, instance: object, value: Any) -> None:
self.number = int(value) * 2
Дескриптор DoubledNumber
предназначен для чтения и записи удвоенных целых чисел. Этот дескриптор занимается хранением целого числа number
и реализует специальные методы __get__
и __set__
для чтения и записи этого числа. Метод __get__
просто возвращает объект number
вызывающей стороне. Метод __set__
конструирует объект целочисленного типа данных из переданного объекта value
, удваивает его значение и сохраняет полученный объект в атрибут number
.
Однако, как упоминалось выше, само удвоенное число хранится в дескрипторе, а не в экземпляре класса. И это в корне неправильно:
class MyClass:
doubled_number = DoubledNumber()
instance1 = MyClass()
instance2 = MyClass()
print(
f"instance1: {instance1.doubled_number}; "
f"instance2: {instance2.doubled_number};",
)
# вывод: instance1: 0; instance2: 0;
instance1.doubled_number = 5
print(
f"instance1: {instance1.doubled_number}; "
f"instance2: {instance2.doubled_number};",
)
# вывод: instance1: 10; instance2: 10;
В этом листинге кода мы определяем класс MyClass
. С помощью нашего дескриптора во второй строке определяется атрибут doubled_number
. Затем мы создаем два экземпляра класса MyClass
в строках 4-5
и выводим в стандартный поток вывода значения их атрибутов doubled_number
в строках 7-10
. Для обоих экземпляров дескриптор вернет 0
. Пока поведение корректно, ведь объекты были созданы только что, и никаких изменений мы еще не вносили. Далее, в 13
строке, мы перезаписываем значение атрибута doubled_number
экземпляра instance1
c 0
на 5
. И тут мы видим что-то неладное, ведь значение атрибута изменилось для обоих экземпляров. Получается, у нас нет возможности изменять данные экземпляров класса MyClass
независимо.
Ошибка кроется как раз в том, что мы сохранили специфичные для экземпляра данные в дескрипторе. Т.к. дескриптор сам по себе - экземпляр объекта-класса, все данные, которые в нем хранятся, являются общими для всех экземпляров данного класса. Как быть?
Все просто. Обратим внимание, что все сигнатуры специальных методов дескриптора так или иначе имеют параметр, с которым связывается экземпляр класса. Этот параметр позволяет получать доступ к данным, уникальным для данного экземпляра. Мы можем хранить сами данные в экземплярах, а дескриптор будет использоваться как общая логика обработки этих данных при чтении и записи.
Исправим дескриптор DoubledNumber
:
from typing import Any, Optional
class DoubledNumber:
_attr_name: str
def __init__(self, attr_name: str) -> None:
self._attr_name = f"_{attr_name}"
def __get__(
self,
obj: Optional[object],
objtype: Optional[type] = None,
) -> int:
num_default = 0
if obj is None:
return num_default
return obj.__dict__.setdefault(self._attr_name, num_default)
def __set__(self, instance: object, value: Any) -> None:
instance.__dict__[self._attr_name] = int(value) * 2
Теперь дескриптор хранит информацию не о самих данных, а об имени атрибута, который отвечает за хранение этих данных на стороне экземпляров класса. В методе __get__
мы проверяем, как именно происходило обращение к дескриптору. Если обращение к дескриптору произошло через объект-класса, то мы вернем 0
. Если же обращение произошло через экземпляр класса, ты мы прочитаем значение поля специального атрибута экземпляра __dict__[self._attr_name]
и вернем результат. Обратите внимание, что читаем мы значение этого поля с помощью метода словаря setdefault
, чтобы не просто прочитать значение поля, но и определить его, если оно еще не было определено.
В методе __set__
мы просто перезаписываем значение поля с именем self._attr_name
в экземпляре класса.
Теперь все работает корректно:
class MyClass:
doubled_number = DoubledNumber("doubled_number")
instance1 = MyClass()
instance2 = MyClass()
print(
f"instance1: {instance1.doubled_number};",
f"instance2: {instance2.doubled_number};",
)
# вывод: instance1: 0; instance2: 0;
instance1.doubled_number = 5
print(
f"instance1: {instance1.doubled_number};",
f"instance2: {instance2.doubled_number};",
)
# вывод: instance1: 10; instance2: 0;
Дескрипторы и property
Итак, мы поняли, что такое дескрипторы. Также мы поняли, что дескрипторы не хранят сами данные. Вместо этого они описывают общую для всех экземпляров класса логику чтения, записи и удаления данных. В этом плане дескрипторы очень похожи на property
. Так зачем же нам нужны дескрипторы, если в языке уже есть property
?
Представим следующую ситуацию. Мы хотим определить два разных класса. Это независимые классы, никак не связанные друг с другом, поэтому они не имеют общих родительских классов. Но при этом у обоих классов необходимо определить атрибут doubled_number
, логику работы с которым мы описали ранее в дескрипторе DoubledNumber
. Как решить эту проблему с помощью дескрипторов?
С помощью дескрипторов эта проблема решается элементарно. Мы просто используем наш дескриптор при определении двух этих классов, и все работает:
class MyClass1:
doubled_number = DoubledNumber("doubled_number")
class MyClass2:
doubled_number = DoubledNumber("doubled_number")
Все выглядит достаточно аккуратно и лаконично. Логика работы с удвоенными числами вынесена в отдельный объект - дескриптор. Эта логика не множится, не копируется, ее можно использовать повторно как в рамках одного объекта, так и в рамках разных объектов.
Как бы решалась такая проблема с помощью property
? Нам бы пришлось дублировать логику работы с удвоенными числами в двух классах:
class MyClass1:
_number: int
def __init__(self) -> int:
self._number = 0
@property
def doubled_number(self) -> int:
return self._number
@doubled_number.setter
def doubled_number(self, value: int) -> int:
self._number = int(value) * 2
class MyClass2:
_number: int
def __init__(self) -> int:
self._number = 0
@property
def doubled_number(self) -> int:
return self._number
@doubled_number.setter
def doubled_number(self, value: int) -> int:
self._number = int(value) * 2
Это неудобно. Это ведет к неоправданному дублированию логики. Такой код будет сложнее поддерживать. Так что дескрипторы идеально подходят для вынесения логики работы с атрибутами, которая должна быть имплементирована в разных, никак не связанных друг с другом объектах.
Кстати, интересный факт. property
также является дескриптором. getter
, setter
и deleter
вашего property
, фактически, используются для определения специальных методов __get__
, __set__
и __delete__
дескриптора, созданного с помощью property
.
Дескрипторы и поиск
На данном этапе может показаться, что дескрипторы - это интересная, но второстепенная концепция. Да, они позволяют удобно выносить логику работы с атрибутами, да, в Python
есть встроенные объекты-дескрипторы. Но так ли важно знание о них? Я думаю, да, ведь знание о дескрипторах фундаментально для понимания алгоритма поиска значений атрибутов в Python
.
Опишем алгоритм поиска при чтении значения некоторого атрибута экземпляра класса. Опишем именно алгоритм чтения, т.к. это наиболее общий случай поиска, зная логику работы которого, несложно понять принцип работы поиска при изменении или удалении атрибутов. Итак, в этом случае суть алгоритма следующая:
Когда мы пытаемся прочитать атрибут экземпляра какого-либо класса с помощью оператора
.
(dot notation), интерпретатор сначала проверит атрибут__dict__
у объекта-класса данного экземпляра. Т.е. когда в одном из первых примеров мы обращались к атрибутуpoint_of_life
объектаinstance
, интерпретатор не сразу обращался кinstance.__dict__
. Вместо этого он сначала выполнял обращение кMyClass.__dict__
. Интерпретатор обращается к этому полю, чтобы найти в нем ключ с именем указанного атрибута (в нашем случаеpoint_of_life
), который был бы дескриптором данных. На данном шаге дескрипторы, которые не являются дескрипторами данных, игнорируются.Если на прошлом шаге был найден подходящий дескриптор данных с реализованным методом
__get__
, то вызывающей стороне будет возвращен результат выполнения метода__get__
данного дескриптора. На этом поиск заканчивается.Если дескриптор данных не найден, то интерпретатор переходит к поиску в атрибуте
__dict__
данного экземпляра класса, в нашем случаеinstance
. Если на этом шаге вinstance.__dict__
будет найден объект с нужным именем, то интерпретатор вернет его. На этом поиск завершитсяЕсли и на этот раз ничего не будет найдено, интерпретатор попытается найти подходящий дескриптор, который не является дескриптором данных, в атрибуте
__dict__
объекта-класса (MyClass.__dict__
).Если подходящего дескриптора не будет, то интерпретатор попытается найти обычный объект с подходящим именем в атрибуте
__dict__
объекта-класса. В случае неудачи, интерпретатор займется поиском в атрибутах__dict__
родительских классов в порядке, определенномMRO
Если искомый атрибут не будет найден и ни в одном из родительских классов, будет возбуждено исключение
AttributeError
Звучит муторно, поэтому я проиллюстрировал принцип работы алгоритма логической схемой поиска атрибута b
экземпляра класса, связанного с идентификатором a
.

Для объектов-классов алгоритм поиска практически аналогичен. Единственное отличие заключается в отсутствии фазы поиска в атрибуте __dict__
экземпляра, по понятным причинам.
За реализацию этого механизма поиска отвечает специальный метод __getattribute__
(не путать с __getattr__
). __getattribute__
определен в объекте object
, наследование от которого происходит автоматически. Сам метод реализован на C
, однако в официальной документации есть пример реализация на Python
. Этот метод не рекомендуется переопределять без веских причин, т.к. вы рискуете поломать логику поиска атрибутов для своего объекта.
Преисполняемся в работе с дескрипторами
Рассмотрим практический пример, используя все наши знания о дескрипторах.
Предположим, что у нас есть некоторый объект, который отвечает за сетевое взаимодействие с каким-либо ресурсом. Сам объект выполняет следующие функции: создает сетевое соединение с ресурсом и предоставляет интерфейс для обмена с ним сетевыми сообщения. Также допустим, что наш объект является частью большой распределенной системы. Мы не знаем, когда именно компонентам системы может потребоваться соединение с ресурсом, и потребуется ли оно вообще, но мы знаем, что создание соединения занимает много времени. Поэтому логично установить соединение не в момент инициализации объекта, а по факту требования.
Реализовать такое поведение можно на уровне внутренней логики нашего объекта. Однако такой подход захламит методы объекта явными проверками наличия установленного соединения. Можно реализовать соединение с помощью property
и унести все проверки в getter
и setter
. Но если нам потребуется переиспользовать данную логику в другом объекте и для другого атрибута, мы получим дублирование кода.
Поэтому задачу мы решим, написав свой дескриптор LazyProperty
:
class LazyProperty:
_method: Callable[[], str]
_name: str
def __init__(self, method: Callable[[], str]) -> None:
self._method = method
self._name = method.__name__
def __get__(
self,
obj: Optional[object],
_: Optional[type] = None,
) -> str:
obj.__dict__[self._name] = self._method(obj)
return obj.__dict__[self._name]
Для инициализации дескриптору будет требоваться метод установки соединения. В __init__
дескриптор сохранит метод и его имя. В __get__
дескриптор устанавливает соединение и сохраняет его в __dict__
объекта, с которым связан метод установки соединения. Отдельно отмечу, что LazyProperty
не является дескриптором данных, т.к. в нем не реализован ни метод __set__
ни метод __delete__
.
Пример использования нашего дескриптора может выглядеть так:
import time
class MyClass:
@LazyProperty
def connection(self) -> str:
time.sleep(5)
return "127.0.0.1"
my_class = MyClass()
print(my_class.connection) # ждем 5 секунд
print(my_class.connection) # не ждем ни секунды
В этом примере первое обращение к полю connection
приведет к вызову метода __get__
нашего дескриптора. Но последующие обращения к полю connection
не будут приводит к установке нового соединения. Это работает так из-за алгоритма поиска атрибутов, который мы обсуждали ранее. Во время первого обращения к connection
в распоряжении интерпретатора есть только non-data дескриптор. После вызова метода __get__
в распоряжении интерпретатора появляется объект из поля __dict__
экземпляра класса MyClass
. Поскольку во время поиска вызов __get__
non-data дескриптора имеет меньший приоритет, последующие обращения к connection
не запускают повторную установку соединений.
Кстати, в этом примере мы фактически реализовали cached_property
.
Итоги
Итак, какие давайте резюмировать, что мы с вами узнали:
Мы поняли, что дескрипторы - это объекты, у которых определен один из специальных методов
__get__
,__set__
или__delete__
.Дескрипторы бывают двух типов: дескрипторы данных и non-data дескрипторы. Чтобы определить дескриптор данных, необходимо определить специальный метод
__set__
или__delete__
. Чтобы определить non-data дескриптор, нужно определить только__get__
.Дескрипторы используются для описания логики чтения, записи и удаления данных атрибутов экземпляров классов. Сами уникальные данные экземпляров в дескрипторах хранить не надо.
Дескрипторы используются в алгоритме поиска значений атрибутов в
Python
. Дескрипторы данных имеют самый высокий приоритет во время поиска. non-data дескрипторы имеют более низкий приоритет и уступают в порядке поиска дескрипторам данных и атрибутам экземпляров класса. Этот факт можно использовать для ленивого определения атрибутов.Многие объекты
Python
, с которыми мы работаем на регулярной основе - это дескрипторы. Один из таких объектов, который мы обсуждали выше - этоproperty
. Также такими объектами являются методы классов. Кстати, об этом я писал в своем телеграм-канале, так что, можете ознакомиться, если тема дескрипторов вас заинтересовала.
Источники
Для подготовки этого материала я активно пользовался официальной документацией Python
. В частности я использовал материалы из раздела Data Model, которые посвящены описанию спецификации дескрипторов. Отдельно отмечу официальный гайд по работе с дескрипторами.
Помимо официальной документации, мне хочется отметить статью про дескрипторы от RealPython. Она очень хорошая.
P.S. Также призываю вас подписаться на мой канал в телеграме, в котором я пишу о Python
и разработке.