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 — связанный метод)")