Что происходит на o.x: дескрипторы, MRO и кеш типа
Точка — не чтение поля. Это протокол из четырёх механизмов, и порядок между ними задаётся одной строчкой приоритета. Из неё следует и то, почему присваивание в property без сеттера даёт AttributeError, и почему __getattr__ иногда не вызывается, и почему обращение к унаследованному атрибуту не зависит от глубины наследования — пока цел кеш версий типа. А на 3.14 нашёлся способ выключить этот кеш, ничего про него не зная.
Полное техническое изложение
TL;DR
- Точка в
o.x— не чтение поля, а поиск по четырём местам в строгом порядке: дескриптор данных в классе → словарь экземпляра → дескриптор не-данных → атрибут класса. Почти всё остальное в теме — следствие этого порядка. - Метод,
property,staticmethod,classmethod, слот__slots__,cached_property— это всё дескрипторы, объекты одного устройства. Отдельного «механизма методов» в Python нет. - За глубину наследования платить не приходится: результат поиска лежит в кеше, привязанном к версии типа. При двух классах в цепочке и при пятидесяти одном — одинаковые ~11 нс. Дорого не наследование, а правка классов во время работы: она обнуляет кеш и классу, и всем наследникам.
- Порядок классов при множественном наследовании — не «слева направо»: общий предок уходит в хвост, и в ромбе выигрывает правая ветка.
- Ловушка, появившаяся в 3.14: класс, чей метакласс переопределяет
mro(), теряет этот кеш — вместе со всеми наследниками, которые о метаклассе даже не знают.
Точка в o.x выглядит как чтение поля, а на самом деле это поиск: интерпретатор
спрашивает несколько мест по очереди и берёт первый ответ. Порядок этих мест и
есть всё содержание темы.
Порядок, из которого растёт остальное
Мест четыре, и приоритет между ними такой:
- дескриптор данных в классе — объект, который умеет и отдавать значение, и перехватывать присваивание;
- словарь самого экземпляра — то, что вы положили через
self.x = ...; - дескриптор не-данных — умеет только отдавать;
- обычный атрибут класса.
Разница между первым и третьим пунктом — одна: умеет ли объект перехватывать запись. Если умеет, он сильнее словаря экземпляра; если нет — слабее.
Из этой единственной строчки следуют вещи, которые обычно заучивают по
отдельности. @property умеет перехватывать запись, поэтому его нельзя
перекрыть, положив значение в экземпляр. functools.cached_property не умеет
— поэтому он и кеширует: при первом обращении кладёт результат в экземпляр, а
дальше словарь экземпляра выигрывает, и его больше не спрашивают.
Всё привычное — дескрипторы
Обычный метод, property, staticmethod, classmethod, слот __slots__,
cached_property — это всё объекты одного устройства. Отдельного «механизма
методов» в Python нет: метод — это функция, лежащая в классе, у которой есть
__get__, и она при обращении возвращает себя, привязанную к экземпляру.
Порядок классов — не «слева направо»
Когда классов несколько, интерпретатор идёт по списку __mro__. Он строится
не «сначала вся левая ветка, потом правая»: общий предок уходит в конец.
class Base:
def m(self): return "Base"
class L(Base): pass
class R(Base):
def m(self): return "R"
class D(L, R): pass
print(D().m()) # R, а не BaseЕсли бы обход шёл вглубь по левой ветке, Base встретился бы раньше R. Он
встречается позже — и выигрывает R.
Иногда такого порядка не существует вовсе, и тогда класс просто не создаётся:
TypeError: Cannot create a consistent method resolution order.
За длину наследования платить не приходится
Казалось бы, чем длиннее цепочка классов, тем дольше поиск. На практике нет: результат поиска лежит в кеше, привязанном к версии типа.
Замер: обращение к унаследованному атрибуту стоит около 11 наносекунд и не зависит от длины цепочки — при двух классах и при пятидесяти одном время одинаковое. Стоит сбивать кеш на каждом шаге, и появляется рост: 181 наносекунда вместо 11 на длинной цепочке.
Отсюда практическое: глубокое наследование само по себе не медленное, а вот правка классов во время работы программы — дорогая. Она обнуляет кеш не только для этого класса, но и для всех наследников.
Одна ловушка, появившаяся в 3.14
У каждого класса есть свой класс — тот, что его создаёт; называется он
метаклассом, и обычно это встроенный type. Метакласс можно написать свой и
переопределить в нём метод mro(), который решает, в каком порядке
просматриваются базовые классы.
Если такой класс стоит в основании иерархии, то на Python 3.14 кеш для него
выключается — и для всех наследников тоже. На цепочке из пятидесяти одного
класса обращение к атрибуту стоит тогда 118,4 нс вместо 6,4 — в восемнадцать
раз дороже; на короткой цепочке из двух классов разница меньше, 15,3 против
6,5. Найти это чтением своего кода нельзя: наследник объявлен обычным
class Sub(Base) и о метаклассе не упоминает.
На 3.12 и 3.13 такого нет. Приём редкий — ни один модуль стандартной
библиотеки mro() не переопределяет, — но в библиотеках плагинов и ORM
встречается.
Мелочь, которая противоречит привычке
«Вынеси поиск атрибута из цикла» — правило верное для функций модуля и
неверное для методов экземпляра. Измерено: obj.m() целиком стоит 16,3 нс, а
одно только obj.m — 24,6. Взять и не вызвать дороже, чем взять и вызвать:
при слитной записи объект связанного метода не создаётся вовсе.
TL;DR
Точка — это не чтение поля. Это протокол, и порядок в нём задаёт одна строчка:
дескриптор данных сильнее словаря экземпляра, словарь экземпляра сильнее
дескриптора не-данных. Дескрипторами при этом оказываются все привычные
члены класса — обычный метод, property, staticmethod, classmethod, слот
__slots__, cached_property.
Порядок обхода классов задаёт C3, и он не «сначала вся левая ветка». В
ромбе class D(L, R) общий предок уходит в хвост: ['D', 'L', 'R', 'Base', 'object'], и метод берётся у R.
Платить за длину этой цепочки не приходится — её результат лежит в кеше
версий типа. Замер: при живом кеше обращение стоит около 11 нс и не зависит
от длины __mro__ вовсе (наклон +0,02 нс на уровень при длинах от 2 до 51).
Сбейте кеш — и появляется линейный рост, +3,31 нс на уровень: 180,8 нс против
11,9 на цепочке из пятидесяти одного класса.
И главное: на 3.14 метакласс, переопределяющий mro() — хотя бы и
возвращающий ровно super().mro(), — выключает кеш для своего класса и для
всех наследников. На цепочке из пятидесяти одного класса это 118,4 нс против
6,4 у обычного типа, и платит тот, кто написал обычный class Sub(Base) и ни
про какой метакласс не знает. На 3.12 и 3.13 эффекта нет.
Одна точка и четыре механизма
o.x в исходнике превращается в одну инструкцию LOAD_ATTR. За ней стоит
tp_getattro типа, у обычных классов это object.__getattribute__, и вот он
уже не простой: ему нужно сложить ответ из четырёх источников, у которых
разный приоритет.
Документация формулирует порядок одним предложением: 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__(), если он определён).__getattr__() if it is provided
Дальше вся статья — про то, что стоит за каждым словом этой фразы и во что обходится.
Правило приоритета: два слова, которые всё решают
Дескриптор — объект, который лежит в классе и у типа которого есть
__get__, __set__ или __delete__. Признак единственный, отдельного
«механизма дескрипторов» больше нет.
Разделение на два вида проходит по одному признаку: есть ли __set__ (или
__delete__). Есть — дескриптор данных, нет — дескриптор не-данных.
Документация говорит о последствиях прямо: Data descriptors always override instance
dictionaries. Non-data descriptors may be overridden by instance
dictionaries
(Дескрипторы данных всегда перекрывают словари экземпляров. Дескрипторы не-данных могут быть перекрыты словарями экземпляров).
Проверяется это одним опытом, в котором оба имени записаны напрямую в
__dict__ экземпляра — то есть присваивание не выполнялось и перехватить его
было некому:
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) # descriptor
print(c.nondata) # instance dictОдно и то же действие — запись в словарь экземпляра — в одном случае не значит
ничего, в другом решает всё. Разница только в наличии __set__ у типа
дескриптора.
«Всегда перекрывают» — почти всегда
Процитированную фразу про «always override» стоит уточнить, потому что
классификация и приоритет при чтении — два разных правила, и совпадают они не
везде. Дескриптором данных тип делает __set__ или __delete__; про
__get__ в этом условии не сказано ничего. А object.__getattribute__ уходит
в дескриптор до словаря экземпляра только если тот и классифицирован как
дескриптор данных, и умеет отдавать.
Тип с одним лишь __delete__ попадает ровно в этот зазор:
| у типа дескриптора | del obj.x | obj.x при своём значении в __dict__ |
|---|---|---|
__get__ | уходит в словарь | из словаря |
__get__ + __set__ | AttributeError | из дескриптора |
__get__ + __delete__ | в дескриптор | из дескриптора |
только __delete__ | в дескриптор | из словаря |
Последняя строка и есть исключение из «always override»: удаление дескриптор
перехватывает — то есть дескриптором данных он и правда считается, — а чтение
до него не доходит, потому что отдавать ему нечем. На практике так почти
никто не пишет, и потому фраза документации работает; но опереться на неё как
на определение нельзя. Проверено на 3.12.3, 3.13.7 и 3.14.7:
bench/attr-lookup/descriptor_kinds.py.
__getattr__ вызывается не оттуда, откуда вы думаете
Последний пункт цепочки стоит особняком, и это источник вопроса «почему у меня
не вызывается __getattr__». Его нет в __getattribute__ вовсе: Note, there is no
(Заметьте, в коде __getattribute__() нет обращения к __getattr__(). Именно поэтому прямой вызов __getattribute__() или вызов через super().__getattribute__ обходит __getattr__() полностью).__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__() отвечают оператор «точка» и функция getattr(), всякий раз когда __getattribute__() возбуждает AttributeError).getattr() function that are responsible for invoking __getattr__()
whenever __getattribute__() raises an AttributeError
Здесь легко сделать вывод, который кажется прямым следствием и неверен: будто
собственный __getattribute__, делегирующий поиск в super(), тем самым
отключает __getattr__. Не отключает — и это стоит проверить, а не принять на
слово:
class C:
def __getattribute__(self, name):
return super().__getattribute__(name)
def __getattr__(self, name):
return "из __getattr__"
C().missing # 'из __getattr__' — сработал
C().__getattribute__("missing") # AttributeErrorРазница в том, КТО спрашивает. Запасной путь лежит уровнем выше самого
__getattribute__, и включает его механизм обычной точки — всякий раз, когда
__getattribute__, свой или унаследованный, выпустил наружу AttributeError.
Делегирование в super() это исключение не гасит, а пропускает, и точка
подхватывает его как обычно.
Процитированная выше фраза документации при этом верна — просто она про другое: про прямой вызов, у которого никакой точки сверху нет. Ловушка не в документации, а в переносе её вывода с одиночного вызова на всё выражение.
Отключает __getattr__ не делегирование, а поглощение: __getattribute__,
который сам ловит AttributeError и возвращает или возбуждает что-то своё.
Тогда наружу исключения нет — и точке нечем запустить запасной путь. Все три
случая напечатаны для 3.12.3, 3.13.7 и 3.14.7 в
bench/attr-lookup/getattr_hook.py; ведут они себя одинаково.
Методы, property, слоты и cached_property — дескрипторы
Не «всё, что лежит в классе»: x = 42 в теле класса — обычный атрибут, и
никакого протокола за ним нет. Но за доступом почти ко всем привычным
«специальным» членам стоит именно он. inspect.getattr_static достаёт объект
из класса, минуя протокол, и видно, что все шесть устроены одинаково:
| что написано в классе | что лежит на самом деле | __get__ | вид |
|---|---|---|---|
| обычный метод | function | есть | не-данных |
@property | property | есть | данных |
@staticmethod | staticmethod | есть | не-данных |
@classmethod | classmethod | есть | не-данных |
__slots__ | member_descriptor | есть | данных |
@functools.cached_property | cached_property | есть | не-данных |
Из этой таблицы выводятся два поведения, которые обычно заучивают порознь.
Почему cached_property кеширует. Он дескриптор НЕ данных. При первом
обращении его __get__ кладёт результат в __dict__ экземпляра — и всё,
дальше словарь экземпляра сильнее, и дескриптор больше не спрашивают. Кеш
здесь не механизм, а следствие приоритета.
Почему property перехватывает и присваивание. Он дескриптор данных:
__set__ у него есть всегда, даже когда сеттер не объявлен, — именно поэтому
присваивание в property без сеттера даёт AttributeError, а не тихо создаёт
атрибут экземпляра.
Где живёт порядок: MRO и C3
Классы обходятся не «слева направо и вглубь», а в порядке __mro__, который
строит C3. Формула из документации:
L[C(B1 ... BN)] = C + merge(L[B1] ... L[BN], B1 ... BN)
Правило слияния — take the head of the first list, i.e L[B1][0]; if this head is
not in the tail of any of the other lists, then add it to the linearization of
C and remove it from the lists in the merge, otherwise look at the head of the
next list and take it, if it is a good head
(взять голову первого списка; если этой головы нет в хвосте ни одного из остальных списков, добавить её к линеаризации C и убрать из списков слияния, иначе посмотреть на голову следующего списка и взять её, если она годная).
На ромбе видно, чем это отличается от интуиции:
class Base:
def m(self): return "Base"
class L(Base): pass
class R(Base):
def m(self): return "R"
class D(L, R): pass
print([c.__name__ for c in D.__mro__])
print(D().m())['D', 'L', 'R', 'Base', 'object']
R
Общий предок ушёл в хвост. Если бы обход шёл «сначала вся ветка L»,
Base встретился бы вторым и ответ был бы 'Base'. Правило «голова не должна
встречаться в хвостах остальных» ровно это и запрещает: Base есть в хвосте
линеаризации R, поэтому взять его раньше R нельзя.
Когда порядка не существует
Not all classes admit a linearization
(Не всякий класс допускает линеаризацию) — и тогда класс просто не
создаётся:
class X: pass
class Y: pass
class A(X, Y): pass
class B(Y, X): pass
class Impossible(A, B): passTypeError: Cannot create a consistent method resolution order (MRO) for bases X, Y
A требует X раньше Y, B — наоборот. Это не ограничение реализации, а
отсутствие решения: порядка, который уважал бы оба требования, нет.
Насколько эти цепочки длинные в жизни
Короткие. У типов, с которыми имеешь дело каждый день, __mro__ — два-четыре
элемента:
| тип | длина | __mro__ |
|---|---|---|
int, str, list, dict | 2 | сам тип и object |
bool | 3 | bool, int, object |
Exception | 3 | Exception, BaseException, object |
ValueError | 4 | плюс Exception и BaseException |
io.StringIO | 4 | StringIO, _TextIOBase, _IOBase, object |
Длины 21 и 51 в замерах ниже — модель, а не типичный код. Они нужны, чтобы увидеть наклон: на цепочке из трёх классов разницу между «кеш есть» и «кеша нет» не различить.
Кеш версий типа
Поиск по __mro__ выполняется не на каждое обращение. У типа есть поле
tp_version_tag, и результат поиска пары «тип, имя» ложится в общую на весь
интерпретатор таблицу. В заголовках 3.13.7 она объявлена так:
// Type attribute lookup cache: speed up attribute and method lookups,
// see _PyType_Lookup().
struct type_cache_entry {
unsigned int version; // initialized from type->tp_version_tag
PyObject *name; // reference to exactly a str or None
PyObject *value; // borrowed reference or NULL
};
#define MCACHE_SIZE_EXP 12Четыре тысячи девяносто шесть ячеек, в каждой — версия типа, имя и найденное
значение. Совпала версия и имя — ответ берётся отсюда, __mro__ не трогается
вовсе.
Официальной документации на этот кеш не существует. Есть ровно одна фраза,
и та в описании функции C API: Invalidate the internal lookup cache for the type
and all of its subtypes. This function must be called after any manual
modification of the attributes or base classes of the type
(Сбросить внутренний кеш поиска для типа и всех его подтипов. Эту функцию необходимо вызывать после любого ручного изменения атрибутов или базовых классов типа). Всё остальное
ниже выведено из заголовков и из замеров, и это стоит держать в голове: речь
про деталь реализации, а не про гарантию языка.
Замер устроен так: одно и то же o.m при четырёх длинах __mro__, в двух
режимах. В первом ничего не трогается, кеш живёт. Во втором на каждой итерации
в класс что-нибудь присваивается — этого достаточно, чтобы версия сбросилась.
Цена самого присваивания меряется отдельной строкой и вычитается, иначе замер
показывал бы стоимость setattr, а не промаха.
длина __mro__ | кеш цел, 3.13.7 | кеш сбивается, 3.13.7 |
|---|---|---|
| 2 | 10,8 нс | 18,7 нс |
| 6 | 10,9 нс | 29,2 нс |
| 21 | 11,0 нс | 87,4 нс |
| 51 | 11,9 нс | 180,8 нс |
Наклон по крайним точкам: +0,02 нс на уровень при живом кеше и +3,31 нс без него. Первое число — это и есть утверждение «не зависит»: на разбросе в пару наносекунд тренда нет вовсе, при том что длина цепочки меняется в двадцать пять раз.
На 3.14.7 картина та же, а строка с живым кешем ещё чище: 12,0 / 12,3 / 12,0 / 12,0 нс. Без кеша: 21,9 / 31,4 / 70,9 / 126,8, наклон +2,14 нс на уровень. Сравнивать эти четвёрки с предыдущими нельзя: сборки различаются не только версией языка. Сравнивать надо строки внутри одной колонки, и там вывод одинаков.
Что сбивает кеш
Версия типа сбрасывается при изменении самого типа: присваивание атрибута
классу, удаление атрибута, подмена __bases__. Сбрасывается она и у всех
подтипов — ровно как обещает документация PyType_Modified.
Отсюда практическое следствие, которое обычно формулируют как загадку:
«накрутили monkey-patching в рантайме, и всё стало медленнее». Правка класса
в горячем цикле стоит не только своей цены: она обнуляет накопленное для этого
типа и всех его наследников, и следующий поиск идёт по __mro__ заново.
Обратная сторона — обнадёживающая: обычный код кеш не сбивает. Присваивание атрибута экземпляру версию типа не трогает; классы после импорта, как правило, не меняются; а значит, длина наследования в счёт не идёт.
Ловушка 3.14: свой mro() выключает кеш
Дальше — то, чего нет в документации и что видно только замером.
В 3.13 у типа появилось поле tp_versions_used — голое, без комментария. В
3.14 рядом с ним встало объяснение и константа:
/* Number of tp_version_tag values used.
* Set to _Py_ATTR_CACHE_UNUSED if the attribute cache is
* disabled for this type (e.g. due to custom MRO entries).
* Otherwise, limited to MAX_VERSIONS_PER_CLASS (defined elsewhere).
*/
uint16_t tp_versions_used;
};
#define _Py_ATTR_CACHE_UNUSED (30000) // (see tp_versions_used)if the attribute cache is disabled for this type (e.g. due to custom MRO
entries)
(если кеш атрибутов для этого типа выключен — например, из-за нестандартных элементов MRO у этого типа) — вот это «например» и оказалось измеримым.
Метакласс, который переопределяет mro(), лишает свой класс кеша. Причём
переопределять содержательно не требуется:
class OwnMRO(type):
def mro(cls):
return super().mro() # порядок НЕ меняетсяЗамер на 3.14.7, три конфигурации при трёх длинах цепочки:
| конфигурация | длина 2 | длина 21 | длина 51 |
|---|---|---|---|
обычный type | 6,5 нс | 6,4 нс | 6,4 нс |
метакласс без mro() | 6,4 нс | 6,4 нс | 6,4 нс |
метакласс со своим mro() | 15,3 нс | 63,8 нс | 118,4 нс |
Средняя строка стоит в замере не для симметрии: без неё замедление можно было
бы списать на метакласс вообще. Она ровная — значит, дело именно в
переопределённом mro().
На 3.13.7 все три строки ровные, 5,8–6,0 нс. На 3.12.3 тоже ровные, 12,7–12,9. Поведение изменилось в 3.14.
Почему это хуже, чем выглядит
Эффект наследуется. Достаточно одного класса, чей метакласс переопределяет
mro(), где-нибудь в основании иерархии: наследник объявляется обычным
class Sub(Base), метакласс Python выводит из баз сам, и кеша лишается тот,
кто про метакласс даже не знает. Замер: цепочка из пятидесяти одного класса,
mro() переопределён только у корня, все наследники объявлены обычным
type — 118,7 нс, столько же, сколько у самого корня, с точностью до разброса
замера.
То есть цена появляется в чужом файле, где ничего необычного не написано, и
не находится ни чтением этого файла, ни профилировщиком, который покажет
только «долго в LOAD_ATTR».
Приём редкий. В стандартной библиотеке сборок 3.12.3, 3.13.7 и 3.14.7
переопределений mro() нет ни одного — это разбор синтаксического дерева всех
574, 632 и 655 её модулей соответственно, bench/attr-lookup/mro_stdlib_scan.py;
запустите на своей сборке, ответ может отличаться. Оговорка обязательна:
набор тестов в эти сборки не входит, так что про тесты сказать нечего.
Нужен такой mro() коду, который намеренно вмешивается в порядок разрешения,
— например, метаклассам, собирающим иерархию на ходу. Называть тут
библиотеки поимённо не буду: под рукой нет проверенного списка, а
правдоподобное перечисление — то же сочинительство, только звучит солиднее.
Практический вывод узкий и проверяемый: если вы переопределяете mro() и
целитесь в 3.14, убедитесь, что это не в основании иерархии, которой
пользуются другие. А если наследуетесь от чужого класса и обращения к
атрибутам вдруг подорожали на порядок — вопрос решается одной строкой:
type(type(obj)).mro is type.mro # False → метакласс переопределил mro()Проверять надо именно так, а не глазами по чужому файлу: наследник объявлен
обычным class Sub(Base) и метакласс не упоминает, но метакласс у него тот же
самый — и проверка это видит.
Связанный метод создаётся не всегда
Вторая половина цены точки — то, что происходит после того, как значение
найдено. Функция в классе — дескриптор не-данных, и её __get__ создаёт
объект связанного метода. Но не всегда:
| что меряем | 3.13.7 |
|---|---|
obj.m() — взять и вызвать сразу | 16,3 нс |
obj.m — только взять | 24,6 нс |
bound() — вызвать уже взятый | 18,1 нс |
Взять атрибут и не вызвать дороже, чем взять и вызвать. Причина не в
измерении: интерпретатор специализирует пару «взять — вызвать» и передаёт
self отдельным аргументом, так что объект связанного метода не создаётся
вовсе. Разложите ту же работу на две строки — и он обязан появиться, потому
что его кладут в переменную: 24,6 + 18,1 против 16,3, то есть +26,4 нс на
ровном месте.
Отсюда практический вывод — но ровно в границах замера. Проверялся обычный
Python-метод на CPython 3.13.7 и 3.14.7, и на обеих сборках obj.method()
оказался дешевле заранее взятого связанного метода (+26,4 и +23,9 нс на
разложенную запись). Значит, привычное «вынеси поиск атрибута из цикла» —
не автоматическая оптимизация: для метода экземпляра она превращает
бесплатное в платное. Привычка остаётся верной для module.func, где
выносится настоящий поиск.
Дальше замера этот вывод не тянется, и растягивать его не стоит: у
C-функций, у объектов с собственным __getattribute__, у обёрток-заместителей
и у других реализаций Python соотношение может быть другим. Мерить надо свой
горячий участок.
super(): цена уровня
| что меряем | 3.13.7 |
|---|---|
direct.m(), внутри Base.m(self) | 31,5 нс |
mid.m(), внутри super().m() | 43,6 нс |
deep.m(), два уровня super() | 75,2 нс |
Один уровень super() против прямого обращения к базовому классу — +12,1
нс. Следующий уровень добавляет ещё 31,5, но приписывать это super()
целиком нельзя: между deep.m и Base.m встаёт целый кадр Mid.m, и большая
часть разницы — обычная цена лишнего вызова Python-функции.
Величина такая, что оптимизировать здесь нечего: двенадцать наносекунд не
окупают потерю кооперативного наследования, ради которого super() и
существует. Знать её стоит для другого — чтобы не искать в super() причину,
когда профилировщик показывает микросекунды.
Что из этого следует для кода
Длина наследования сама по себе не стоит ничего. Пока классы не правятся в рантайме, кеш держит результат, и цепочка из пятидесяти классов обходится как цепочка из двух. Аргумент «не наследуйся глубоко, это медленно» замером не подтверждается.
Правка классов во время работы программы стоит дорого и не там, где кажется. Цена не в самом присваивании, а в том, что оно обнуляет кеш для типа и всех наследников.
property — это дескриптор данных, и его __set__ есть всегда. Отсюда
AttributeError при присваивании без сеттера и невозможность «перекрыть»
property записью в __dict__ экземпляра.
cached_property не работает, когда у экземпляров нет __dict__ — класть
значение ему больше некуда. Так выходит, например, если __slots__ объявлены
по всей цепочке классов. Это прямое следствие того, что он дескриптор
не-данных.
В горячем цикле не выносите метод экземпляра в переменную — по крайней мере на 3.13 и 3.14. Измерено выше: это добавляет создание объекта связанного метода, которого при слитной записи не происходит. Для других видов вызываемых объектов проверяйте свой участок.
Свой mro() на 3.14 — решение с ценой. И цену платит не тот, кто его
написал.
История версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| 3.12 | Поля tp_versions_used у типа ещё нет. Все конфигурации замера ровные: кеш работает одинаково и для обычного типа, и для класса с переопределённым mro(). | |
| 3.13 | Поле tp_versions_used появляется в cpython/object.h — объявлено голым, без комментария, и на поведении это не сказывается: замер по-прежнему ровный во всех конфигурациях. functools.partial в теле класса ещё ведёт себя как обычный атрибут, но предупреждает: functools.partial will be a method descriptor in future Python versions; wrap it in staticmethod() if you want to preserve the old behavior (functools.partial станет дескриптором метода в будущих версиях Python; оберните его в staticmethod(), если хотите сохранить прежнее поведение). | |
| 3.14 | У того же поля появляется смысл: константа _Py_ATTR_CACHE_UNUSED и комментарий про выключенный кеш. Класс с переопределённым mro() теряет кеш атрибутов — и вместе с ним все наследники. functools.partial становится дескриптором метода (gh-121027): код, который на 3.13 работал с предупреждением, падает с TypeError, потому что в вызов теперь приезжает ещё и self. |
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.
Со временем:
Без времени — там сравниваются значения и последовательности вызовов:
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Точка — это не чтение поля. Это протокол, и порядок в нём задаёт одна строчка: дескриптор данных сильнее словаря экземпляра, словарь экземпляра сильнее дескриптора не-данных. Дескрипторами при этом оказываются все привычные члены класса — обычный метод,
property,staticmethod,classmethod, слот__slots__,cached_property. - Порядок обхода классов задаёт C3, и он не «сначала вся левая ветка». В ромбе
class D(L, R)общий предок уходит в хвост:['D', 'L', 'R', 'Base', 'object'], и метод берётся уR. - Платить за длину этой цепочки не приходится — её результат лежит в кеше версий типа. Замер: при живом кеше обращение стоит около 11 нс и не зависит от длины
__mro__вовсе (наклон +0,02 нс на уровень при длинах от 2 до 51). Сбейте кеш — и появляется линейный рост, +3,31 нс на уровень: 180,8 нс против 11,9 на цепочке из пятидесяти одного класса. - И главное: на 3.14 метакласс, переопределяющий
mro()— хотя бы и возвращающий ровноsuper().mro(), — выключает кеш для своего класса и для всех наследников. На цепочке из пятидесяти одного класса это 118,4 нс против 6,4 у обычного типа, и платит тот, кто написал обычныйclass Sub(Base)и ни про какой метакласс не знает. На 3.12 и 3.13 эффекта нет.
На самом деле
- Пока цел кеш версий типа — нет. Измерено при длинах
__mro__2, 6, 21 и 51: 10,8 / 10,9 / 11,0 / 11,9 нс, наклон +0,02 нс на уровень, то есть тренда нет вовсе. Линейный рост появляется только когда кеш сбивается на каждом обращении: тогда те же четыре длины дают 18,7 / 29,2 / 87,4 / 180,8 нс, +3,31 нс на уровень. Дорого не наследование, а правка классов во время работы. - Вызывается не поиском, а тем, кто поиск запустил. В
__getattribute__обращения к__getattr__нет вовсе — документация говорит это прямо, — а зовут его оператор «точка» иgetattr(), когда__getattribute__возбудилAttributeError. Отсюда часто делают следующий шаг, и он неверен: будто собственный__getattribute__, делегирующий поиск вsuper(), тем самым__getattr__отключает. Не отключает: запасной путь лежит выше самого__getattribute__, а делегирование исключение не гасит, а пропускает наружу — точка подхватывает его как обычно. Обходит__getattr__только ПРЯМОЙ вызовobj.__getattribute__(name), у которого никакой точки сверху нет; а отключает — override, который сам ловитAttributeErrorи отвечает за него. Одинаково на 3.12.3, 3.13.7 и 3.14.7. - Нет: общий предок уходит в хвост.
D.__mro__— это['D', 'L', 'R', 'Base', 'object'], иD().m()возвращает'R', а не'Base'. Правило C3 запрещает брать голову, которая встречается в хвосте другого списка:Baseесть в хвосте линеаризацииR, значит раньшеRего взять нельзя. Одинаково на 3.12.3, 3.13.7 и 3.14.7. - Это следствие приоритета, а не механизм.
cached_property— дескриптор НЕ данных: при первом обращении он кладёт значение в__dict__экземпляра, а словарь экземпляра сильнее дескрипторов не-данных, поэтому спрашивать его больше не будут. Отсюда же и то, что с__slots__он не работает: класть некуда. - Для метода экземпляра — наоборот. Измерено на 3.13.7:
obj.m()целиком 16,3 нс, одно толькоobj.m24,6 нс, вызов уже взятого 18,1. Взять и вызвать порознь дороже слитного на 26,4 нс, потому что при слитной записи объект связанного метода не создаётся вовсе — интерпретатор передаётselfотдельно. Привычка верна дляmodule.func, где выносится настоящий поиск. На 3.14.7 то же самое: 17,1 / 23,5 / 17,5, разложенная запись дороже на 23,9 нс. Дальше замера вывод не тянется: проверялся обычный Python-метод, и у C-функций, обёрток-заместителей и других реализаций Python соотношение может быть иным. - Не описано. На весь кеш есть одна официальная фраза, и та в описании функции C API
PyType_Modified: Invalidate the internal lookup cache for the type and all of its subtypes. Ниtp_version_tag, ни размер таблицы, ни условия отключения в публичной документации не упомянуты — всё это выводится из заголовков сборки и из замеров, и относиться к нему надо как к детали реализации, а не как к гарантии языка. - Не сам по себе. Метакласс, который ничего не переопределяет, даёт то же время, что обычный
type, на всех проверенных длинах цепочки и на всех трёх версиях: на 3.14.7 обе строки замера дают 6,4 нс, на 3.13.7 — 5,8–6,0, на 3.12.3 — 12,7–12,9. Замедляет конкретное действие — переопределённыйmro(), и только на 3.14: там класс теряет кеш атрибутов, и обращение начинает расти с длиной__mro__, до 118,4 нс против 6,4. Средняя строка «метакласс без mro()» стоит в замере именно для того, чтобы эффект нельзя было списать на метакласс вообще.
По версиям
- 3.12
- Поля
tp_versions_usedу типа ещё нет. Все конфигурации замера ровные: кеш работает одинаково и для обычного типа, и для класса с переопределённымmro().< - 3.13
- Поле
tp_versions_usedпоявляется вcpython/object.h— объявлено голым, без комментария, и на поведении это не сказывается: замер по-прежнему ровный во всех конфигурациях.functools.partialв теле класса ещё ведёт себя как обычный атрибут, но предупреждает: functools.partial will be a method descriptor in future Python versions; wrap it in staticmethod() if you want to preserve the old behavior.< - 3.14
- У того же поля появляется смысл: константа
_Py_ATTR_CACHE_UNUSEDи комментарий про выключенный кеш. Класс с переопределённымmro()теряет кеш атрибутов — и вместе с ним все наследники.functools.partialстановится дескриптором метода (gh-121027): код, который на 3.13 работал с предупреждением, падает сTypeError, потому что в вызов теперь приезжает ещё иself.<
Что разобрано
- Одна точка и четыре механизма
- Правило приоритета: два слова, которые всё решают
- Методы, property, слоты и cached_property — дескрипторы
- Где живёт порядок: MRO и C3
- Кеш версий типа
- Ловушка 3.14: свой `mro()` выключает кеш
- Связанный метод создаётся не всегда
- `super()`: цена уровня
- Что из этого следует для кода
- История версий
- Чем измерено
Частые заблуждения
Глубокое наследование замедляет обращение к атрибутам
Пока цел кеш версий типа — нет. Измерено при длинах __mro__ 2, 6, 21 и 51: 10,8 / 10,9 / 11,0 / 11,9 нс, наклон +0,02 нс на уровень, то есть тренда нет вовсе. Линейный рост появляется только когда кеш сбивается на каждом обращении: тогда те же четыре длины дают 18,7 / 29,2 / 87,4 / 180,8 нс, +3,31 нс на уровень. Дорого не наследование, а правка классов во время работы.
__getattr__ вызывается, когда атрибут не найден
Вызывается не поиском, а тем, кто поиск запустил. В __getattribute__ обращения к __getattr__ нет вовсе — документация говорит это прямо, — а зовут его оператор «точка» и getattr(), когда __getattribute__ возбудил AttributeError. Отсюда часто делают следующий шаг, и он неверен: будто собственный __getattribute__, делегирующий поиск в super(), тем самым __getattr__ отключает. Не отключает: запасной путь лежит выше самого __getattribute__, а делегирование исключение не гасит, а пропускает наружу — точка подхватывает его как обычно. Обходит __getattr__ только ПРЯМОЙ вызов obj.__getattribute__(name), у которого никакой точки сверху нет; а отключает — override, который сам ловит AttributeError и отвечает за него. Одинаково на 3.12.3, 3.13.7 и 3.14.7.
В ромбе class D(L, R) обход идёт сначала по всей ветке L
Нет: общий предок уходит в хвост. D.__mro__ — это ['D', 'L', 'R', 'Base', 'object'], и D().m() возвращает 'R', а не 'Base'. Правило C3 запрещает брать голову, которая встречается в хвосте другого списка: Base есть в хвосте линеаризации R, значит раньше R его взять нельзя. Одинаково на 3.12.3, 3.13.7 и 3.14.7.
cached_property — это отдельный механизм кеширования
Это следствие приоритета, а не механизм. cached_property — дескриптор НЕ данных: при первом обращении он кладёт значение в __dict__ экземпляра, а словарь экземпляра сильнее дескрипторов не-данных, поэтому спрашивать его больше не будут. Отсюда же и то, что с __slots__ он не работает: класть некуда.
Вынести obj.method в переменную перед циклом — оптимизация
Для метода экземпляра — наоборот. Измерено на 3.13.7: obj.m() целиком 16,3 нс, одно только obj.m 24,6 нс, вызов уже взятого 18,1. Взять и вызвать порознь дороже слитного на 26,4 нс, потому что при слитной записи объект связанного метода не создаётся вовсе — интерпретатор передаёт self отдельно. Привычка верна для module.func, где выносится настоящий поиск. На 3.14.7 то же самое: 17,1 / 23,5 / 17,5, разложенная запись дороже на 23,9 нс. Дальше замера вывод не тянется: проверялся обычный Python-метод, и у C-функций, обёрток-заместителей и других реализаций Python соотношение может быть иным.
Устройство кеша типа описано в документации
Не описано. На весь кеш есть одна официальная фраза, и та в описании функции C API PyType_Modified: Invalidate the internal lookup cache for the type and all of its subtypes
(сбросить внутренний кеш поиска для этого типа и всех его подтипов). Ни tp_version_tag, ни размер таблицы, ни условия отключения в публичной документации не упомянуты — всё это выводится из заголовков сборки и из замеров, и относиться к нему надо как к детали реализации, а не как к гарантии языка.
Метакласс сам по себе замедляет обращение к атрибутам
Не сам по себе. Метакласс, который ничего не переопределяет, даёт то же время, что обычный type, на всех проверенных длинах цепочки и на всех трёх версиях: на 3.14.7 обе строки замера дают 6,4 нс, на 3.13.7 — 5,8–6,0, на 3.12.3 — 12,7–12,9. Замедляет конкретное действие — переопределённый mro(), и только на 3.14: там класс теряет кеш атрибутов, и обращение начинает расти с длиной __mro__, до 118,4 нс против 6,4. Средняя строка «метакласс без mro()» стоит в замере именно для того, чтобы эффект нельзя было списать на метакласс вообще.
Проверка знаний
У класса есть property с именем x. В обход присваивания пишут прямо в словарь экземпляра: obj.__dict__['x'] = 1. Что вернёт obj.x?
Источники и что читать дальше
7 ИСТОЧНИКОВ
- Descriptor Guide — Invocation from an instanceОфициальная документация. Правило приоритета одним предложением: «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__()`, если он определён). И отдельно, почему `__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» (Заметьте, в коде `__getattribute__()` нет обращения к `__getattr__()`. Именно поэтому прямой вызов `__getattribute__()` или вызов через `super().__getattribute__` обходит `__getattr__()` полностью).https://docs.python.org/3/howto/descriptor.html
- The Python 2.3 Method Resolution OrderОфициальная документация. Формула C3 в том виде, в каком её приводит документ: `L[C(B1 ... BN)] = C + merge(L[B1] ... L[BN], B1 ... BN)`. Правило выбора головы: «take the head of the first list, i.e L[B1][0]; if this head is not in the tail of any of the other lists, then add it to the linearization of C and remove it from the lists in the merge, otherwise look at the head of the next list and take it, if it is a good head» (взять голову первого списка; если этой головы нет в хвосте ни одного из остальных списков, добавить её к линеаризации C и убрать из списков слияния, иначе посмотреть на голову следующего списка и взять её, если она годная). И прямое утверждение о том, что порядок существует не всегда: «Not all classes admit a linearization» (Не всякий класс допускает линеаризацию).https://docs.python.org/3/howto/mro.html
- Type Objects — PyType_ModifiedОфициальная документация. Единственная официальная фраза про сброс кеша: «Invalidate the internal lookup cache for the type and all of its subtypes. This function must be called after any manual modification of the attributes or base classes of the type» (Сбросить внутренний кеш поиска для типа и всех его подтипов. Эту функцию необходимо вызывать после любого ручного изменения атрибутов или базовых классов типа). Ни устройство кеша, ни поле `tp_version_tag` в публичной документации не описаны — это оговорено в тексте статьи прямо.https://docs.python.org/3/c-api/type.html
- PEP 252 — Making Types Look More Like ClassesPEP. Гвидо ван Россум, Final, Python 2.2. Документ, которым протокол дескрипторов вошёл в язык: там же введено разделение на дескрипторы, умеющие только чтение, и умеющие запись.https://peps.python.org/pep-0252/
- PEP 253 — Subtyping Built-in TypesPEP. Гвидо ван Россум, Final, Python 2.2. Вторая половина той же реформы: метатипы, наследование от встроенных типов и то, откуда у типа берётся его собственный поиск атрибутов, отличный от поиска у экземпляра.https://peps.python.org/pep-0253/
- cpython/object.h — tp_version_tag и tp_versions_usedИсходный код CPython. Объявления сверены не по репозиторию, а по заголовкам ТЕХ сборок, на которых сняты числа. В 3.13.7 (`cpython/object.h:232`) поле `tp_versions_used` объявлено голым, без комментария. В 3.14.7 (`cpython/object.h:241`) рядом с ним стоит объяснение: значение `_Py_ATTR_CACHE_UNUSED`, «if the attribute cache is disabled for this type (e.g. due to custom MRO entries)» (если кеш атрибутов для этого типа выключен — например, из-за нестандартных элементов MRO у этого типа), и сама константа `#define _Py_ATTR_CACHE_UNUSED (30000)`. Скрипт `
bench/attr-lookup/mro_override.py` печатает эти строки из заголовков того интерпретатора, на котором запущен. Тег CPython 3.14.0.https://github.com/python/cpython/blob/v3.14.0/Include/cpython/object.h - internal/pycore_typeobject.h — сам кешИсходный код CPython. Структура кеша в 3.13.7: `struct type_cache_entry` из трёх полей — `version`, `name`, `value`, — и размер таблицы `#define MCACHE_SIZE_EXP 12`, то есть 4096 ячеек на интерпретатор. Комментарий над структурой: «Type attribute lookup cache: speed up attribute and method lookups, see _PyType_Lookup()» (Кеш поиска атрибутов типа: ускоряет поиск атрибутов и методов, см. _PyType_Lookup()). В 3.14.7 та же структура и тот же размер, но объявление переехало в `internal/pycore_interp_structs.h`. Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Include/internal/pycore_typeobject.h