Дескрипторы: одно правило приоритета, из которого растут property, методы и слоты
Дескриптор — это объект в классе, у которого есть __get__, __set__ или __delete__. Всё остальное — следствие одной строчки: дескриптор данных сильнее словаря экземпляра, дескриптор не-данных — слабее. Отсюда и работающий cached_property, и падающий @classmethod под чужим декоратором.
Полное техническое изложение
TL;DR
Дескриптор — это объект, который лежит в КЛАССЕ и сам решает, что вернуть, когда у ЭКЗЕМПЛЯРА спрашивают атрибут. Обращение через точку не всегда означает чтение поля: свойство при обращении выполняет код, а обычная функция из класса превращается в связанный метод. И то и другое — дескрипторы; отдельного механизма для них в языке нет.
Отсюда главное следствие: признак дескриптора ровно один — у его типа есть
__get__, __set__ или __delete__. Дескрипторами уже являются property,
обычный метод, staticmethod, classmethod, слот __slots__ и
functools.cached_property, то есть вы пользуетесь ими каждый день — и,
например, obj.method = ... проходит, а obj.prop = ... падает именно
поэтому.
Дальше — одна строчка приоритета, из которой выводится остальное.
Дескриптор данных (есть __set__ или __delete__) сильнее словаря
экземпляра, дескриптор не данных (только __get__) — слабее. Из неё
следует и то, почему cached_property кеширует, и то, почему @classmethod
под чужим декоратором падает — молча на определении класса и TypeError при
первом вызове. Сам протокол при этом ничего не стоит: слот — 17,04 нс против
17,00 у обычного атрибута на 3.13.7; дорог не он, а геттер, написанный на
Python (property — ×2,06).
- в классе объявляют методы, а у экземпляра есть свои данные;
- запись
obj.attrчитает атрибут, аobj.attr = …его меняет; - функция, объявленная в классе, вызывается у экземпляра как метод и получает его первым аргументом.
__get__,__set__,__delete__,__set_name__, деление на дескрипторы данных и не-данных;object.__getattribute__, порядок разрешения методов, метаклассы,__slots__,functools.cached_property.
База: почему точка — не всегда чтение поля
Привычное представление об атрибуте простое: у объекта есть словарь, и
obj.attr достаёт оттуда значение. Пока в классе лежат только данные, так оно
и есть.
Но два инструмента, которыми пользуются каждый день, в эту картину не укладываются.
Первый — свойство. Обращение obj.price выглядит как чтение поля, а на
самом деле выполняет код: тело метода, помеченного @property. Никакого
значения price в экземпляре может не быть вовсе.
Второй — обычный метод. В классе лежит одна функция на весь класс. Но
Класс.метод даёт саму эту функцию, а экземпляр.метод — уже связанный
метод, который сам подставит экземпляр первым аргументом. Объект в классе
один, а результат обращения зависит от того, у кого спросили.
Отсюда и вопрос, на который отвечает весь урок: кто и в какой момент превращает объект, лежащий в классе, в то, что возвращает точка?
Ответ: сам этот объект. В языке есть договорённость: если у объекта, найденного
в классе, определён один из трёх специальных методов — __get__, __set__,
__delete__, — то обращение к атрибуту не отдаёт его самого, а зовёт его метод
и отдаёт результат. Такие объекты называются дескрипторами, а три метода —
протоколом дескрипторов. Свойство и функция устроены именно так; ничего
специального под них в языке не заводили.
Второе, что нужно знать сразу, — кто сильнее. Значение под тем же именем
может лежать и в самом экземпляре, и тогда на одно обращение два претендента:
запись в экземпляре и дескриптор в классе. Побеждает не всегда экземпляр.
Дескриптор, у которого есть __set__ или __delete__ (такой называют
дескриптором данных), сильнее словаря экземпляра; дескриптор, у которого есть
только __get__, — слабее.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Дальше —
про то, откуда дескриптор узнаёт своё имя, почему из одной строчки приоритета
следуют и работающий кеш cached_property, и падение @classmethod под чужим
декоратором, и сколько всё это стоит.
Механизм 1: что такое дескриптор
__get__, __set__ или __delete__. Это модель данных, а не CPython.Определение целиком укладывается в одно предложение справочника:
Define any of these methods and an object is considered a descriptor and can
override default behavior upon being looked up as an attribute.
Определите любой из этих методов — и объект считается дескриптором и может переопределить поведение по умолчанию при обращении к нему как к атрибуту.
«Любой из этих» — это __get__, __set__, __delete__. Минимальный
дескриптор занимает три строки:
class Loud:
def __get__(self, obj, owner=None):
return "меня спросили"
class Thing:
attr = Loud()
print(Thing().attr) # меня спросилиЗдесь стоит развести две вещи, которые обычно склеивают в одну.
Дескриптор — это свойство объекта, а не его места. Дескриптором объект
делает его ТИП: если тип реализует __get__, __set__ или __delete__,
объект — дескриптор, где бы он ни лежал.
Автоматически вызывается протокол только при одном условии — когда объект
найден поиском в ТИПЕ того, у кого спрашивают атрибут. Это уже правило поиска
атрибута, а не определение дескриптора, и именно оно объясняет следующий
пример: Loud() в экземпляре остаётся дескриптором, но никто его не зовёт.
t = Thing()
t.other = Loud()
print(t.other) # <__main__.Loud object at 0x...> — просто объектМеханизм 2: одно правило приоритета
Дальше всё держится на различении, которое стоит выучить дословно:
If an object defines set or delete, it is
considered a data descriptor. Descriptors that only define get
are called non-data descriptors.
Если объект определяет __set__ или __delete__, он считается дескриптором данных. Дескрипторы, которые определяют только __get__, называются дескрипторами не-данных.
И сразу же — зачем это различение нужно:
If an instance's dictionary has an entry with the same name as a data
descriptor, the data descriptor takes precedence. If an instance's dictionary
has an entry with the same name as a non-data descriptor, the dictionary entry
takes precedence.
Если в словаре экземпляра есть запись с тем же именем, что у дескриптора данных, приоритет у дескриптора данных. Если в словаре экземпляра есть запись с тем же именем, что у дескриптора не-данных, приоритет у записи в словаре.
Проверяется это опытом, в котором всё одинаково, кроме наличия __set__:
class Data:
def __get__(self, obj, owner=None): return "из дескриптора данных"
def __set__(self, obj, value): obj.__dict__["data"] = f"перехвачено: {value}"
class NonData:
def __get__(self, obj, owner=None): return "из дескриптора не-данных"
class Both:
data = Data()
plain = NonData()
obj = Both()
obj.__dict__["data"] = "положено прямо в __dict__"
obj.__dict__["plain"] = "положено прямо в __dict__"
print(obj.data) # из дескриптора данных <- дескриптор победил
print(obj.plain) # положено прямо в __dict__ <- словарь победилВ словаре экземпляра лежит одно и то же, а результат разный. Единственное
различие между Data и NonData — три слова def __set__(self, obj, value).
Полная цепочка поиска записана в руководстве по дескрипторам тоже одним предложением:
Instance lookup scans through a chain of namespaces giving data descriptors
the highest priority, followed by instance variables, then non-data
descriptors, then class variables, and lastly getattr if it is
provided.
Поиск у экземпляра идёт по цепочке пространств имён, отдавая наивысший приоритет дескрипторам данных, затем переменным экземпляра, затем дескрипторам не-данных, затем переменным класса и, наконец, __getattr__, если он определён.
Ниже та же цепочка проиграна по шагам. Переключатель меняет только то, что лежит в классе, — и видно, на каком шаге поиск останавливается.
Механизм 3: откуда дескриптор знает своё имя
Дескриптор — один объект на весь класс, и на двух атрибутах это был бы один и тот же объект. Различить их он может только по имени, а имя ему выдают в момент создания класса:
Automatically called at the time the owning class owner is created. The
object has been assigned to name in that class.
Вызывается автоматически в момент создания класса-владельца. Объект был присвоен имени в этом классе.
class Named:
def __set_name__(self, owner, name):
self.name = name
print(f"__set_name__: класс {owner.__name__}, имя {name!r}")
def __get__(self, obj, owner=None):
return f"я знаю, что меня зовут {self.name}"
class WithNames:
first = Named()
second = Named()__set_name__: класс WithNames, имя 'first'
__set_name__: класс WithNames, имя 'second'
Обе строки печатаются при выполнении class, а не при первом обращении.
Оговорка из того же абзаца справочника важна на практике: если атрибут класса
присвоен уже после создания класса, __set_name__ автоматически не вызовется.
Механизм 4: вы уже ими пользуетесь
Ощущение «дескрипторы — редкая экзотика» держится ровно до одной проверки: спросить у встроенных объектов, есть ли у них методы протокола.
| что | __get__ | __set__ | __delete__ | вид |
|---|---|---|---|---|
| функция (метод) | да | нет | нет | не данных |
property | да | да | да | данных |
staticmethod | да | нет | нет | не данных |
classmethod | да | нет | нет | не данных |
слот __slots__ | да | да | да | данных |
functools.cached_property | да | нет | нет | не данных |
Одинаково на 3.11, 3.12, 3.13 и 3.14. Справочник говорит про методы прямо:
Python methods (including those decorated with staticmethod and
classmethod) are implemented as non-data descriptors. Accordingly,
instances can redefine and override methods.
Методы Python (в том числе украшенные staticmethod и classmethod) реализованы как дескрипторы не-данных. Соответственно, экземпляры могут переопределять и подменять методы.
Отсюда, кстати, ответ на вопрос, который обычно задают отдельно: почему
obj.method = something работает, а obj.prop = something падает. Метод —
дескриптор не-данных, его можно перекрыть записью в экземпляр. property —
дескриптор данных, и он перехватывает саму запись.
Механизм 5: почему @classmethod под чужим декоратором ломается
Это то место, ради которого урок и написан: в уроке про декораторы сказано, что
@staticmethod и @classmethod ставятся самыми верхними, — а причина
называлась словом «дескриптор» без объяснения. Вот она.
Декоратор получает не функцию, а объект staticmethod или classmethod —
тот самый дескриптор. Обёртка внутри декоратора делает f(*args, **kwargs), и
дальше всё решает одна проверка:
class Sample:
@staticmethod
def static(): pass
@classmethod
def klass(cls): pass
print(callable(Sample.__dict__["static"])) # True
print(callable(Sample.__dict__["klass"])) # FalseОбъект staticmethod вызываем начиная с 3.10 — документация фиксирует это
отдельной строкой Changed in version 3.10: Static methods are now callable
(Изменено в версии 3.10: статические методы теперь вызываемы). У classmethod такой строки
нет, и объект остаётся невызываемым. Проверено на 3.11, 3.12, 3.13 и 3.14 —
во всех четырёх одинаково.
Практическое следствие неприятнее самой асимметрии: класс определяется
молча. functools.wraps копирует атрибуты через try/except и о
дескриптор не спотыкается, так что ошибка возникает не при построении класса, а
при первом вызове — TypeError: 'classmethod' object is not callable.
Механизм 6: cached_property — кеш держится на приоритете
functools.cached_property — самый наглядный пример того, что правило
приоритета не абстракция. Смотреть надо на __get__:
# Lib/functools.py:1110–1134, тег v3.14.5, сокращено
def __get__(self, instance, owner=None):
...
cache = instance.__dict__
val = cache.get(self.attrname, _NOT_FOUND)
if val is _NOT_FOUND:
val = self.func(instance)
cache[self.attrname] = val
return valДескриптор кладёт результат в словарь экземпляра под своим же именем. И
поскольку он дескриптор НЕ данных, со следующего обращения словарь оказывается
сильнее — и __get__ больше не вызывают вовсе. Кеш здесь не хитрость, а прямое
следствие приоритета:
class Lazy:
@functools.cached_property
def heavy(self):
print("тело выполнено")
return "посчитано"
lazy = Lazy()
print(lazy.__dict__) # {}
lazy.heavy # тело выполнено
print(lazy.__dict__) # {'heavy': 'посчитано'}
lazy.heavy # тишина: дескриптора не спрашивалиОтсюда же и два ограничения, которые обычно узнают на своей шкуре. С
__slots__ cached_property не работает: складывать некуда, __dict__ нет.
И сбросить кеш можно только del obj.attr — то есть удалением записи из
словаря, а не обращением к дескриптору.
Глубже: правило целиком — четыре ветки object.__getattribute__
object.__getattribute__. Порядок обещан языком, ветвление — деталь.Правило приоритета выше — это сокращение. Полностью логика точки записана в
object.__getattribute__, и документация приводит её эквивалент на самом
Python:
The logic for a dotted lookup is in object.__getattribute__. Here is a pure
Python equivalent:
Логика поиска через точку находится в object.__getattribute__. Вот её эквивалент на чистом Python:
def object_getattribute(obj, name):
"Emulate PyObject_GenericGetAttr() in Objects/object.c"
null = object()
objtype = type(obj)
cls_var = find_name_in_mro(objtype, name, null)
descr_get = getattr(type(cls_var), '__get__', null)
if descr_get is not null:
if (hasattr(type(cls_var), '__set__')
or hasattr(type(cls_var), '__delete__')):
return descr_get(cls_var, obj, objtype) # data descriptor
if hasattr(obj, '__dict__') and name in vars(obj):
return vars(obj)[name] # instance variable
if descr_get is not null:
return descr_get(cls_var, obj, objtype) # non-data descriptor
if cls_var is not null:
return cls_var # class variable
raise AttributeError(name)Читать его стоит как список из четырёх веток по порядку: дескриптор данных, словарь экземпляра, дескриптор не-данных, переменная класса. Правило приоритета, названное выше, — это две пары соседних веток: ветка 1 сильнее ветки 2, а ветка 2 сильнее ветки 3.
Что этот псевдокод действительно эквивалентен точке, проверяется запуском: один
класс, пять имён, каждое достаётся и точкой, и псевдокодом
(bench/attr-lookup/getattribute_equivalent.py, вывод одинаков на 3.11–3.14;
здесь и ниже он сокращён до относящихся к делу строк):
3) какая ветка псевдокода сработала:
data -> ветка 1 (data descriptor): __dict__ экземпляра ПРОИГНОРИРОВАН
nondata -> ветка 2 (instance variable): __dict__ экземпляра ПОБЕДИЛ
plain -> ветка 2 (instance variable)
own -> ветка 2 (instance variable)
method -> ветка 3 (non-data descriptor), функция сама дескриптор: True | __set__: False
Строка method — ответ на вопрос, почему обычный метод вообще работает: функция
и есть дескриптор не-данных, и связанный метод получается на ветке 3.
Чего в этом коде нет — это __getattr__. Крючка на промах в псевдокоде не
видно, и это не упущение:
Note, there is no __getattr__ hook in the __getattribute__ code. That is why
calling __getattribute__ directly or with super().__getattribute__ will
bypass __getattr__ entirely. Instead, it is the dot operator and the
getattr() function that are responsible for invoking __getattr__ whenever
__getattribute__ raises an AttributeError.
Заметьте, в коде __getattribute__ нет крючка __getattr__. Именно поэтому прямой вызов __getattribute__ или вызов через super().__getattribute__ полностью обходит __getattr__. Вместо этого за вызов __getattr__ отвечают оператор точки и функция getattr() — всякий раз, когда __getattribute__ возбуждает AttributeError.
Проверено на классе с __getattr__: точка зовёт запасной путь, прямой вызов
object.__getattribute__ — нет, он падает AttributeError. Это ровно та
разница, из-за которой прокси, написанный «через super().__getattribute__»,
теряет свой же __getattr__.
И ещё одна строка псевдокода, которую стоит запомнить отдельно: objtype = type(obj). Из неё следует весь раздел ниже.
Глубже: тот же поиск на этаж выше — дескриптор на метаклассе
C.x и c.x — это два разных поиска, и разные они ровно на одну строку
псевдокода. У экземпляра objtype = type(obj) даёт класс, у класса — метакласс.
Документация формулирует это как отдельный, но такой же алгоритм:
The logic for a dotted lookup such as A.x is in type.__getattribute__. The
steps are similar to those for object.__getattribute__ but the instance
dictionary lookup is replaced by a search through the class's method resolution
order. If a descriptor is found, it is invoked with desc.__get__(None, A).
Логика поиска через точку вида A.x находится в type.__getattribute__. Шаги похожи на шаги object.__getattribute__, но поиск в словаре экземпляра заменён поиском по порядку разрешения методов класса. Если дескриптор найден, он вызывается как desc.__get__(None, A).
И там же — про разницу вызовов:
object.__getattribute__ and type.__getattribute__ make different calls to
__get__. The first includes the instance and may include the class. The second
puts in None for the instance and always includes the class.
object.__getattribute__ и type.__getattribute__ делают разные вызовы __get__. Первый передаёт экземпляр и может передать класс. Второй подставляет None вместо экземпляра и всегда передаёт класс.
Практическое следствие сильнее, чем звучит
(bench/attr-lookup/metaclass_descriptor.py, вывод одинаков на 3.11–3.14):
2) C.on_meta -> <на-метаклассе>
на-метаклассе.__get__(obj=класс C, objtype=Meta)
3) C().on_meta -> AttributeError: 'C' object has no attribute 'on_meta'
вызовов __get__: нет
C.__mro__ : ['C', 'object']
type(C).__mro__: ['Meta', 'type', 'object']
Дескриптор на метаклассе у экземпляра не «не срабатывает» — его там нет.
Ноль вызовов __get__ и обычный AttributeError, потому что метакласса нет в
type(c).__mro__. Это единственный способ сделать атрибут, который виден у
класса и невидим у экземпляра, — и одновременно самая частая причина
недоумения, когда его пытаются прочитать у экземпляра.
Правило приоритета при этом одно на оба этажа:
6) дескриптор не-данных на метаклассе:
после E.nd = 'перекрыто' -> перекрыто | в E.__dict__: True
__dict__ КЛАССА перекрывает дескриптор не-данных метакласса,
ровно как __dict__ экземпляра перекрывает дескриптор не-данных класса
7) имя `both` объявлено и на метаклассе, и на классе:
F.both -> <метакласс>
F().both -> <класс>
F.both ВЫИГРАЛ у F.__dict__['both']: дескриптор ДАННЫХ на type(F)
перекрывает словарь самого F — тем же правилом
То есть выучив четыре ветки один раз, вы получаете и поведение метаклассов: там
те же ветки, только vars(экземпляра) заменён на MRO класса.
Есть и мелочь, о которую спотыкаются при переносе дескриптора между этажами:
__set_name__ зовётся только у дескрипторов, лежащих в теле своего класса.
У дескриптора на метаклассе owner — это метакласс, а не класс, который им
пользуется.
Глубже: что это стоит
Шесть способов добыть одно и то же значение, замерено подряд в одном процессе, Python 3.13.7, лучшее из девяти прогонов по 200 000 обращений:
| способ | нс на обращение | к обычному атрибуту |
|---|---|---|
обычный атрибут (__dict__) | 17,00 | ×1,00 |
слот __slots__ | 17,04 | ×1,00 |
property | 35,06 | ×2,06 |
| свой дескриптор данных на Python | 107,32 | ×6,31 |
| свой дескриптор не-данных на Python | 119,56 | ×7,03 |
cached_property после первого обращения | 31,26 | ×1,84 |
Первое, что видно: «дескриптор» сам по себе не стоит ничего. Слот
__slots__ — полноценный дескриптор данных, и он ×1,00. Дорог не протокол, а
геттер, написанный на Python: property ×2,06, свой дескриптор ×6,31.
Второе интереснее, потому что опровергает ожидание. Прогретый
cached_property должен был сравняться с обычным атрибутом: значение уже в
__dict__, дескриптор не-данных слабее словаря, спрашивать его незачем.
Измерено — устойчивые ×1,84.
Причина видна в специализированном байткоде:
def read(o):
return o.value
# после двухсот вызовов, dis.get_instructions(read, adaptive=True)
# обычный атрибут -> LOAD_ATTR_INSTANCE_VALUE
# cached_property -> LOAD_ATTRОбычному атрибуту интерпретатор подставляет специализированную инструкцию, которая читает значение из экземпляра напрямую. Атрибуту, за которым в типе всё-таки лежит дескриптор, — не подставляет: проверить тип придётся на каждом обращении, потому что дескриптор мог бы оказаться дескриптором данных.
Практический вывод узкий и честный: cached_property избавляет от повторного
вычисления, но не превращает атрибут в обычный. Если считать нечего, а
обращений миллионы, обычное поле в __init__ дешевле почти вдвое.
Глубже: история версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| 2.2 | PEP 252 вводит протокол дескрипторов вместе с новыми классами. С этого момента property, staticmethod и classmethod — не встроенные исключения из правил, а обычные дескрипторы поверх общего механизма. | |
| 3.6 | Появляется __set_name__: дескриптор узнаёт своё имя при создании класса. До этого имя приходилось дублировать аргументом конструктора — x = Field("x"), — и рассинхронизация имени с атрибутом была обычной ошибкой. | |
| 3.8 | Появляется functools.cached_property — дескриптор не-данных, кладущий результат в __dict__ экземпляра. Работает только там, где __dict__ есть: с __slots__ он несовместим. | |
| 3.10 | Объект staticmethod становится вызываемым напрямую. У classmethod такой строки в документации нет, и объект остаётся невызываемым — отсюда асимметрия, из-за которой @classmethod под чужим декоратором падает, а @staticmethod работает. Проверено на 3.11–3.14. | |
| 3.12 | Из cached_property убран общий на класс замок. Раньше первый расчёт в многопоточной программе сериализовался на всех экземплярах сразу; теперь тело может выполниться больше одного раза, зато не блокирует чужие экземпляры. Если тело обязано выполниться ровно один раз, замок теперь ваш. | |
| 3.13 | classmethod перестаёт разворачивать вложенный дескриптор: сочетание @classmethod над @property больше не даёт значение. Исключения при этом нет — меняется тип результата, и обнаруживается это там, где значение используют. |
Как отвечать на собеседовании
Короткий ответ: дескриптор — это объект, который лежит в классе и
вмешивается в обращение к атрибуту экземпляра; признак один — у его типа есть
__get__, __set__ или __delete__. Всё поведение выводится из одной
строки приоритета: дескриптор данных (есть __set__ или __delete__)
сильнее словаря экземпляра, дескриптор не-данных (только __get__) —
слабее. property, обычный метод, staticmethod, classmethod, слот и
cached_property уже дескрипторы.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
Если спросят, зачем это знать, лучший ответ — вывести из той же строки два
следствия: cached_property кеширует потому, что он дескриптор не-данных и
после записи в __dict__ его больше не спрашивают; а дескриптор, положенный
в экземпляр, не работает вовсе, потому что искать его будут в классе.
Дальше спросят
cached_property — это отдельный механизм кеширования?
Нет, это следствие правила приоритета. Он дескриптор НЕ данных: при первом
обращении кладёт результат в __dict__ экземпляра, а словарь экземпляра
сильнее дескрипторов не-данных — поэтому спрашивать его больше не будут.
Дескриптор объявлен на метаклассе. Экземпляр его увидит?
Нет: C.x и c.x — два разных поиска, различающихся одной строкой
псевдокода. У экземпляра objtype — это класс, у класса — метакласс, и
дескриптор метакласса виден у класса, но не существует у экземпляра.
Частые заблуждения
Дескрипторы — это про метаклассы и фреймворки, в обычном коде их нет
Их шесть штук в любом классе с методом. Обычный метод, staticmethod, classmethod, property, слот __slots__ и cached_property — всё это дескрипторы, и проверяется это одной строкой: hasattr(type(Класс.__dict__["имя"]), "__get__"). Отдельного «механизма дескрипторов», который можно не включать, не существует.
Чтобы сделать дескриптор, нужны все три метода — __get__, __set__, __delete__
Достаточно любого одного, и выбор меняет поведение на противоположное. С одним __get__ запись в экземпляр перекроет дескриптор; с добавленным __set__ — уже нет. Читаемый только дескриптор данных делают именно так: __set__, который бросает AttributeError, — тело не нужно, нужно само наличие метода.
Дескриптор — это медленно
Слот __slots__ — дескриптор данных, написанный на C, и он стоит 17,04 нс против 17,00 у обычного атрибута, то есть ×1,00. Дорог не протокол, а геттер на Python: property — ×2,06, свой дескриптор — ×6,31. Замер: шесть способов подряд в одном процессе, 3.13.7.
После первого обращения cached_property — это обычный атрибут
Значение действительно лежит в __dict__, но обращение всё равно дороже: ×1,84 на 3.13.7. Интерпретатор не может подставить специализированную инструкцию LOAD_ATTR_INSTANCE_VALUE, потому что в типе стоит дескриптор и проверять тип приходится каждый раз. Проверено дизассемблером с adaptive=True.
Дескриптор можно положить в экземпляр, если так удобнее
Протокол ищут только в типе. Объект с __get__, положенный в obj.attr, останется обычным объектом: обращение вернёт его самого, а не результат __get__. Это же объясняет, почему __set_name__ вызывается при создании класса — дескриптор существует как часть класса, а не экземпляра.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
class Data:
def __get__(self, obj, cls):
return "descriptor"
def __set__(self, obj, value):
pass
class NonData:
def __get__(self, obj, cls):
return "descriptor"
class C:
data = Data()
nondata = NonData()
c = C()
c.__dict__["data"] = "instance dict"
c.__dict__["nondata"] = "instance dict"
print(c.data)
print(c.nondata)Практика · оцените
Проверьте себя
В классе есть дескриптор с одним __get__. В __init__ пишут self.attr = 5. Что вернёт obj.attr?
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Дескриптор — это объект, который лежит в КЛАССЕ и сам решает, что вернуть, когда у ЭКЗЕМПЛЯРА спрашивают атрибут. Обращение через точку не всегда означает чтение поля: свойство при обращении выполняет код, а обычная функция из класса превращается в связанный метод. И то и другое — дескрипторы; отдельного механизма для них в языке нет.
- Отсюда главное следствие: признак дескриптора ровно один — у его типа есть
__get__,__set__или__delete__. Дескрипторами уже являютсяproperty, обычный метод,staticmethod,classmethod, слот__slots__иfunctools.cached_property, то есть вы пользуетесь ими каждый день — и, например,obj.method = ...проходит, аobj.prop = ...падает именно поэтому. - Дальше — одна строчка приоритета, из которой выводится остальное. Дескриптор данных (есть
__set__или__delete__) сильнее словаря экземпляра, дескриптор не данных (только__get__) — слабее. Из неё следует и то, почемуcached_propertyкеширует, и то, почему@classmethodпод чужим декоратором падает — молча на определении класса иTypeErrorпри первом вызове. Сам протокол при этом ничего не стоит: слот — 17,04 нс против 17,00 у обычного атрибута на 3.13.7; дорог не он, а геттер, написанный на Python (property— ×2,06).
На самом деле
- Их шесть штук в любом классе с методом. Обычный метод,
staticmethod,classmethod,property, слот__slots__иcached_property— всё это дескрипторы, и проверяется это одной строкой:hasattr(type(Класс.__dict__["имя"]), "__get__"). Отдельного «механизма дескрипторов», который можно не включать, не существует. - Достаточно любого одного, и выбор меняет поведение на противоположное. С одним
__get__запись в экземпляр перекроет дескриптор; с добавленным__set__— уже нет. Читаемый только дескриптор данных делают именно так:__set__, который бросаетAttributeError, — тело не нужно, нужно само наличие метода. - Слот
__slots__— дескриптор данных, написанный на C, и он стоит 17,04 нс против 17,00 у обычного атрибута, то есть ×1,00. Дорог не протокол, а геттер на Python:property— ×2,06, свой дескриптор — ×6,31. Замер: шесть способов подряд в одном процессе, 3.13.7. - Значение действительно лежит в
__dict__, но обращение всё равно дороже: ×1,84 на 3.13.7. Интерпретатор не может подставить специализированную инструкциюLOAD_ATTR_INSTANCE_VALUE, потому что в типе стоит дескриптор и проверять тип приходится каждый раз. Проверено дизассемблером сadaptive=True. - Протокол ищут только в типе. Объект с
__get__, положенный вobj.attr, останется обычным объектом: обращение вернёт его самого, а не результат__get__. Это же объясняет, почему__set_name__вызывается при создании класса — дескриптор существует как часть класса, а не экземпляра.
По версиям
- 2.2
- PEP 252 вводит протокол дескрипторов вместе с новыми классами. С этого момента
property,staticmethodиclassmethod— не встроенные исключения из правил, а обычные дескрипторы поверх общего механизма.< - 3.6
- Появляется
__set_name__: дескриптор узнаёт своё имя при создании класса. До этого имя приходилось дублировать аргументом конструктора —x = Field("x"), — и рассинхронизация имени с атрибутом была обычной ошибкой.< - 3.8
- Появляется
functools.cached_property— дескриптор не-данных, кладущий результат в__dict__экземпляра. Работает только там, где__dict__есть: с__slots__он несовместим.< - 3.10
- Объект
staticmethodстановится вызываемым напрямую. Уclassmethodтакой строки в документации нет, и объект остаётся невызываемым — отсюда асимметрия, из-за которой@classmethodпод чужим декоратором падает, а@staticmethodработает. Проверено на 3.11–3.14.< - 3.12
- Из
cached_propertyубран общий на класс замок. Раньше первый расчёт в многопоточной программе сериализовался на всех экземплярах сразу; теперь тело может выполниться больше одного раза, зато не блокирует чужие экземпляры. Если тело обязано выполниться ровно один раз, замок теперь ваш.< - 3.13
classmethodперестаёт разворачивать вложенный дескриптор: сочетание@classmethodнад@propertyбольше не даёт значение. Исключения при этом нет — меняется тип результата, и обнаруживается это там, где значение используют.<
Что разобрано
- База: почему точка — не всегда чтение поля
- Механизм 1: что такое дескриптор
- Механизм 2: одно правило приоритета
- Механизм 3: откуда дескриптор знает своё имя
- Механизм 4: вы уже ими пользуетесь
- Механизм 5: почему `@classmethod` под чужим декоратором ломается
- Механизм 6: `cached_property` — кеш держится на приоритете
- Глубже: правило целиком — четыре ветки `object.__getattribute__`
- Глубже: тот же поиск на этаж выше — дескриптор на метаклассе
- Глубже: что это стоит
- Глубже: история версий
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверьте себя
- Чем измерено
Источники и что читать дальше
6 ИСТОЧНИКОВ
- Descriptor HowTo GuideОфициальная документация. Определение протокола и правило приоритета дословно: «If an instance's dictionary has an entry with the same name as a data descriptor, the data descriptor takes precedence» (Если в словаре экземпляра есть запись с тем же именем, что у дескриптора данных, приоритет у дескриптора данных), и там же — обратный случай для дескриптора не-данных.https://docs.python.org/3.14/howto/descriptor.html
- Справочник языка — вызов дескрипторов и __set_name__Официальная документация. Полная цепочка поиска и прямое утверждение, что методы — это дескрипторы не-данных: «Python methods (including those decorated with staticmethod and classmethod) are implemented as non-data descriptors» (Методы Python (в том числе украшенные staticmethod и classmethod) реализованы как дескрипторы не-данных). Оттуда же условие вызова __set_name__.https://docs.python.org/3.14/reference/datamodel.html#invoking-descriptors
- Lib/functools.py — cached_property.__get__Исходный код CPython. Строки 1110–1134: значение кладётся прямо в instance.__dict__ и оттуда же читается на следующем обращении. Кеш работает не сам по себе, а потому что дескриптор не-данных слабее словаря. Тег CPython 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Lib/functools.py
- Objects/object.c — _PyObject_GenericGetAttrWithDictИсходный код CPython. Функция, в которой правило приоритета и записано: сначала поиск по типу, затем проверка на дескриптор данных, затем словарь экземпляра, и только потом __get__ дескриптора не-данных. Тег CPython 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Objects/object.c
- Встроенные функции — staticmethod и classmethodОфициальная документация. «Changed in version 3.10: Static methods are now callable» (Изменено в версии 3.10: статические методы теперь вызываемы) — и рядом, у classmethod, отсутствие такой же строки. Из этой асимметрии и растёт падение @classmethod под чужим декоратором.https://docs.python.org/3.14/library/functions.html
- PEP 252 — Making Types Look More Like ClassesPEP. Документ, которым протокол дескрипторов вошёл в язык (Python 2.2, Guido van Rossum, статус Final). Нужен, чтобы показать: это не позднее украшение, а основа модели атрибутов.https://peps.python.org/pep-0252/