Deep Engineering

MEASUREMENT

bench/descriptors/protocol.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/python/descriptors
How to run it
python3.11 bench/descriptors/protocol.py
python3.12 bench/descriptors/protocol.py
python3.13 bench/descriptors/protocol.py
python3.14 bench/descriptors/protocol.py

python3.13 bench/descriptors/cost.py
python3.14 bench/descriptors/cost.py

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «Дескрипторы»

Скрипт Что делает
protocol.py приоритет дескриптора над словарём экземпляра, __set_name__, кто из встроенного является дескриптором и какого вида, вызываемость staticmethod против classmethod, механика cached_property
cost.py цена обращения к атрибуту шестью способами плюс специализированный байткод, объясняющий разницу
python3.11 bench/descriptors/protocol.py
python3.12 bench/descriptors/protocol.py
python3.13 bench/descriptors/protocol.py
python3.14 bench/descriptors/protocol.py

python3.13 bench/descriptors/cost.py
python3.14 bench/descriptors/cost.py

Практика урока

practice.py — источник ответов двух практических задач урока, а runs/practice.txt — дословная запись его прогона. Ответ задачи не сочиняется: сборка сверяет заявленное с этой записью (scripts/validate-practice.mjs) и не проходит, если они разошлись.

Прогон снят 30.08.2026 на CPython 3.13.7 (Clang 20.1.4). Абсолютные числа — этой машины; переносится кратность, и задача «во сколько раз» стоит именно на ней.

Кратность устойчива только потому, что формы меряются ВПЕРЕМЕЖКУ: в каждом круге меряются все, минимум для каждой берётся по кругам. Пока замеры шли подряд, просадка машины в окне одной формы целиком доставалась ей, и отношение гуляло в полтора раза от запуска к запуску (измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.

Что здесь важно прочитать правильно

Числа сравниваются только внутри одного прогона. Все шесть способов меряются подряд, в одном процессе, ради этого и написаны в один скрипт. Общее правило про несравнимость версий по времени действует и здесь.

Одно ожидание не подтвердилось. Прогретый cached_property должен был сравняться с обычным атрибутом: значение уже в __dict__, а дескриптор не-данных слабее словаря. Измерено — устойчивые 1,8 от обычного атрибута. Объяснение печатает сам скрипт: обычный атрибут получает специализацию LOAD_ATTR_INSTANCE_VALUE, а атрибут, за которым в типе стоит дескриптор, — не получает.

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

Script

249 lines
"""Дескрипторы: кто побеждает при обращении к атрибуту и что из этого следует.

ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ. Весь урок держится на одном правиле приоритета, и
правило это не про «красиво», а про то, кого спросят первым:

  1. ПРИОРИТЕТ. Дескриптор данных (есть `__set__` или `__delete__`) сильнее
     словаря экземпляра; дескриптор НЕ данных — слабее. Разница в одну строчку
     кода и в противоположное поведение.
  2. `__set_name__`. Дескриптор узнаёт своё имя в момент создания класса, а не
     при обращении. Без этого один и тот же объект-дескриптор на двух атрибутах
     не смог бы их различить.
  3. ВСТРОЕННЫЕ. `property`, `staticmethod`, `classmethod`, `cached_property`,
     обычная функция и слот `__slots__` — всё это дескрипторы; здесь проверено,
     у кого какие методы протокола есть.
  4. ЧЕМ ЛОМАЕТСЯ `@classmethod` ПОД ЧУЖИМ ДЕКОРАТОРОМ. Объект `staticmethod`
     вызываем начиная с 3.10, объект `classmethod` — нет. Проверено на всех
     четырёх версиях.
  5. `cached_property` — дескриптор НЕ данных, и именно поэтому кеш работает:
     после первого обращения значение лежит в `__dict__` и дескриптора больше
     не спрашивают.
  6. ЧТО МЕНЯЛОСЬ ПО ВЕРСИЯМ: замок внутри `cached_property` (есть в 3.11,
     убран в 3.12), несовместимость с `__slots__`, сброс кеша через `del`, и
     `@classmethod` над `@property` — строка на 3.11–3.12, связанный метод на
     3.13–3.14.

ЧЕГО ЗДЕСЬ НЕТ. Времени — оно в `cost.py`.

    python3.11 bench/descriptors/protocol.py
    python3.12 bench/descriptors/protocol.py
    python3.13 bench/descriptors/protocol.py
    python3.14 bench/descriptors/protocol.py
"""

import functools
import inspect
import sys


def rule(title):
    print()
    print(title)
    print("-" * len(title))


print("PY", sys.version.split()[0])


# ------------------------------------------------------- 1. приоритет
rule("1. Кто сильнее: дескриптор или словарь экземпляра")


class DataDescriptor:
    """Есть __set__ — значит, дескриптор ДАННЫХ."""

    def __get__(self, obj, owner=None):
        return "из дескриптора данных"

    def __set__(self, obj, value):
        obj.__dict__["data"] = f"перехвачено: {value}"


class NonDataDescriptor:
    """Только __get__ — дескриптор НЕ данных."""

    def __get__(self, obj, owner=None):
        return "из дескриптора не-данных"


class Both:
    data = DataDescriptor()
    plain = NonDataDescriptor()


obj = Both()
obj.__dict__["data"] = "положено прямо в __dict__"
obj.__dict__["plain"] = "положено прямо в __dict__"

print("  в __dict__ лежит одно и то же для обоих имён:")
print("    obj.data  ->", obj.data)
print("    obj.plain ->", obj.plain)
print("  то есть дескриптор данных сильнее словаря, а не-данных — слабее")

# И присваивание тоже проходит через дескриптор данных.
obj.data = "новое значение"
print("  после obj.data = ... в __dict__ лежит:", obj.__dict__["data"])
print("  но читается по-прежнему через __get__:", obj.data)


# ------------------------------------------------------- 2. __set_name__
rule("2. Дескриптор узнаёт своё имя при создании класса")


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}"


print("  создаём класс:")


class WithNames:
    first = Named()
    second = Named()


print("  ", WithNames().first)
print("  ", WithNames().second)


# ------------------------------------------------------- 3. кто дескриптор
rule("3. Что из встроенного является дескриптором")


class Sample:
    __slots__ = ("slot",)

    def method(self):
        pass

    @property
    def prop(self):
        return 1

    @staticmethod
    def static():
        pass

    @classmethod
    def klass(cls):
        pass


class Cached:
    @functools.cached_property
    def value(self):
        return 42


CANDIDATES = [
    ("функция (метод)", Sample.__dict__["method"]),
    ("property", Sample.__dict__["prop"]),
    ("staticmethod", Sample.__dict__["static"]),
    ("classmethod", Sample.__dict__["klass"]),
    ("слот __slots__", Sample.__dict__["slot"]),
    ("cached_property", Cached.__dict__["value"]),
]

print(f"  {'что':<20} {'__get__':>8} {'__set__':>8} {'__delete__':>11}  вид")
for label, thing in CANDIDATES:
    has_get = hasattr(type(thing), "__get__")
    has_set = hasattr(type(thing), "__set__")
    has_del = hasattr(type(thing), "__delete__")
    kind = "данных" if (has_set or has_del) else "не данных" if has_get else "не дескриптор"
    print(f"  {label:<20} {str(has_get):>8} {str(has_set):>8} {str(has_del):>11}  {kind}")


# ------------------------------------------------------- 4. вызываемость
rule("4. Почему @classmethod под чужим декоратором ломается")

static_obj = Sample.__dict__["static"]
class_obj = Sample.__dict__["klass"]
print("  callable(объект staticmethod):", callable(static_obj))
print("  callable(объект classmethod): ", callable(class_obj))
print("  обёртка внутри чужого декоратора делает f(*a, **kw) — и на classmethod")
print("  это невозможно; ошибка возникает при ВЫЗОВЕ, а не при создании класса")


# ------------------------------------------------------- 5. cached_property
rule("5. Кеш работает потому, что дескриптор НЕ данных")

calls = []


class Lazy:
    @functools.cached_property
    def heavy(self):
        calls.append(1)
        return "посчитано"


lazy = Lazy()
print("  до первого обращения в __dict__:", dict(lazy.__dict__))
print("  первое обращение :", lazy.heavy)
print("  теперь в __dict__:", dict(lazy.__dict__))
print("  второе обращение :", lazy.heavy)
print("  тело выполнено раз:", len(calls))
print("  словарь экземпляра сильнее дескриптора не-данных — поэтому второй раз")
print("  дескриптора не спрашивают вовсе")

# И обратная сторона: property кешировать так нельзя.
try:
    Sample().prop = 5
except AttributeError as exc:
    print("  а property — дескриптор ДАННЫХ, и записать поверх нельзя:", exc.__class__.__name__)


# ------------------------------------------------------- 6. что менялось
rule("6. Что менялось по версиям")

src = inspect.getsource(functools.cached_property)
print("  замок внутри cached_property:", "RLock" in src or "self.lock" in src,
      "(есть в 3.11, убран в 3.12)")


class Slotted:
    __slots__ = ()

    @functools.cached_property
    def v(self):
        return 1


try:
    Slotted().v
    print("  cached_property на классе со __slots__: сработало")
except TypeError as exc:
    print("  cached_property на классе со __slots__: TypeError —", str(exc)[:60])

reset_calls = []


class Resettable:
    @functools.cached_property
    def v(self):
        reset_calls.append(1)
        return 1


r = Resettable()
r.v
del r.v
r.v
print("  сброс кеша через del obj.v: тело выполнено", len(reset_calls), "раза")


class OverProperty:
    @classmethod
    @property
    def v(cls):
        return "значение"


print("  @classmethod над @property даёт:", repr(OverProperty.v)[:60])
print("  (на 3.11 и 3.12 это строка, на 3.13 и 3.14 — связанный метод)")