obj.x насквозьСамая частая строка в языке и один из самых глубоких провалов в понимании. Одна процедура из четырёх шагов объясняет дескрипторы, property, явный self, __slots__, метаклассы и то, почему mypy принципиально не может быть надёжным.
Четыре фрагмента. Зафиксируй вывод и причину до того, как откроешь ответ.
class A:
x = 1
a = A()
print(a.x)
a.x = 2
print(a.x, A.x)
del a.x
print(a.x)
1
2 1
1Атрибут инстанса появился, затенил классовый, исчез — и классовый вернулся.class B:
@property
def v(self): return 1
b = B()
b.__dict__['v'] = 99
print(b.v)
1Значение положено прямо в словарь инстанса — и проигнорировано.class C:
__slots__ = ('x',)
c = C()
c.y = 1
AttributeError: 'C' object has no attribute 'y'Причём c.x = 1 работает прекрасно.class D:
def __getattr__(self, name):
return f"нет такого: {name}"
d = D()
print(d.foo)
d.foo = 1
print(d.foo)
нет такого: foo
1Один и тот же d.foo, два разных источника ответа.Первое, что нужно принять: obj.x — не чтение поля. Это вызов. Компилятор превращает точку в обращение к типу объекта, и дальше работает процедура, у которой есть ровно четыре шага в строгом порядке. Весь урок — про этот порядок.
Прежде чем его выписать, нужно одно понятие. Дескриптор — объект, у которого определён __get__. Если у него есть ещё и __set__ или __delete__, он называется дескриптором данных. Если только __get__ — дескриптором не-данных. Разница между этими двумя видами и есть вся интрига.
Начать надо не с определения, а с задачи, которую дескриптор решает.
Атрибут на классе — один на всех. C.x = 1 означает, что у каждого инстанса x равен единице, и это одно и то же значение. Вопрос: как сделать так, чтобы атрибут лежал на классе — то есть был общим объявлением, — но отвечал по-разному для каждого инстанса, а то и вычислялся в момент обращения?
Ответ Python: пусть объект, лежащий на классе, сам решает, что вернуть. Для этого он должен уметь принять вопрос — то есть иметь метод __get__.
Дескриптор — это объект, который умеет отвечать на вопрос «что вернуть, когда меня читают через инстанс». Формально: объект, у типа которого определён __get__.
Самый маленький дескриптор, который вообще можно написать, — три строки. Он ничего не хранит, просто показывает, что его зовут и с чем:
class Probe:
def __get__(self, obj, objtype=None):
print(f"__get__ вызван: obj={obj!r}, objtype={objtype.__name__}")
return 42
class C:
x = Probe()
c = C()
print(c.x)
# → __get__ вызван: obj=<__main__.C object at 0x...>, objtype=C
# → 42
print(C.x)
# → __get__ вызван: obj=None, objtype=C
# → 42
Обрати внимание на главное: c.x вернуло 42, а не объект Probe. Хотя на классе лежит именно Probe. Это и есть перехват — обычный поиск атрибута нашёл бы объект и вернул его как есть, а здесь вместо возврата произошёл вызов.
Три аргумента __get__. self — сам дескриптор, он один на класс. obj — инстанс, через который читают; именно он позволяет отвечать по-разному для разных объектов. objtype — класс.
Когда читают через класс, а не через инстанс, obj равен None. Отсюда идиома, которую видно в любом дескрипторе: if obj is None: return self — «меня спрашивают у класса, инстанса нет, верну себя». Именно поэтому SomeClass.some_property печатает <property object>, а не значение.
property самомуПроверка понимания: property — не синтаксис языка и не встроенная магия, а обычный класс с __get__ и __set__. Вот рабочая версия, укладывающаяся в двенадцать строк:
class MyProperty:
def __init__(self, fget=None, fset=None):
self.fget, self.fset = fget, fset
def __get__(self, obj, objtype=None):
if obj is None: return self # спросили у класса
if self.fget is None: raise AttributeError("нет геттера")
return self.fget(obj) # зовём функцию, подставив инстанс
def __set__(self, obj, value):
if self.fset is None: raise AttributeError("только для чтения")
self.fset(obj, value)
def setter(self, fset): # чтобы работал @celsius.setter
return MyProperty(self.fget, fset)
class Temp:
def __init__(self, c): self._c = c
@MyProperty
def celsius(self): return self._c
@celsius.setter
def celsius(self, v):
if v < -273.15: raise ValueError("ниже абсолютного нуля")
self._c = v
t = Temp(20)
print(t.celsius) # → 20
t.celsius = 25
print(t.celsius) # → 25
t.celsius = -300 # → ValueError: ниже абсолютного нуля
Ни одной специальной конструкции здесь нет. @MyProperty — обычный декоратор: он получает функцию celsius и кладёт на класс вместо неё объект MyProperty. Дальше при t.celsius срабатывает __get__, при t.celsius = 25 — __set__. Настоящий property устроен ровно так же, только написан на C и умеет ещё deleter и докстроку.
Дескриптор часто должен где-то хранить данные — и логичное место это словарь инстанса. Но чтобы туда писать, нужен ключ, а имени своего дескриптор изначально не знает: он был создан раньше, чем его присвоили атрибуту.
Для этого есть __set_name__ (с версии 3.6): интерпретатор вызывает его при создании класса и сообщает дескриптору владельца и имя.
class Field:
def __set_name__(self, owner, name):
print(f"__set_name__: {owner.__name__}.{name}")
self.name = "_" + name
def __get__(self, obj, objtype=None):
if obj is None: return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
obj.__dict__[self.name] = value
class Point:
x = Field()
y = Field()
# → __set_name__: Point.x
# → __set_name__: Point.y
p = Point()
p.x, p.y = 1, 2
print(p.x, p.y) # → 1 2
print(p.__dict__) # → {'_x': 1, '_y': 2}
До 3.6 имя приходилось передавать руками — x = Field("x"), — и это была классическая причина заводить метакласс. __set_name__ убрал такую необходимость и вместе с __init_subclass__ закрыл большинство сценариев, ради которых метаклассы писали (урок 07).
Теперь ключевой вопрос, ответ на который обычно просто запоминают. Почему одни дескрипторы имеют приоритет над словарём инстанса, а другие нет?
Потому что это две разные задачи, и им нужно противоположное поведение.
Метод должен быть затеняем. Функция — дескриптор не-данных, и поэтому значение в словаре инстанса перебивает её. Это не недосмотр, а необходимая возможность: подмена метода на одном объекте.
class Service:
def send(self): return "настоящая отправка"
s = Service()
s.send = lambda: "заглушка" # только на этом объекте
print(s.send()) # → заглушка
print(Service().send()) # → настоящая отправка
На этом стоят моки в тестах, обёртки вокруг конкретного объекта, отладочные подмены. Будь функция дескриптором данных, ничего этого не работало бы.
Валидация затеняема быть не должна. Если бы property можно было перебить записью в словарь инстанса, любую проверку можно было бы обойти — случайно или намеренно:
class Account:
@property
def balance(self): return self._b
@balance.setter
def balance(self, v):
if v < 0: raise ValueError("отрицательный баланс")
self._b = v
a = Account()
a.balance = 100
a.__dict__["balance"] = -999 # положили в обход сеттера
print(a.__dict__) # → {'_b': 100, 'balance': -999}
print(a.balance) # → 100 дескриптор данных отвечает первым
Значение в словаре есть, оно просто недостижимо: шаг 1 срабатывает раньше шага 2. Инвариант класса удержан, несмотря на прямую запись в его словарь.
Отсюда правило, которое теперь не надо запоминать — оно выводится: дескриптор данных имеет приоритет над инстансом, потому что он про контроль; дескриптор не-данных уступает инстансу, потому что он про поведение по умолчанию.
Почти всё, что выглядит как отдельная возможность языка:
import functools
class Q:
__slots__ = ("s",)
def m(self): pass
@property
def p(self): return 1
@classmethod
def cm(cls): pass
@staticmethod
def sm(): pass
for n in ("m", "p", "cm", "sm", "s"):
o = Q.__dict__[n]
t = type(o)
kind = ("данных" if hasattr(t, "__set__") or hasattr(t, "__delete__")
else "не-данных" if hasattr(t, "__get__") else "не дескриптор")
print(f"{n:4} {t.__name__:18} {kind}")
# → m function не-данных
# → p property данных
# → cm classmethod не-данных
# → sm staticmethod не-данных
# → s member_descriptor данных
| что | тип | вид | что делает __get__ |
|---|---|---|---|
| метод | function | не-данных | Возвращает связанный метод с подставленным self |
@property | property | данных | Зовёт функцию-геттер |
@classmethod | classmethod | не-данных | Подставляет первым аргументом класс, а не инстанс |
@staticmethod | staticmethod | не-данных | Возвращает функцию как есть, ничего не подставляя |
слот __slots__ | member_descriptor | данных | Читает и пишет по фиксированному смещению в объекте |
@cached_property | cached_property | не-данных | Считает один раз и кладёт результат в словарь инстанса |
сам __dict__ | getset_descriptor | данных | Отдаёт словарь инстанса |
Строка про cached_property — лучшая иллюстрация всей механики. Он сделан дескриптором не-данных намеренно: посчитав значение один раз, он записывает его в obj.__dict__ — и со второго обращения срабатывает шаг 2, а сам дескриптор больше никогда не вызывается.
import functools
class D:
def __init__(self): self.calls = 0
@functools.cached_property
def value(self):
self.calls += 1
return "посчитано"
d = D()
print(d.__dict__) # → {'calls': 0} значения ещё нет
print(d.value, d.calls) # → посчитано 1
print(d.__dict__) # → {'calls': 1, 'value': 'посчитано'}
print(d.value, d.calls) # → посчитано 1 функция не звалась
Кеширование здесь не отдельный механизм, а прямое следствие приоритетов: не-данных уступает инстансу, значит достаточно один раз записать в инстанс — и дескриптор самоустранится. Замер: чтение property — 0.041 с на миллион, прогретого cached_property — 0.029 с, то есть обычное чтение атрибута.
А заодно отсюда видно ограничение: cached_property не работает с __slots__ — писать некуда, словаря инстанса нет.
В C++ обращение к полю — смещение, вычисленное компилятором; перехватить его нельзя, можно только заранее написать геттер и вызывать его явно. В C# есть свойства с синтаксисом get/set, но это синтаксис компилятора: каждое свойство описывается по месту и не является объектом.
Разница принципиальная: дескриптор — это объект. Значит его можно создать один раз и переиспользовать в десяти классах, параметризовать при создании, положить в список, сгенерировать в цикле. Валидатор поля, ленивое поле, поле с логированием доступа, поле из базы — всё это в Python пишется один раз как класс и дальше используется как x = PositiveInt().
Именно на этом стоят ORM (поле модели Django — дескриптор), pydantic, dataclasses с валидацией и половина конфигурационных библиотек. Не «магия фреймворка», а один протокол из трёх методов.
obj.xШаг 1. Ищем x в типе объекта и всех классах его MRO. Если нашли и это дескриптор данных — вызываем его __get__. Конец.
Шаг 2. Иначе смотрим в данные самого инстанса. Нашли — возвращаем. Конец.
Шаг 3. Иначе возвращаемся к тому, что нашли на классе на шаге 1. Дескриптор не-данных — вызываем __get__; обычное значение — возвращаем как есть. Конец.
Шаг 4. Не нашли нигде — вызываем __getattr__, если он определён. Иначе AttributeError.
Четыре шага. Теперь все четыре фрагмента получаются прямым применением.
Сначала x есть только на классе и не является дескриптором — срабатывает шаг 3, видим 1. Присваивание a.x = 2 кладёт значение в данные инстанса, и теперь шаг 2 срабатывает раньше шага 3: инстанс видит 2, класс по-прежнему 1. del a.x убирает запись инстанса, шаг 2 снова не находит ничего, шаг 3 возвращает классовое значение. Ничего не «перезаписывалось» — просто менялось, какой шаг успевает первым.
property нельзя перебитьproperty — не синтаксис и не магия. Это обычный объект с __get__, __set__ и __delete__, то есть дескриптор данных, лежащий на классе. Значит срабатывает шаг 1 и до словаря инстанса дело не доходит вовсе. Значение 99 лежит в b.__dict__ и недостижимо.
Отсюда же выводится то, что обычно запоминают отдельно: присваивание в property без сеттера падает не потому, что «property только для чтения», а потому, что __set__ у него определён всегда и просто выбрасывает AttributeError.
__slots____slots__ делает две вещи. Убирает у инстанса __dict__ — то есть отключает шаг 2 полностью. И создаёт на классе по одному дескриптору данных на каждое объявленное имя, каждый из которых читает и пишет по фиксированному смещению в объекте.
Дальше всё следует. c.x работает, потому что шаг 1 находит дескриптор слота. c.y = 1 падает, потому что дескриптора y на классе нет, а положить значение больше некуда — шага 2 не существует. И становится понятно, почему __slots__ экономит память: исчезает целый словарь на объект.
__getattr__ — это шаг 4, а не шаг 0Первое обращение к d.foo проходит все три шага впустую и попадает на четвёртый. После d.foo = 1 значение оказывается в данных инстанса, и шаг 2 отвечает раньше — __getattr__ уже не вызывается никогда. Это единственная причина разного вывода: не «перехватчик отключился», а поиск стал успешным раньше.
Здесь же лежит различие, которое путают чаще всего. __getattribute__ — это вся процедура целиком, он вызывается на каждое обращение. __getattr__ — только запасной выход на шаге 4. Переопределять первый опасно ровно потому, что внутри него любое обращение к атрибуту вызовет его же.
Почему self явный. Функция, написанная в теле класса, — дескриптор не-данных: у неё есть __get__ и нет __set__. Значит q.m идёт по шагу 3, вызывает __get__, и тот возвращает не функцию, а связанный метод — объект, который помнит инстанс и подставит его первым аргументом.
print((type(Q.m), type(q.m)))
# → (<class 'function'>, <class 'method'>)
print(q.m.__self__ is q)
# → True
print(Q.__dict__['m'].__get__(q, Q))
# → <bound method Q.m of <__main__.Q object>>
Явный self — не стилистическое решение и не «дань явности». Это единственный способ записать сигнатуру функции, которая станет связанным методом через дескриптор. И из той же строчки следует, почему Q.m(q) работает: с класса функция берётся напрямую, без шага 3, и первый аргумент приходится передать руками.
Что такое classmethod и staticmethod. Те же дескрипторы не-данных, только с другой реализацией __get__: первый подставляет класс вместо инстанса, второй не подставляет ничего. Три декоратора — один механизм и три варианта одного метода.
Почему mypy принципиально не может быть надёжным. Вся процедура выполняется во время исполнения и зависит от того, что лежит в классе и инстансе прямо сейчас. Класс можно поменять после создания, дескриптор — подставить в рантайме, __getattr__ — синтезировать любое имя. Статический анализатор обязан предполагать, что этого не произошло. Он полезен, но его выводы — не доказательства.
Дандеры ищутся мимо этой процедуры. Когда интерпретатор сам вызывает специальный метод — len(obj), obj + x, with obj — он идёт напрямую в тип, минуя и инстанс, и __getattr__. Поэтому объект-прокси, синтезирующий любые имена через __getattr__, всё равно не станет итерируемым: __iter__ так подставить нельзя 🔒. Разобранная процедура описывает явное обращение obj.x, а не неявные вызовы.
Для класса процедура другая. A.x — это обращение к атрибуту объекта-класса, и работает оно через тип класса, то есть через метакласс. Отсюда и то, что property на метаклассе виден у класса, но не у инстансов.
«Данные инстанса» — не обязательно __dict__. Начиная с 3.11 значения атрибутов обычного объекта физически лежат не в отдельном словаре, а в области рядом с объектом, и словарь материализуется по требованию 🔧. На поведение это не влияет — на память и скорость влияет сильно. Подробности в L2.
Решение, из которого всё выросло: атрибут не разрешается заранее — он ищется в момент обращения, а пространство имён объекта изменяемо во время работы программы.
Ранний Python имел это свойство почти случайно, как следствие простоты реализации. Осознанным решением оно стало в 2001 году, когда Гвидо объединял типы и классы: тогда появились дескрипторы, MRO и property — и всё это было построено поверх динамического поиска, а не вместо него. Язык мог на этом шаге зафиксировать раскладку объектов и стать быстрее. Вместо этого он получил единый механизм, через который выражаются property, методы, слоты, ORM и валидаторы, — ценой того, что каждое обращение к атрибуту стоит работы.
Эта цена оставалась неоплаченной двадцать лет и была одной из главных причин репутации «Python медленный». Заплатили в 3.11: интерпретатор научился запоминать результат поиска прямо в потоке байткода. Механизм не изменился — изменилось то, что второй и последующий разы он не выполняется целиком.
MRO — это линейное продолжение частичного порядка. Наследование задаёт отношение на классах, ромб делает его именно частичным, а C3 строит такую линеаризацию, где каждый класс встречается один раз, потомок всегда раньше предка и порядок базовых классов из объявления сохраняется. Если совместимого линейного порядка не существует — класс не создастся, и это ошибка на этапе объявления, а не при вызове.
class X: pass
class Y(X): pass
class Z(X): pass
class W(Y, Z): pass
print([c.__name__ for c in W.__mro__])
# → ['W', 'Y', 'Z', 'X', 'object']
Отсюда сразу видно, чем super() не является. Он не «вызывает родителя»: он передаёт управление следующему классу в этой линеаризации — а следующим за Y здесь стоит Z, который родителем Y не является вообще. Именно поэтому кооперативное множественное наследование работает, и именно поэтому оно ломается, если хоть один класс в цепочке не вызвал super().
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
self явный — его же можно было подставлять автоматически?Потому что тогда пришлось бы вводить особый вид функций, а метод — обычная функция.
Механика в этом уроке: функция — дескриптор не-данных, и при обращении через инстанс её __get__ возвращает связанный метод, у которого первый аргумент уже подставлен. Никакого «объявления метода» в языке нет — есть функция в теле класса.
class C:
def m(self): return self
c = C()
print(C.m) # → обычная функция
print(c.m) # → bound method
print(c.m.__self__ is c) # → True
print(C.m(c) is c.m()) # → True одно и то же
Из этого следует всё остальное: функцию можно приделать к классу после его создания, можно передать метод как обычный вызываемый объект, можно позвать C.m(instance) напрямую — что и делает super() внутри.
Второй аргумент — читаемость. В C++ и Java поле, к которому обращаются без this, визуально неотличимо от локальной переменной, и это источник ошибок. В Python self.x и x — разные вещи и выглядят по-разному. Гвидо считал это достаточной причиной само по себе.
Цена — три лишних символа в каждой сигнатуре и классическая ошибка «забыл self». Обмен сознательный и зафиксирован в PEP 20: явное лучше неявного.
__slots__ даёт и память, и скорость, почему не по умолчанию?Потому что он запрещает то, на чём стоит половина экосистемы: добавление атрибутов после создания.
class S:
__slots__ = ("x",)
s = S()
s.y = 1
# → AttributeError: 'S' object has no attribute 'y'
Это ломает: monkey-patching в тестах, кеширование вычисленного значения на инстансе, mixin-ы, дописывающие поля, ORM и сериализаторы, вешающие служебные атрибуты, functools.cached_property, слабые ссылки (если не добавить __weakref__ явно). Плюс множественное наследование от двух классов с непустыми __slots__ просто не работает.
Выигрыш при этом реален, но скромнее, чем в советах. Замерено в этом уроке: 56 байт на инстанс против 96 у обычного класса — и против 160, если код где-нибудь тронул obj.__dict__. По скорости чтения атрибута разницы почти нет:
обычный 0.077 c на 2 млн чтений
__slots__ 0.081 c
property 0.185 c
Правило: __slots__ оправдан там, где объектов сотни тысяч и они одинаковые — точки, записи, узлы графа. Для двадцати сервисных объектов это чистый вред. И сначала стоит проверить, не подходит ли dataclass(slots=True) или вообще массив вместо объектов.
Потому что поиска почти не происходит: инлайн-кеш запоминает результат прямо в инструкции.
Замер из урока 23: 6 нс на инструкцию байткода, и разница между чтением локальной и чтением атрибута теряется в шуме цикла. Это не значит, что процедура из четырёх шагов не выполняется, — значит, что в типичном случае она выполняется один раз, а дальше проверяется одно равенство версии типа.
import dis
class P:
def __init__(self): self.x = 1
p = P()
def get(o): return o.x
for _ in range(200): get(p)
dis.dis(get, adaptive=True)
# → LOAD_ATTR_INSTANCE_VALUE вместо общего LOAD_ATTR
Но ставка проверяется, и её можно проиграть. Если на одну площадку обращения приходят объекты разных типов, кеш промахивается и инструкция деоптимизируется. Замер из урока 21: однородная коллекция обходится в 1.4 раза быстрее смеси трёх классов при полностью идентичном коде.
А вот property кешу не помогает: там на каждом чтении реально вызывается функция, и это 2.4 раза сверх обычного атрибута. Отсюда практическое: property для вычисляемого значения — нормально, property-обёртка вокруг простого поля «на будущее» — плата без выигрыша.
__getattr__, если есть __getattribute__?Затем, что один из них безопасен, а второй — заряженное ружьё.
__getattribute__ вызывается на каждое обращение к атрибуту и заменяет собой всю процедуру. Написать его правильно почти невозможно: любое обращение к self.чему_угодно внутри него уходит в рекурсию, а забыть вызвать object.__getattribute__ для обычного случая — значит сломать объект целиком.
__getattr__ вызывается только когда стандартная процедура не нашла атрибут. То есть обычные поля работают как обычно и по обычной цене, а перехват срабатывает лишь на промахе.
class Lazy:
def __getattr__(self, name): # только на промахе
print("промах:", name)
return 42
o = Lazy()
o.x = 1
print(o.x) # → 1 __getattr__ не звали
print(o.whatever) # → промах: whatever / 42
Практическое следствие: прокси, ленивые обёртки и клиенты к API с динамическими методами пишут через __getattr__. __getattribute__ оправдан примерно никогда в прикладном коде — и если он всё же появился в чужой библиотеке, это первое место, куда стоит смотреть при непонятном поведении и просадке производительности.
И отдельно: ни тот ни другой не перехватывают дандеры при неявном вызове — len(x) идёт в слот типа мимо всей процедуры. Урок 11 про это.
obj.x — вызов процедуры из четырёх шагов: дескриптор данных на классе, затем данные инстанса, затем всё остальное с класса, затем __getattr__. property побеждает словарь инстанса потому, что стоит на первом шаге; cached_property проигрывает ему со второго обращения потому, что стоит на третьем и пишет во второй; __slots__ удаляет второй шаг целиком; self явный потому, что функция — дескриптор не-данных, и __get__ возвращает связанный метод. Всё это выполняется в рантайме и зависит от текущего состояния класса — отсюда и невозможность надёжного статического вывода, и двадцать лет медленного доступа к атрибуту, оплаченных инлайн-кешами в 3.11.
JavaScript принял ровно то же решение — динамический поиск по цепочке прототипов — и столкнулся с той же ценой на десять лет раньше. Ответ V8 в 2008 году: скрытые классы плюс инлайн-кеши. Python пришёл к тому же выводу в 2022-м. Одинаковая задача, одинаковое решение, разрыв в полтора десятилетия — хорошая иллюстрация того, что это свойство модели, а не чьей-то реализации.
C++ — самая точная точка сравнения. Виртуальный вызов там тоже непрямой: объект несёт указатель на vtable, вызов идёт через фиксированное смещение в ней. Но смещение вычислено компилятором и неизменно, а невиртуальные поля читаются вообще по константному сдвигу от начала объекта. Python делает ровно то же самое — ищет по имени и читает по смещению — только вычисляет это смещение в рантайме и заново кеширует после каждого изменения класса. Инлайн-кеш из раздела L2 и есть попытка приблизиться к vtable, не отказываясь от возможности переписать класс на ходу.
Java разрешает поле статически на этапе компиляции. Обращение стоит одну инструкцию, и это быстро. Но property туда уже не вставить — нужен заранее написанный геттер; подменить поле в рантайме нельзя; ORM обязан генерировать байткод. Скорость куплена отказом от гибкости, а не бесплатно.
Кеш посчитанного значения. Работает, тестами покрыт, в проде отдаёт устаревшие числа.
class Cart:
def __init__(self, items):
self.items = items
@cached_property
def total(self):
return sum(i.price for i in self.items)
Дальше в коде корзину пополняют — и сумма не меняется:
cart = Cart([Item(10), Item(20)])
print(cart.total)
# → 30
cart.items.append(Item(70))
print(cart.total)
# → 30
print(cart.__dict__)
# → {'items': [...], 'total': 30}
После раздела 6 это уже не сюрприз, а прямое следствие: первое обращение сработало по шагу 3, дескриптор посчитал 30 и положил результат в словарь инстанса. Со второго раза шаг 2 находит его раньше, и метод не вызывается больше никогда. Инвалидации не существует — её негде разместить, потому что дескриптор в этом сценарии уже не участвует.
Почему ревью пропускает: @cached_property читается как «дешевле считать один раз» и выглядит безобидной оптимизацией. Тесты обычно создают объект, читают total и проверяют число — сценария «изменили и прочитали снова» в них нет. А в проде корзина живёт между запросами.
Дальше есть развилка, и она содержательная. Если объект неизменяемый — cached_property идеален. Если изменяемый, нужен либо обычный property (считать каждый раз), либо явный сброс self.__dict__.pop("total", None) при мутации, либо frozen-объект и пересоздание. Правильный выбор зависит от того, что дороже: пересчёт или риск устаревания, — и теперь этот выбор делается осознанно, а не по привычке.
Две вещи, дающие вместе кратный эффект на горячем коде, и обе следуют из процедуры поиска.
Первое — убрать словарь у массовых объектов. Двести тысяч экземпляров класса из двух полей:
| класс | на объект | 200 000 объектов |
|---|---|---|
| обычный | 128 Б | 25.6 МБ |
со __slots__ | 88 Б | 17.6 МБ |
Треть памяти за одну строку объявления. Экономия берётся оттуда, что шага 2 больше нет: вместо хранилища на объект — дескрипторы с фиксированными смещениями на классе.
Второе — сохранить однородность горячего цикла. Одна и та же функция, суммирующая o.v по тридцати тысячам объектов; в первом случае все объекты одного класса, во втором перемешаны три класса:
| цикл | 200 проходов |
|---|---|
| объекты одного класса | 0.063 с |
| перемешаны три класса | 0.088 с |
Разница в 1.4 раза при полностью идентичном коде. Причина в разделе L2: инлайн-кеш хранит одну версию типа, и на однородной коллекции сверка версии проходит всегда, а на разнородной — промахивается на каждом третьем элементе и откатывается на общий путь поиска.
Практический вывод, который иначе выглядит суеверием: разложить разнородную коллекцию по однородным спискам и обработать их по очереди бывает выгоднее, чем один проход по смеси. Причём профайлер этого не подскажет — он покажет, что время уходит в цикл, а почему оно там уходит, видно только из механизма.
Замерено на CPython 3.11, linux x86-64. На другой версии числа будут другими, соотношение — тем же.
Прогони четыре фрагмента, сверяясь с предсказанием. Потом — четыре вопроса, ответы на которые выводятся из процедуры, но в тексте не даны:
# 1. Что напечатает и почему?
class F:
def __getattr__(self, n): return "запасной"
@property
def p(self): raise AttributeError("ой")
print(F().p)
# 2. Почему это НЕ вызовет __getattr__?
class G:
def __getattr__(self, n): return lambda: 0
len(G())
# 3. Сделай атрибут, доступный на чтение у класса, но не у инстанса.
# 4. Почему cached_property не работает с __slots__,
# а обычный property — работает?
Первый вопрос — самый полезный: он показывает, что шаги процедуры не изолированы, и AttributeError изнутри property неотличим от «атрибут не найден».
functools.cached_property — тридцать строк, в которых процедура из раздела 2 используется как приём. Это дескриптор не-данных: у него есть __get__ и намеренно нет __set__.
class E:
@cached_property
def z(self):
print("вычисляю")
return 42
e = E()
print(e.z)
# → вычисляю
# → 42
print(e.z)
# → 42
print(e.__dict__)
# → {'z': 42}
Заметь, почему второй раз ничего не печатается. При первом обращении шага 2 нет — в инстансе пусто, срабатывает шаг 3, дескриптор считает значение и кладёт его в словарь инстанса под тем же именем. Со второго раза шаг 2 находит его первым, и дескриптор больше не вызывается никогда. Кеш работает не потому, что внутри что-то запоминается, а потому что шаг 2 идёт раньше шага 3.
И сразу следствие, которое иначе выглядит произволом: cached_property не работает на классах со __slots__. Некуда положить — шага 2 не существует.
Сравни с property в том же Lib/functools.py и в Objects/descrobject.c: тот дескриптор данных, поэтому вызывается всегда. Один и тот же протокол, два вида, противоположное поведение — и вся разница в наличии __set__.
Из L1 ты знаешь порядок из четырёх шагов. Здесь — где живут «данные инстанса» на самом деле и почему повторное обращение дешевле первого.
Словарь на объект — дорого. Наивная реализация дала бы каждому объекту полноценный словарь:
class P: pass
p = P(); p.x = 1
print((sys.getsizeof(p), sys.getsizeof(p.__dict__)))
# → (56, 296)
class S: __slots__ = ('x',)
print(sys.getsizeof(S()))
# → 40
Триста байт на объект с одним атрибутом — неприемлемо, если объектов миллион. Поэтому в CPython есть два уровня оптимизации.
Разделяемые ключи. У всех инстансов одного класса набор имён атрибутов почти всегда одинаков. Значит имена можно хранить один раз на класс, а в объекте держать только массив значений. Это и делается: словарь инстанса расщеплён на общую таблицу ключей и приватный массив значений. С 3.11 этот массив лежит в памяти прямо перед объектом, а настоящий __dict__ собирается только если о нём попросили 🔧 — отсюда совет не трогать __dict__ без нужды: обращение к нему материализует словарь и отменяет оптимизацию.
Инлайн-кеш. Даже с быстрым хранилищем каждое obj.x — это поиск. Начиная с 3.11 инструкция LOAD_ATTR несёт рядом с собой служебные ячейки и после нескольких исполнений заменяет себя на специализированный вариант: запомнить версию типа и смещение, дальше сверить версию и прочитать по смещению. Посмотреть можно прямо из Python:
import dis
def f(o): return o.x
print(dis.dis(f, adaptive=True))
# → LOAD_FAST 0 (o)
# → LOAD_ATTR 0 (x)
for _ in range(100): f(p)
print(dis.dis(f, adaptive=True)) # после прогрева
# → LOAD_ATTR_INSTANCE_VALUE 0 (x)
Инструкция изменилась сама. Любое изменение класса поднимает его версию и обесценивает кеш — за гибкость по-прежнему платят, просто теперь только те, кто ею пользуется.
Отсюда практический вывод, который на собеседовании звучит как знание внутренностей, а на деле выводится: горячий цикл, в котором класс объекта каждый раз разный, теряет специализацию, и obj.x там стоит заметно дороже, чем в цикле по однородной коллекции.
Тег v3.11.15. Читать в этом порядке.
Objects/object.c, функция _PyObject_GenericGetAttrWithDict — процедура из раздела 2, буквально. Найди в ней проверку f != NULL && PyDescr_IsData(descr): вот он, шаг 1.Objects/typeobject.c, mro_implementation — C3-линеаризация. Сама merge-процедура занимает страницу.Objects/funcobject.c, func_descr_get — три строки, из-за которых self явный.Python/specialize.c, _Py_Specialize_LoadAttr — как принимается решение специализировать инструкцию и по каким причинам специализация не удаётся. Список причин отказа сам по себе поучителен.__slots__ против разделяемых ключей: два способа не платить за словарь на объект, с разными компромиссами__set__, а поведение противоположное@property и @classmethod как частные случаи одного механизмаproperty, classmethod и функций на Python. Читать целиком.