Deep Engineering
Экспертный·Опубликовано·3.11 · 3.12 · 3.13 · 3.14·35 МИН

Что происходит на o.x: дескрипторы, MRO и кеш типа

Точка — не чтение поля. Это протокол из четырёх механизмов, и порядок между ними задаётся одной строчкой приоритета. Из неё следует и то, почему присваивание в property без сеттера даёт AttributeError, и почему __getattr__ иногда не вызывается, и почему обращение к унаследованному атрибуту не зависит от глубины наследования — пока цел кеш версий типа. А на 3.14 нашёлся способ выключить этот кеш, ничего про него не зная.

Полное техническое изложение

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__() if it is provided (Поиск у экземпляра идёт по цепочке пространств имён, отдавая высший приоритет дескрипторам данных, затем переменным экземпляра, затем дескрипторам не-данных, затем переменным класса и в последнюю очередь __getattr__(), если он определён).

Дальше вся статья — про то, что стоит за каждым словом этой фразы и во что обходится.

Правило приоритета: два слова, которые всё решают

Дескриптор — объект, который лежит в классе и у типа которого есть __get__, __set__ или __delete__. Признак единственный, отдельного «механизма дескрипторов» больше нет.

Разделение на два вида проходит по одному признаку: есть ли __set__ (или __delete__). Есть — дескриптор данных, нет — дескриптор не-данных. Документация говорит о последствиях прямо: Data descriptors always override instance dictionaries. Non-data descriptors may be overridden by instance dictionaries (Дескрипторы данных всегда перекрывают словари экземпляров. Дескрипторы не-данных могут быть перекрыты словарями экземпляров).

Проверяется это одним опытом, в котором оба имени записаны напрямую в __dict__ экземпляра — то есть присваивание не выполнялось и перехватить его было некому:

PYTHON
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.xobj.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 __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__() полностью).

Зовёт его не поиск, а тот, кто поиск запустил: Instead, it is the dot operator and the getattr() function that are responsible for invoking __getattr__() whenever __getattribute__() raises an AttributeError (Вместо этого за вызов __getattr__() отвечают оператор «точка» и функция getattr(), всякий раз когда __getattribute__() возбуждает AttributeError).

Здесь легко сделать вывод, который кажется прямым следствием и неверен: будто собственный __getattribute__, делегирующий поиск в super(), тем самым отключает __getattr__. Не отключает — и это стоит проверить, а не принять на слово:

PYTHON
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естьне-данных
@propertypropertyестьданных
@staticmethodstaticmethodестьне-данных
@classmethodclassmethodестьне-данных
__slots__member_descriptorестьданных
@functools.cached_propertycached_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 и убрать из списков слияния, иначе посмотреть на голову следующего списка и взять её, если она годная).

На ромбе видно, чем это отличается от интуиции:

PYTHON
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 (Не всякий класс допускает линеаризацию) — и тогда класс просто не создаётся:

PYTHON
class X: pass
class Y: pass
class A(X, Y): pass
class B(Y, X): pass
class Impossible(A, B): pass
TypeError: Cannot create a consistent method resolution order (MRO) for bases X, Y

A требует X раньше Y, B — наоборот. Это не ограничение реализации, а отсутствие решения: порядка, который уважал бы оба требования, нет.

Насколько эти цепочки длинные в жизни

Короткие. У типов, с которыми имеешь дело каждый день, __mro__ — два-четыре элемента:

типдлина__mro__
int, str, list, dict2сам тип и object
bool3bool, int, object
Exception3Exception, BaseException, object
ValueError4плюс Exception и BaseException
io.StringIO4StringIO, _TextIOBase, _IOBase, object

Длины 21 и 51 в замерах ниже — модель, а не типичный код. Они нужны, чтобы увидеть наклон: на цепочке из трёх классов разницу между «кеш есть» и «кеша нет» не различить.

Кеш версий типа

Поиск по __mro__ выполняется не на каждое обращение. У типа есть поле tp_version_tag, и результат поиска пары «тип, имя» ложится в общую на весь интерпретатор таблицу. В заголовках 3.13.7 она объявлена так:

C
// 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
210,8 нс18,7 нс
610,9 нс29,2 нс
2111,0 нс87,4 нс
5111,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 рядом с ним встало объяснение и константа:

C
    /* 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(), лишает свой класс кеша. Причём переопределять содержательно не требуется:

PYTHON
class OwnMRO(type):
    def mro(cls):
        return super().mro()   # порядок НЕ меняется

Замер на 3.14.7, три конфигурации при трёх длинах цепочки:

конфигурациядлина 2длина 21длина 51
обычный type6,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() переопределён только у корня, все наследники объявлены обычным type118,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, убедитесь, что это не в основании иерархии, которой пользуются другие. А если наследуетесь от чужого класса и обращения к атрибутам вдруг подорожали на порядок — вопрос решается одной строкой:

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

Чем измерено

Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.

Со временем:

Без времени — там сравниваются значения и последовательности вызовов:

Частые заблуждения

Утверждение

Глубокое наследование замедляет обращение к атрибутам

На самом деле

Пока цел кеш версий типа — нет. Измерено при длинах __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()» стоит в замере именно для того, чтобы эффект нельзя было списать на метакласс вообще.

Проверка знаний

Вопрос 1 из 5

У класса есть property с именем x. В обход присваивания пишут прямо в словарь экземпляра: obj.__dict__['x'] = 1. Что вернёт obj.x?

Источники и что читать дальше

7 ИСТОЧНИКОВ

  1. 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
  2. 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
  3. 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
  4. PEP 252 — Making Types Look More Like ClassesPEP. Гвидо ван Россум, Final, Python 2.2. Документ, которым протокол дескрипторов вошёл в язык: там же введено разделение на дескрипторы, умеющие только чтение, и умеющие запись.https://peps.python.org/pep-0252/
  5. PEP 253 — Subtyping Built-in TypesPEP. Гвидо ван Россум, Final, Python 2.2. Вторая половина той же реформы: метатипы, наследование от встроенных типов и то, откуда у типа берётся его собственный поиск атрибутов, отличный от поиска у экземпляра.https://peps.python.org/pep-0253/
  6. 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
  7. 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