ЗАМЕР
bench/attr-lookup/getattr_hook.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/python/runtime/attribute-lookup
- Как запустить
У первых двух скриптов времени нет вовсе: сравниваются значения и последовательности вызовов, а адреса объектов заменены на `0x...` ради воспроизводимости прогона. Время появилось позже, вместе с двумя новыми скриптами, — и про него ниже отдельный раздел. ## Время: что меряется и чему верить Записи прогонов лежат в `runs/`, по файлу на скрипт и версию. **Чередование обязательно.** Формы меряются не подряд, а кругами: в каждом круге проходят все конфигурации, минимум для каждой берётся по кругам. Причина не теоретическая. Пока замеры шли подряд, отношение между формами гуляло в полтора раза от запуска к запуску — просадка машины в окне одной формы целиком доставалась ей. Измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2 на четырёх запусках одного и того же замера. **Кратности от малых величин не печатаются.** Частное, у которого в знаменателе стоит величина порядка пятнадцати наносекунд, шумит вдвое и не измеряет ничего: на двух подряд запусках `mro_cache.py` оно дало 7,3 и 10,7 при одинаковом наклоне. Поэтому оба скрипта печатают времена и разности, а утверждения статьи строятся на наклоне, который устойчив. **Что показал `mro_cache.py`.** При живом кеше время обращения не зависит от длины `__mro__` — наклон по крайним точкам укладывается в сотые доли наносекунды на уровень при разбросе самих значений в пару наносекунд. Стоит сбивать кеш присваиванием в класс на каждой итерации, и появляется линейный рост: 3,31 нс на уровень на 3.13.7 и 2,14 на 3.14.7 (наклон воспроизводится между запусками, сами времена — только на нижней границе). Цена самого присваивания измеряется отдельной строкой и вычитается — иначе замер показывал бы стоимость `setattr`, а не промаха кеша. **Что показал `mro_override.py`, и это главное здесь.** Метакласс, который переопределяет `mro()` — даже возвращая ровно `super().mro()`, то есть не меняя в порядке ничего, — на 3.14.7 лишает свой класс кеша атрибутов типа. Обращение к унаследованному атрибуту начинает расти вместе с длиной `__mro__`: 15,3 / 63,8 / 118,4 нс при длинах 2 / 21 / 51 против ровных 6,4 нс у обычного типа. На длине 51 это в восемнадцать раз дороже. На 3.12.3 и 3.13.7 эффекта нет: там все три конфигурации ровные. Средняя строка таблицы — метакласс БЕЗ `mro()` — стоит в замере именно для того, чтобы эффект нельзя было списать на метакласс вообще: она ровная на всех трёх версиях. **Эффект наследуется, и это его практическая цена.** Достаточно одного класса с переопределённым `mro()` где-нибудь в основании иерархии: наследник объявляется обычным `class Sub(Base)`, метакласс Python выводит из баз сам, и кеша лишается тот, кто о метаклассе даже не знает. Замер: 118,7 нс при длине 51, то есть ровно столько же, сколько у самого корня. Со стороны исходников это видно так. Поле `tp_versions_used` появилось у типа в 3.13 — голым, без комментария. В 3.14 рядом с ним встала константа `_Py_ATTR_CACHE_UNUSED (30000)` и объяснение: это значение ставится, «if the attribute cache is disabled for this type (e.g. due to custom MRO entries)». Скрипт печатает оба объявления из заголовков того интерпретатора, на котором запущен, поэтому проверить утверждение о C-уровне можно, не выходя из своей системы. В стандартной библиотеке `mro()` не переопределён нигде: `grep "def mro("` по `Lib/` даёт ноль совпадений. Это приём стороннего кода. **Что показал `bound_method.py`.** `obj.m` — взять атрибут и не вызывать — стоит ДОРОЖЕ, чем `obj.m()` целиком: 24,6 против 16,3 нс на 3.13.7. Объект связанного метода при слитной записи не создаётся вовсе, а при раздельной обязан быть создан, потому что его кладут в переменную. **Что показал `mro_shape.py`.** Времени там нет, только поведение, и оно одинаково на 3.12.3, 3.13.7 и 3.14.7, кроме последнего пункта. Ромб `class D(L, R)` даёт `['D', 'L', 'R', 'Base', 'object']`, и `D().m()` возвращает `'R'`: общий предок уходит в хвост, а не «сначала вся ветка L». Отказ C3 звучит дословно так: `Cannot create a consistent method resolution order (MRO) for bases X, Y`. У встроенных типов `__mro__` короткий — два-четыре элемента, — поэтому длины 21 и 51 в замерах кеша это модель, а не типичный код. Все шесть видов членов класса оказываются дескрипторами: у типа каждого есть `__get__`, и разделение на данных и не данных проходит по `__set__`. Единственное расхождение по версиям — `functools.partial` в теле класса. На 3.13.7 он ещё обычный атрибут и предупреждает: «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.7 он уже метод-дескриптор, и тот же код падает с `TypeError: two() takes 2 positional arguments but 3 were given`. Объект связанного метода при слитной записи не создаётся вовсе, а при раздельной обязан быть создан, потому что его кладут в переменную. ## Версии **Вывод обоих скриптов одинаков на 3.11.15, 3.12.3, 3.13.7 и 3.14.7** — различается только строка `PY`. Различий по версиям нет ни одного, и это самостоятельный результат: правило приоритета не менялось, несмотря на то что `_PyObject_GenericGetAttrWithDict` в C между 3.13 и 3.14 переписывался (см. ниже). ## Псевдокод и настоящий поиск
Запись прогона
Замеры: где в поиске атрибута сидит протокол дескрипторов
Два сюжета, которых нет в bench/descriptors/: питоновский эквивалент
object.__getattribute__, запущенный рядом с настоящим, и разница между
поиском у экземпляра и поиском у класса — то есть дескриптор на метаклассе.
| скрипт | что показывает |
|---|---|
getattribute_equivalent.py |
псевдокод из Doc/howto/descriptor.rst даёт те же ответы, что точка; три ветки приоритета; в псевдокоде нет __getattr__ |
metaclass_descriptor.py |
дескриптор метакласса виден у класса и не существует у экземпляра; дескриптор данных метакласса перекрывает __dict__ класса |
mro_cache.py |
цена o.m при четырёх длинах __mro__ в двух режимах: кеш версий типа цел и сбивается на каждой итерации |
bound_method.py |
во что обходится объект связанного метода и во что — super() |
mro_override.py |
переопределённый mro() и кеш атрибутов типа: на 3.14 он выключается, на 3.12 и 3.13 нет |
mro_shape.py |
форма MRO: ромб, отказ C3 дословным текстом, длины у настоящих типов, кто из членов класса дескриптор, functools.partial в теле класса |
getattr_hook.py |
кто на самом деле зовёт __getattr__: делегирование в super() его НЕ отключает, прямой вызов обходит, поглощение исключения отключает |
descriptor_kinds.py |
классификация дескриптора данных (__set__ или __delete__) против приоритета при чтении (нужен ещё __get__) — и строка, где они расходятся |
mro_stdlib_scan.py |
разбором AST: сколько модулей стандартной библиотеки этой сборки переопределяют mro() |
Запуск:
for v in 3.11 3.12 3.13 3.14; do
echo "== $v"; python$v bench/attr-lookup/getattribute_equivalent.py
done
У первых двух скриптов времени нет вовсе: сравниваются значения и
последовательности вызовов, а адреса объектов заменены на 0x... ради
воспроизводимости прогона. Время появилось позже, вместе с двумя новыми
скриптами, — и про него ниже отдельный раздел.
Время: что меряется и чему верить
Записи прогонов лежат в runs/, по файлу на скрипт и версию.
Чередование обязательно. Формы меряются не подряд, а кругами: в каждом круге проходят все конфигурации, минимум для каждой берётся по кругам. Причина не теоретическая. Пока замеры шли подряд, отношение между формами гуляло в полтора раза от запуска к запуску — просадка машины в окне одной формы целиком доставалась ей. Измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2 на четырёх запусках одного и того же замера.
Кратности от малых величин не печатаются. Частное, у которого в
знаменателе стоит величина порядка пятнадцати наносекунд, шумит вдвое и не
измеряет ничего: на двух подряд запусках mro_cache.py оно дало 7,3 и 10,7
при одинаковом наклоне. Поэтому оба скрипта печатают времена и разности, а
утверждения статьи строятся на наклоне, который устойчив.
Что показал mro_cache.py. При живом кеше время обращения не зависит от
длины __mro__ — наклон по крайним точкам укладывается в сотые доли
наносекунды на уровень при разбросе самих значений в пару наносекунд. Стоит
сбивать кеш присваиванием в класс на каждой итерации, и появляется линейный
рост: 3,31 нс на уровень на 3.13.7 и 2,14 на 3.14.7 (наклон воспроизводится
между запусками, сами времена — только на нижней границе). Цена самого присваивания измеряется отдельной строкой и вычитается —
иначе замер показывал бы стоимость setattr, а не промаха кеша.
Что показал mro_override.py, и это главное здесь. Метакласс, который
переопределяет mro() — даже возвращая ровно super().mro(), то есть не меняя
в порядке ничего, — на 3.14.7 лишает свой класс кеша атрибутов типа. Обращение
к унаследованному атрибуту начинает расти вместе с длиной __mro__:
15,3 / 63,8 / 118,4 нс при длинах 2 / 21 / 51 против ровных 6,4 нс у обычного
типа. На длине 51 это в восемнадцать раз дороже. На 3.12.3 и 3.13.7 эффекта
нет: там все три конфигурации ровные.
Средняя строка таблицы — метакласс БЕЗ mro() — стоит в замере именно для
того, чтобы эффект нельзя было списать на метакласс вообще: она ровная на всех
трёх версиях.
Эффект наследуется, и это его практическая цена. Достаточно одного класса
с переопределённым mro() где-нибудь в основании иерархии: наследник
объявляется обычным class Sub(Base), метакласс Python выводит из баз сам, и
кеша лишается тот, кто о метаклассе даже не знает. Замер: 118,7 нс при длине 51,
то есть ровно столько же, сколько у самого корня.
Со стороны исходников это видно так. Поле tp_versions_used появилось у типа
в 3.13 — голым, без комментария. В 3.14 рядом с ним встала константа
_Py_ATTR_CACHE_UNUSED (30000) и объяснение: это значение ставится, «if the
attribute cache is disabled for this type (e.g. due to custom MRO entries)».
Скрипт печатает оба объявления из заголовков того интерпретатора, на котором
запущен, поэтому проверить утверждение о C-уровне можно, не выходя из своей
системы.
В стандартной библиотеке mro() не переопределён нигде: grep "def mro(" по
Lib/ даёт ноль совпадений. Это приём стороннего кода.
Что показал bound_method.py. obj.m — взять атрибут и не вызывать —
стоит ДОРОЖЕ, чем obj.m() целиком: 24,6 против 16,3 нс на 3.13.7. Объект
связанного метода при слитной записи не создаётся вовсе, а при раздельной
обязан быть создан, потому что его кладут в переменную.
Что показал mro_shape.py. Времени там нет, только поведение, и оно
одинаково на 3.12.3, 3.13.7 и 3.14.7, кроме последнего пункта. Ромб
class D(L, R) даёт ['D', 'L', 'R', 'Base', 'object'], и D().m()
возвращает 'R': общий предок уходит в хвост, а не «сначала вся ветка L».
Отказ C3 звучит дословно так: Cannot create a consistent method resolution order (MRO) for bases X, Y. У встроенных типов __mro__ короткий — два-четыре
элемента, — поэтому длины 21 и 51 в замерах кеша это модель, а не типичный код.
Все шесть видов членов класса оказываются дескрипторами: у типа каждого есть
__get__, и разделение на данных и не данных проходит по __set__.
Единственное расхождение по версиям — functools.partial в теле класса. На
3.13.7 он ещё обычный атрибут и предупреждает: «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.7 он уже метод-дескриптор, и тот же
код падает с TypeError: two() takes 2 positional arguments but 3 were given. Объект связанного метода при слитной
записи не создаётся вовсе, а при раздельной обязан быть создан, потому что его
кладут в переменную.
Версии
Вывод обоих скриптов одинаков на 3.11.15, 3.12.3, 3.13.7 и 3.14.7 —
различается только строка PY. Различий по версиям нет ни одного, и это
самостоятельный результат: правило приоритета не менялось, несмотря на то что
_PyObject_GenericGetAttrWithDict в C между 3.13 и 3.14 переписывался (см. ниже).
Псевдокод и настоящий поиск
$ python3.13 getattribute_equivalent.py
PY 3.13.7
1) в __dict__ экземпляра лежат все четыре имени: ['data', 'nondata', 'own', 'plain']
2) точка против псевдокода — одно и то же значение:
c.data = значение дескриптора данных
псевдокод = значение дескриптора данных совпало: True
c.nondata = ЭКЗЕМПЛЯР nondata
псевдокод = ЭКЗЕМПЛЯР nondata совпало: True
c.plain = ЭКЗЕМПЛЯР plain
псевдокод = ЭКЗЕМПЛЯР plain совпало: True
c.own = только у экземпляра
псевдокод = только у экземпляра совпало: True
c.method = <bound method C.method of <__main__.C object at 0x...>>
псевдокод = <bound method C.method of <__main__.C object at 0x...>> совпало: True
3) какая ветка псевдокода сработала:
data -> ветка 1 (data descriptor): __dict__ экземпляра ПРОИГНОРИРОВАН
nondata -> ветка 2 (instance variable): __dict__ экземпляра ПОБЕДИЛ
plain -> ветка 2 (instance variable)
own -> ветка 2 (instance variable)
method -> ветка 3 (non-data descriptor), функция сама дескриптор: True | __set__: False
4) вызовы __get__ НАШИХ дескрипторов (Data/NonData) — точка и псевдокод:
data точка=['Data.__get__'] псевдокод=['Data.__get__']
nondata точка=— псевдокод=—
plain точка=— псевдокод=—
own точка=— псевдокод=—
method точка=— псевдокод=—
5) точка зовёт __getattr__ после AttributeError: __getattr__(missing)
псевдокод (и object.__getattribute__ напрямую) -> AttributeError: missing
object.__getattribute__(w, 'missing') -> AttributeError: 'WithFallback' object has no attribute 'missing'
6) instance lookup против class lookup у одного и того же дескриптора:
D().x -> obj=<__main__.D object at 0x...>, objtype=D
D.x -> obj=None, objtype=D
вызовы (obj is None, objtype): [(False, 'D'), (True, 'D')]
документация: object.__getattribute__ передаёт экземпляр,
type.__getattribute__ подставляет None вместо экземпляра
Пункт 4 читается вместе с пунктом 3: __get__ дескриптора данных вызывается
до того, как поиск вообще посмотрит в __dict__ экземпляра, и это видно по
единственному вызову в строке data. У nondata вызова нет вовсе — до третьей
ветки дело не дошло, потому что вторая уже вернула значение.
Пункт 5 — про то, чего в псевдокоде нет: ветки __getattr__. Она живёт этажом
выше, в операторе точки; сам object.__getattribute__ её не знает.
Первоисточник
Doc/howto/descriptor.rst, строки 583–611 (тег v3.14.5) — код в
getattribute_equivalent.py скопирован дословно, включая комментарии:
The logic for a dotted lookup is in
object.__getattribute__. Here is a pure Python equivalent:
Перевод: «Логика поиска через точку находится в object.__getattribute__. Вот
её эквивалент на чистом Python:»
def find_name_in_mro(cls, name, default):
"Emulate _PyType_Lookup() in Objects/typeobject.c"
for base in cls.__mro__:
if name in vars(base):
return vars(base)[name]
return default
def object_getattribute(obj, name):
"Emulate PyObject_GenericGetAttr() in Objects/object.c"
null = object()
objtype = type(obj)
cls_var = find_name_in_mro(objtype, name, null)
descr_get = getattr(type(cls_var), '__get__', null)
if descr_get is not null:
if (hasattr(type(cls_var), '__set__')
or hasattr(type(cls_var), '__delete__')):
return descr_get(cls_var, obj, objtype) # data descriptor
if hasattr(obj, '__dict__') and name in vars(obj):
return vars(obj)[name] # instance variable
if descr_get is not null:
return descr_get(cls_var, obj, objtype) # non-data descriptor
if cls_var is not null:
return cls_var # class variable
raise AttributeError(name)
Там же, строки 722–727, про отсутствующую ветку:
Note, there is no
__getattr__hook in the__getattribute__code. That is why calling__getattribute__directly or withsuper().__getattribute__will bypass__getattr__entirely. Instead, it is the dot operator and thegetattr()function that are responsible for invoking__getattr__whenever__getattribute__raises anAttributeError.
Перевод: «Заметьте, в коде __getattribute__ нет крючка __getattr__. Именно
поэтому прямой вызов __getattribute__ или вызов через
super().__getattribute__ полностью обходит __getattr__. Вместо этого за
вызов __getattr__ отвечают оператор точки и функция getattr() — всякий раз,
когда __getattribute__ возбуждает AttributeError.»
И строки 806–829, итоговая формулировка:
The mechanism for descriptors is embedded in the
__getattribute__methods forobject,type, andsuper. […]object.__getattribute__andtype.__getattribute__make different calls to__get__. The first includes the instance and may include the class. The second puts inNonefor the instance and always includes the class.
Перевод: «Механизм дескрипторов встроен в методы __getattribute__ у object,
type и super. […] object.__getattribute__ и type.__getattribute__
делают разные вызовы __get__. Первый передаёт экземпляр и может передать
класс. Второй подставляет None вместо экземпляра и всегда передаёт класс.»
Пункт 6 замера — прямая проверка этого абзаца.
C-исходник
Objects/object.c, функция _PyObject_GenericGetAttrWithDict. Порядок ветвей
там тот же, но номера строк по версиям разные, и сам код между 3.13 и 3.14
переписан:
тег v3.13.7 |
тег v3.14.5 |
|
|---|---|---|
| начало функции | строка 1632 | строка 1809 |
| поиск по типу | descr = _PyType_LookupRef(tp, name); (1659) |
_PyType_LookupStackRefAndVersion(tp, name, &cref.ref); (1841) |
| проверка на дескриптор данных | if (f != NULL && PyDescr_IsData(descr)) (1664) |
то же (1847) |
| словарь экземпляра | PyDict_GetItemRef(dict, name, &res) (1705) |
то же (1888) |
| дескриптор не-данных | if (f != NULL) { res = f(descr, obj, ...) } (1720) |
то же (1903) |
| переменная класса | if (descr != NULL) { res = descr; } (1729–1730) |
if (descr != NULL) { res = PyStackRef_AsPyObjectSteal(cref.ref); } (1912–1913) |
AttributeError |
1735–1741 | 1918–1924 |
Изменились работа со ссылками (PyStackRef) и способ обращения к кешу типов —
порядок четырёх ветвей не изменился, и вывод обоих скриптов на 3.13 и 3.14
совпадает построчно.
Дескриптор на метаклассе
$ python3.13 metaclass_descriptor.py
PY 3.13.7
1) __set_name__ вызывается только у дескрипторов в теле СВОЕГО класса:
__set_name__(на-метаклассе, owner=Meta, name=on_meta)
__set_name__(на-классе, owner=C, name=on_class)
для Loud на метаклассе owner=Meta, для Loud на классе owner=C
2) C.on_meta -> <на-метаклассе>
на-метаклассе.__get__(obj=класс C, objtype=Meta)
objtype=Meta: поиск шёл по type(C).__mro__, то есть по MRO метакласса
3) C().on_meta -> AttributeError: 'C' object has no attribute 'on_meta'
вызовов __get__: нет
поиск шёл по type(c).__mro__ = C.__mro__, а метакласса там нет:
C.__mro__ : ['C', 'object']
type(C).__mro__: ['Meta', 'type', 'object']
4) C().on_class -> <на-классе>
на-классе.__get__(obj=экземпляр C, objtype=C)
C.on_class -> <на-классе>
на-классе.__get__(obj=None, objtype=C)
у экземпляра obj — сам экземпляр, у класса obj=None: это и есть разница вызовов
5) C.on_meta = 'новое' -> ["на-метаклассе.__set__('новое')"]
значение НЕ попало в C.__dict__: False
c.on_class = 'новое' -> ["на-классе.__set__('новое')"]
значение НЕ попало в c.__dict__: False
6) дескриптор не-данных на метаклассе:
E.nd -> <не-данных на метаклассе>
после E.nd = 'перекрыто' -> перекрыто | в E.__dict__: True
__dict__ КЛАССА перекрывает дескриптор не-данных метакласса,
ровно как __dict__ экземпляра перекрывает дескриптор не-данных класса
7) имя `both` объявлено и на метаклассе, и на классе:
F.both -> <метакласс>
F().both -> <класс>
метакласс.__get__(obj=класс F, objtype=Meta2)
класс.__get__(obj=экземпляр F, objtype=F)
F.both ВЫИГРАЛ у F.__dict__['both']: Loud — дескриптор ДАННЫХ,
а дескриптор данных на type(F) перекрывает словарь самого F —
тем же правилом, каким дескриптор данных класса перекрывает vars(экземпляра).
F.__dict__['both'] при этом никуда не делся: Loud
8) итог поиска:
x.attr -> ищется в type(x).__mro__ и в vars(x)
C.attr -> ищется в type(C).__mro__ (метаклассы) и в C.__mro__
type(c) = C | type(C) = Meta | type(Meta) = type
Три вещи, ради которых написан файл.
Пункт 3 — самая частая ошибка ожидания: дескриптор на метаклассе у экземпляра
не просто не срабатывает, его там нет. AttributeError, ноль вызовов
__get__. Причина в одной строке псевдокода: objtype = type(obj). Для c
это C, и в C.__mro__ метакласса нет.
Пункт 7 — то же правило приоритета, поднятое на этаж выше. F.both — это
поиск атрибута у объекта F, тип которого Meta2; Loud на Meta2 —
дескриптор данных, поэтому он перекрывает F.__dict__['both'] точно так же,
как дескриптор данных класса перекрывает vars(экземпляра).
Пункт 6 — зеркальный случай: дескриптор не-данных на метаклассе перекрывается словарём класса. Пара 6/7 показывает, что правило приоритета одно, а «уровней» два, и оно применяется на обоих.
Первоисточник class lookup — Doc/howto/descriptor.rst, строки 776–786
(тег v3.14.5):
The logic for a dotted lookup such as
A.xis intype.__getattribute__. The steps are similar to those forobject.__getattribute__but the instance dictionary lookup is replaced by a search through the class's method resolution order. If a descriptor is found, it is invoked withdesc.__get__(None, A). The full C implementation can be found intype_getattro()and_PyType_Lookup()inObjects/typeobject.c.
Перевод: «Логика поиска через точку вида A.x находится в
type.__getattribute__. Шаги похожи на шаги object.__getattribute__, но
поиск в словаре экземпляра заменён поиском по порядку разрешения методов
класса. Если дескриптор найден, он вызывается как desc.__get__(None, A).
Полную реализацию на C можно найти в type_getattro() и _PyType_Lookup() в
Objects/typeobject.c.»
Оговорка про версию
3.14 здесь — это 3.14.7. Ни одна строка вывода на 3.14 не отличается
от 3.13.7, поэтому «поведение не изменилось» — это наблюдение с rc, а не с
финального релиза. Номера строк в C-исходнике взяты с тегов v3.13.7 и
v3.14.5, цитаты из Doc/ — с тега v3.14.5.
Скрипт
114 строк"""Кто на самом деле зовёт `__getattr__` — и что его действительно отключает.
ЗАЧЕМ ЭТОТ СКРИПТ. Документация говорит, что в `__getattribute__` обращения к
`__getattr__` нет и что прямой вызов `__getattribute__()` или вызов через
`super().__getattribute__` «обходит `__getattr__` полностью». Из этого легко
сделать вывод, который кажется очевидным и неверен: будто собственный
`__getattribute__`, делегирующий поиск в `super()`, тем самым отключает
`__getattr__` для обычного `obj.x`.
Не отключает. Запасной путь лежит УРОВНЕМ ВЫШЕ самого `__getattribute__`: его
включает механизм обычной точки, когда `__getattribute__` — чей угодно, свой
или унаследованный, — завершился `AttributeError`. Делегирование в `super()`
это исключение не гасит, а пропускает наружу, и точка ловит его как обычно.
Обходится `__getattr__` только там, где над `__getattribute__` нет этого
механизма: при ПРЯМОМ вызове `obj.__getattribute__(name)`. Ровно об этом
случае документация и говорит; фраза про `super().__getattribute__` в ней
относится к тому же прямому вызову, а не к делегированию внутри override.
А по-настоящему отключает `__getattr__` другое: override, который САМ ловит
`AttributeError` и возвращает или возбуждает что-то своё. Тогда наружу
исключения нет — и звать запасной путь точке не по чему.
Скрипт печатает все четыре случая на текущем интерпретаторе. Ничего не
измеряется по времени: здесь проверяется поведение, а не цена.
"""
import sys
def case(title, fn):
"""Что случилось: значение или исключение, и кто по дороге сработал."""
log.clear()
try:
value = fn()
outcome = f"вернулось {value!r}"
except AttributeError as exc:
outcome = f"AttributeError({exc})"
print(f" {title}")
print(f" сработали: {' → '.join(log) or '(никто)'}")
print(f" итог: {outcome}")
print()
log: list[str] = []
class Delegating:
"""Override, который делегирует поиск и НЕ трогает исключение."""
def __getattribute__(self, name):
log.append("__getattribute__")
return super().__getattribute__(name)
def __getattr__(self, name):
log.append("__getattr__")
return "из __getattr__"
class Swallowing:
"""Override, который сам ловит AttributeError и отвечает за него."""
def __getattribute__(self, name):
log.append("__getattribute__")
try:
return super().__getattribute__(name)
except AttributeError:
log.append("(исключение погашено внутри override)")
return "из __getattribute__"
def __getattr__(self, name):
log.append("__getattr__")
return "из __getattr__"
class Plain:
"""Без override — точка отсчёта."""
def __getattr__(self, name):
log.append("__getattr__")
return "из __getattr__"
print(f"PY {sys.version.split()[0]} | кто зовёт __getattr__")
print()
plain = Plain()
delegating = Delegating()
swallowing = Swallowing()
print("БЕЗ OVERRIDE — как выглядит норма")
case("plain.missing", lambda: plain.missing)
print("OVERRIDE ДЕЛЕГИРУЕТ В super() И НЕ ГАСИТ ИСКЛЮЧЕНИЕ")
case("delegating.missing (обычная точка)", lambda: delegating.missing)
case("getattr(delegating, 'missing')", lambda: getattr(delegating, "missing"))
case(
"delegating.__getattribute__('missing') (прямой вызов)",
lambda: delegating.__getattribute__("missing"),
)
case(
"object.__getattribute__(delegating, 'missing') (тоже прямой)",
lambda: object.__getattribute__(delegating, "missing"),
)
print("OVERRIDE САМ ЛОВИТ AttributeError")
case("swallowing.missing (обычная точка)", lambda: swallowing.missing)
print("ВЫВОД")
print(" Делегирование в super() внутри override НЕ отключает __getattr__:")
print(" запасной путь лежит выше __getattribute__ и срабатывает на любом")
print(" AttributeError, вышедшем наружу. Обходит __getattr__ только прямой")
print(" вызов __getattribute__, а отключает — override, гасящий исключение.")