Deep Engineering
Продвинутый·Опубликовано·3.11 · 3.12 · 3.13 · 3.14·35 МИН

Дескрипторы: одно правило приоритета, из которого растут 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.

перевод

Определите любой из этих методов — и объект считается дескриптором и может переопределить поведение по умолчанию при обращении к нему как к атрибуту.

Descriptor HowTo Guide

«Любой из этих» — это __get__, __set__, __delete__. Минимальный дескриптор занимает три строки:

PYTHON
class Loud:
    def __get__(self, obj, owner=None):
        return "меня спросили"
 
class Thing:
    attr = Loud()
 
print(Thing().attr)      # меня спросили

Здесь стоит развести две вещи, которые обычно склеивают в одну.

Дескриптор — это свойство объекта, а не его места. Дескриптором объект делает его ТИП: если тип реализует __get__, __set__ или __delete__, объект — дескриптор, где бы он ни лежал.

Автоматически вызывается протокол только при одном условии — когда объект найден поиском в ТИПЕ того, у кого спрашивают атрибут. Это уже правило поиска атрибута, а не определение дескриптора, и именно оно объясняет следующий пример: Loud() в экземпляре остаётся дескриптором, но никто его не зовёт.

PYTHON
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__, называются дескрипторами не-данных.

Descriptor HowTo Guide

И сразу же — зачем это различение нужно:

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.

перевод

Если в словаре экземпляра есть запись с тем же именем, что у дескриптора данных, приоритет у дескриптора данных. Если в словаре экземпляра есть запись с тем же именем, что у дескриптора не-данных, приоритет у записи в словаре.

Descriptor HowTo Guide

Проверяется это опытом, в котором всё одинаково, кроме наличия __set__:

PYTHON
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__, если он определён.

Descriptor HowTo Guide

Ниже та же цепочка проиграна по шагам. Переключатель меняет только то, что лежит в классе, — и видно, на каком шаге поиск останавливается.

Механизм 3: откуда дескриптор знает своё имя

Дескриптор — один объект на весь класс, и на двух атрибутах это был бы один и тот же объект. Различить их он может только по имени, а имя ему выдают в момент создания класса:

Automatically called at the time the owning class owner is created. The object has been assigned to name in that class.

перевод

Вызывается автоматически в момент создания класса-владельца. Объект был присвоен имени в этом классе.

Справочник языка — object.__set_name__
PYTHON
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), и дальше всё решает одна проверка:

PYTHON
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__:

PYTHON
# 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__ больше не вызывают вовсе. Кеш здесь не хитрость, а прямое следствие приоритета:

PYTHON
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__

деталь реализации · CPython 3.13Четыре ветки — то, как правило приоритета записано в исходнике object.__getattribute__. Порядок обещан языком, ветвление — деталь.

Правило приоритета выше — это сокращение. Полностью логика точки записана в object.__getattribute__, и документация приводит её эквивалент на самом Python:

The logic for a dotted lookup is in object.__getattribute__. Here is a pure Python equivalent:

перевод

Логика поиска через точку находится в object.__getattribute__. Вот её эквивалент на чистом Python:

Descriptor HowTo Guide
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.

Descriptor HowTo Guide

Проверено на классе с __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).

Descriptor HowTo Guide

И там же — про разницу вызовов:

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 — это метакласс, а не класс, который им пользуется.

Глубже: что это стоит

наблюдение замераbench/descriptors/cost.py, CPython 3.13.7. Числа сняты на одной машине; содержательна кратность между способами доступа.

Шесть способов добыть одно и то же значение, замерено подряд в одном процессе, Python 3.13.7, лучшее из девяти прогонов по 200 000 обращений:

способнс на обращениек обычному атрибуту
обычный атрибут (__dict__)17,00×1,00
слот __slots__17,04×1,00
property35,06×2,06
свой дескриптор данных на Python107,32×6,31
свой дескриптор не-данных на Python119,56×7,03
cached_property после первого обращения31,26×1,84

Первое, что видно: «дескриптор» сам по себе не стоит ничего. Слот __slots__ — полноценный дескриптор данных, и он ×1,00. Дорог не протокол, а геттер, написанный на Python: property ×2,06, свой дескриптор ×6,31.

Второе интереснее, потому что опровергает ожидание. Прогретый cached_property должен был сравняться с обычным атрибутом: значение уже в __dict__, дескриптор не-данных слабее словаря, спрашивать его незачем. Измерено — устойчивые ×1,84.

Причина видна в специализированном байткоде:

PYTHON
def read(o):
    return o.value
 
# после двухсот вызовов, dis.get_instructions(read, adaptive=True)
# обычный атрибут  -> LOAD_ATTR_INSTANCE_VALUE
# cached_property  -> LOAD_ATTR

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

Практический вывод узкий и честный: cached_property избавляет от повторного вычисления, но не превращает атрибут в обычный. Если считать нечего, а обращений миллионы, обычное поле в __init__ дешевле почти вдвое.

Глубже: история версий

ВерсияИзменениеЧто это значит для кода
2.2PEP 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.13classmethod перестаёт разворачивать вложенный дескриптор: сочетание @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__ вызывается при создании класса — дескриптор существует как часть класса, а не экземпляра.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

У класса два дескриптора: у одного есть __set__, у другого только __get__. Оба имени записаны напрямую в словарь экземпляра. Что напечатает этот код?
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)

Практика · оцените

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

Проверьте себя

Вопрос 1 из 4

В классе есть дескриптор с одним __get__. В __init__ пишут self.attr = 5. Что вернёт obj.attr?

Чем измерено

Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. 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
  2. Справочник языка — вызов дескрипторов и __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
  3. 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
  4. Objects/object.c — _PyObject_GenericGetAttrWithDictИсходный код CPython. Функция, в которой правило приоритета и записано: сначала поиск по типу, затем проверка на дескриптор данных, затем словарь экземпляра, и только потом __get__ дескриптора не-данных. Тег CPython 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Objects/object.c
  5. Встроенные функции — 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
  6. PEP 252 — Making Types Look More Like ClassesPEP. Документ, которым протокол дескрипторов вошёл в язык (Python 2.2, Guido van Rossum, статус Final). Нужен, чтобы показать: это не позднее украшение, а основа модели атрибутов.https://peps.python.org/pep-0252/