obj.x насквозь

Самая частая строка в языке и один из самых глубоких провалов в понимании. Одна процедура из четырёх шагов объясняет дескрипторы, property, явный self, __slots__, метаклассы и то, почему mypy принципиально не может быть надёжным.

L1 · 9 минL2 · 18 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Четыре фрагмента. Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
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
Атрибут инстанса появился, затенил классовый, исчез — и классовый вернулся.
Фрагмент B
class B:
    @property
    def v(self): return 1

b = B()
b.__dict__['v'] = 99
print(b.v)
1
Значение положено прямо в словарь инстанса — и проигнорировано.
Фрагмент C
class C:
    __slots__ = ('x',)

c = C()
c.y = 1
AttributeError: 'C' object has no attribute 'y'
Причём c.x = 1 работает прекрасно.
Фрагмент D
class D:
    def __getattr__(self, name):
        return f"нет такого: {name}"

d = D()
print(d.foo)
d.foo = 1
print(d.foo)
нет такого: foo
1
Один и тот же d.foo, два разных источника ответа.

2Механизм

Первое, что нужно принять: 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. Инвариант класса удержан, несмотря на прямую запись в его словарь.

Отсюда правило, которое теперь не надо запоминать — оно выводится: дескриптор данных имеет приоритет над инстансом, потому что он про контроль; дескриптор не-данных уступает инстансу, потому что он про поведение по умолчанию.

Что в Python на самом деле является дескриптором

Почти всё, что выглядит как отдельная возможность языка:

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
@propertypropertyданныхЗовёт функцию-геттер
@classmethodclassmethodне-данныхПодставляет первым аргументом класс, а не инстанс
@staticmethodstaticmethodне-данныхВозвращает функцию как есть, ничего не подставляя
слот __slots__member_descriptorданныхЧитает и пишет по фиксированному смещению в объекте
@cached_propertycached_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#

В C++ обращение к полю — смещение, вычисленное компилятором; перехватить его нельзя, можно только заранее написать геттер и вызывать его явно. В C# есть свойства с синтаксисом get/set, но это синтаксис компилятора: каждое свойство описывается по месту и не является объектом.

Разница принципиальная: дескриптор — это объект. Значит его можно создать один раз и переиспользовать в десяти классах, параметризовать при создании, положить в список, сгенерировать в цикле. Валидатор поля, ленивое поле, поле с логированием доступа, поле из базы — всё это в Python пишется один раз как класс и дальше используется как x = PositiveInt().

Именно на этом стоят ORM (поле модели Django — дескриптор), pydantic, dataclasses с валидацией и половина конфигурационных библиотек. Не «магия фреймворка», а один протокол из трёх методов.

Процедура поиска obj.x

Шаг 1. Ищем x в типе объекта и всех классах его MRO. Если нашли и это дескриптор данных — вызываем его __get__. Конец.

Шаг 2. Иначе смотрим в данные самого инстанса. Нашли — возвращаем. Конец.

Шаг 3. Иначе возвращаемся к тому, что нашли на классе на шаге 1. Дескриптор не-данных — вызываем __get__; обычное значение — возвращаем как есть. Конец.

Шаг 4. Не нашли нигде — вызываем __getattr__, если он определён. Иначе AttributeError.

Четыре шага. Теперь все четыре фрагмента получаются прямым применением.

A · затенение

Сначала x есть только на классе и не является дескриптором — срабатывает шаг 3, видим 1. Присваивание a.x = 2 кладёт значение в данные инстанса, и теперь шаг 2 срабатывает раньше шага 3: инстанс видит 2, класс по-прежнему 1. del a.x убирает запись инстанса, шаг 2 снова не находит ничего, шаг 3 возвращает классовое значение. Ничего не «перезаписывалось» — просто менялось, какой шаг успевает первым.

B · почему property нельзя перебить

property — не синтаксис и не магия. Это обычный объект с __get__, __set__ и __delete__, то есть дескриптор данных, лежащий на классе. Значит срабатывает шаг 1 и до словаря инстанса дело не доходит вовсе. Значение 99 лежит в b.__dict__ и недостижимо.

Отсюда же выводится то, что обычно запоминают отдельно: присваивание в property без сеттера падает не потому, что «property только для чтения», а потому, что __set__ у него определён всегда и просто выбрасывает AttributeError.

C · что такое __slots__

__slots__ делает две вещи. Убирает у инстанса __dict__ — то есть отключает шаг 2 полностью. И создаёт на классе по одному дескриптору данных на каждое объявленное имя, каждый из которых читает и пишет по фиксированному смещению в объекте.

Дальше всё следует. c.x работает, потому что шаг 1 находит дескриптор слота. c.y = 1 падает, потому что дескриптора y на классе нет, а положить значение больше некуда — шага 2 не существует. И становится понятно, почему __slots__ экономит память: исчезает целый словарь на объект.

D · __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__ — синтезировать любое имя. Статический анализатор обязан предполагать, что этого не произошло. Он полезен, но его выводы — не доказательства.

3Границы модели

Где сказанное перестаёт держать

Дандеры ищутся мимо этой процедуры. Когда интерпретатор сам вызывает специальный метод — len(obj), obj + x, with obj — он идёт напрямую в тип, минуя и инстанс, и __getattr__. Поэтому объект-прокси, синтезирующий любые имена через __getattr__, всё равно не станет итерируемым: __iter__ так подставить нельзя 🔒. Разобранная процедура описывает явное обращение obj.x, а не неявные вызовы.

Для класса процедура другая. A.x — это обращение к атрибуту объекта-класса, и работает оно через тип класса, то есть через метакласс. Отсюда и то, что property на метаклассе виден у класса, но не у инстансов.

«Данные инстанса» — не обязательно __dict__. Начиная с 3.11 значения атрибутов обычного объекта физически лежат не в отдельном словаре, а в области рядом с объектом, и словарь материализуется по требованию 🔧. На поведение это не влияет — на память и скорость влияет сильно. Подробности в L2.

4Корень

Решение, из которого всё выросло: атрибут не разрешается заранее — он ищется в момент обращения, а пространство имён объекта изменяемо во время работы программы.

Ранний Python имел это свойство почти случайно, как следствие простоты реализации. Осознанным решением оно стало в 2001 году, когда Гвидо объединял типы и классы: тогда появились дескрипторы, MRO и property — и всё это было построено поверх динамического поиска, а не вместо него. Язык мог на этом шаге зафиксировать раскладку объектов и стать быстрее. Вместо этого он получил единый механизм, через который выражаются property, методы, слоты, ORM и валидаторы, — ценой того, что каждое обращение к атрибуту стоит работы.

Эта цена оставалась неоплаченной двадцать лет и была одной из главных причин репутации «Python медленный». Заплатили в 3.11: интерпретатор научился запоминать результат поиска прямо в потоке байткода. Механизм не изменился — изменилось то, что второй и последующий разы он не выполняется целиком.

5Аналогия

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().

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему 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) или вообще массив вместо объектов.

Если атрибут — поиск по словарям, почему он всего на 5% дороже локальной переменной?

Потому что поиска почти не происходит: инлайн-кеш запоминает результат прямо в инструкции.

Замер из урока 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.
Дальше — по желанию
контрфактуалC++ vtable, JavaScript, JavaСмещение, вычисленное компилятором, против вычисляемого в рантайме
Контрфактуал · те же вопросы у других

JavaScript принял ровно то же решение — динамический поиск по цепочке прототипов — и столкнулся с той же ценой на десять лет раньше. Ответ V8 в 2008 году: скрытые классы плюс инлайн-кеши. Python пришёл к тому же выводу в 2022-м. Одинаковая задача, одинаковое решение, разрыв в полтора десятилетия — хорошая иллюстрация того, что это свойство модели, а не чьей-то реализации.

C++ — самая точная точка сравнения. Виртуальный вызов там тоже непрямой: объект несёт указатель на vtable, вызов идёт через фиксированное смещение в ней. Но смещение вычислено компилятором и неизменно, а невиртуальные поля читаются вообще по константному сдвигу от начала объекта. Python делает ровно то же самое — ищет по имени и читает по смещению — только вычисляет это смещение в рантайме и заново кеширует после каждого изменения класса. Инлайн-кеш из раздела L2 и есть попытка приблизиться к vtable, не отказываясь от возможности переписать класс на ходу.

Java разрешает поле статически на этапе компиляции. Обращение стоит одну инструкцию, и это быстро. Но property туда уже не вставить — нужен заранее написанный геттер; подменить поле в рантайме нельзя; ORM обязан генерировать байткод. Скорость куплена отказом от гибкости, а не бесплатно.

🐛 багУстаревший cached_propertyДескриптор пишет в __dict__ и больше не вызывается — инвалидации негде разместить
🐛 Баг, который проходит ревью

Кеш посчитанного значения. Работает, тестами покрыт, в проде отдаёт устаревшие числа.

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-объект и пересоздание. Правильный выбор зависит от того, что дороже: пересчёт или риск устаревания, — и теперь этот выбор делается осознанно, а не по привычке.

⚡ 1.4×__slots__ и однородный цикл128 → 88 байт на объект; моно-цикл 0.063 с против 0.088 с на смеси классов
⚡ Оптимизация, которую без механизма не найти

Две вещи, дающие вместе кратный эффект на горячем коде, и обе следуют из процедуры поиска.

Первое — убрать словарь у массовых объектов. Двести тысяч экземпляров класса из двух полей:

классна объект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. На другой версии числа будут другими, соотношение — тем же.

💻 терминалПроверь в REPLЧетыре фрагмента плюс AttributeError внутри property и дандеры мимо __getattr__

Проверь

Прогони четыре фрагмента, сверяясь с предсказанием. Потом — четыре вопроса, ответы на которые выводятся из процедуры, но в тексте не даны:

# 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Тридцать строк, где порядок шагов используется как приём

Где это живёт в реальном коде

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

L2Где физически лежат атрибуты и сколько стоит точкаManaged dict, разделяемые ключи, квикенинг LOAD_ATTR в dis

Из 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 там стоит заметно дороже, чем в цикле по однородной коллекции.

L3Где это в исходниках CPython_PyObject_GenericGetAttrWithDict, mro_implementation, specialize.c

Тег 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 — как принимается решение специализировать инструкцию и по каким причинам специализация не удаётся. Список причин отказа сам по себе поучителен.
связиКуда это ведётL01, L05, L06, L08 · контраст дескрипторов данных и не-данных

Связи

корень R2 · Атрибут резолвится в рантайме, неймспейс — dict
← основа L01 · Имя, объект, ссылка — «данные инстанса» это привязки, а не значения
↔ контраст __slots__ против разделяемых ключей: два способа не платить за словарь на объект, с разными компромиссами
↔ контраст Дескриптор данных против дескриптора не-данных — вся разница в __set__, а поведение противоположное
→ дальше L05 · dict и хешируемость — что за структура держит и ключи, и разделяемую таблицу
→ дальше L08 · Функции и декораторы — @property и @classmethod как частные случаи одного механизма
чего здесь нет C3 разобрана как понятие, но не как алгоритм; метаклассы упомянуты и живут в L07
источникиЧто почитатьDescriptor HowTo 🟥 · PEP 659 🟧 · PEP 252 🟦

Что почитать