ЗАМЕР
bench/metaclass/cost.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/interview/python/metaclasses
- Как запустить
python3.11 bench/metaclass/order.py python3.12 bench/metaclass/order.py python3.13 bench/metaclass/order.py python3.14 bench/metaclass/order.py python3.13 bench/metaclass/cost.py python3.14 bench/metaclass/cost.py
Запись прогона
Замеры для урока «Метаклассы»
| Скрипт | Что делает |
|---|---|
order.py |
наблюдения без времени: порядок шести шагов создания класса, что такое метакласс, __prepare__, граница __init_subclass__, конфликт метаклассов |
cost.py |
цена: создание класса с метаклассом и без, создание экземпляра с перехваченным __call__ и без |
python3.11 bench/metaclass/order.py
python3.12 bench/metaclass/order.py
python3.13 bench/metaclass/order.py
python3.14 bench/metaclass/order.py
python3.13 bench/metaclass/cost.py
python3.14 bench/metaclass/cost.py
Практика урока
practice.py — источник ответов двух практических задач урока, а runs/practice.txt —
дословная запись его прогона. Ответ задачи не сочиняется: сборка сверяет
заявленное с этой записью (scripts/validate-practice.mjs) и не проходит,
если они разошлись.
Прогон снят 30.08.2026 на CPython 3.13.7 (Clang 20.1.4). Абсолютные числа — этой машины; переносится кратность, и задача «во сколько раз» стоит именно на ней.
Кратность устойчива только потому, что формы меряются ВПЕРЕМЕЖКУ: в каждом круге меряются все, минимум для каждой берётся по кругам. Пока замеры шли подряд, просадка машины в окне одной формы целиком доставалась ей, и отношение гуляло в полтора раза от запуска к запуску (измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.
Что здесь важно прочитать правильно
order.py не меряет время — он меряет порядок. Каждый хук дописывает
строку в общий список, и печатается список. Такое наблюдение от машины не
зависит и обязано совпасть у любого, кто запустит скрипт на той же версии.
Порядок — главный результат обоих скриптов. В документации он рассыпан по
трём разделам: __prepare__ описан в модели данных, __set_name__ — в
разделе про дескрипторы, __init_subclass__ — в разделе про классы. Пока они
не выстроены в одну колонку, на вопросы вида «увидит ли __init_subclass__
то, что сделал метакласс» ответить нечем.
cost.py уточняет утверждение «метаклассы дорогие», и уточнение важнее
самого утверждения. Создание класса с метаклассом стоит столько же, сколько
без него. Платится за метакласс в другом месте: перехват __call__ добавляет
вызов метода на Python на КАЖДОМ создании экземпляра.
Что получилось на 3.13.7, одинаково на 3.11, 3.12, 3.13 и 3.14.7:
Создание класса — шесть шагов:
Meta.__prepare__— выдаёт словарь под тело класса- тело класса выполняется
Meta.__new__— создаёт объект класса__set_name__у дескрипторов__init_subclass__у родителяMeta.__init__— дописывает уже созданный класс
Создание экземпляра — три:
Meta.__call__Class.__new__Class.__init__
Шаги 4 и 5 происходят ВНУТРИ type.__new__, то есть до Meta.__init__:
__init_subclass__ не видит того, что метакласс делает в __init__, а
метакласс в __init__ видит результат обоих.
Цена (3.13.7):
| что | сколько |
|---|---|
| создание класса без метакласса | 6,98 мкс |
| создание класса с пустым метаклассом | 7,09 мкс |
создание класса с __init_subclass__ |
6,88 мкс |
| создание экземпляра, обычный класс | 59,6 нс |
создание экземпляра, метакласс перехватил __call__ |
223,3 нс — ×3,7 |
От запуска к запуску отношение по экземплярам держится в пределах 3,7–3,8.
Что этими числами доказать НЕЛЬЗЯ
Что метаклассы «бесплатны». Их цена реальна, просто она не во времени: конфликт
метаклассов (metaclass conflict) возникает у того, кто вашего метакласса не
выбирал, — у автора класса, который наследуется и от вашего, и от чего-то ещё
со своим метаклассом, например от ABC или Enum. Ошибка при этом появляется
в чужом файле.
Скрипт
123 строк"""
Цена метакласса для урока «Метаклассы».
ЗАПУСК (числа урока сняты на 3.13.7):
python3.13 bench/metaclass/cost.py
python3.14 bench/metaclass/cost.py
ЧТО МОЖНО СРАВНИВАТЬ. Только строки внутри одного блока одного запуска.
Абсолютные величины с другой машины несравнимы, между версиями — тоже (общее
правило замеров про тулчейн).
ЗАЧЕМ ЭТОТ ЗАМЕР ВООБЩЕ. Про метаклассы принято говорить «дорого», не
уточняя что именно. Уточнение оказывается важнее самого утверждения: создание
класса с метаклассом стоит столько же, сколько без него, а платится за
метакласс в другом месте и по другому поводу — на КАЖДОМ создании экземпляра,
и только если перехвачен `__call__`.
ПОЧЕМУ КЛАССЫ СОЗДАЮТСЯ ФУНКЦИЕЙ, А НЕ exec. Тело класса компилируется один
раз вместе с функцией; в замер попадает создание класса, а не разбор
исходника.
"""
import sys
import timeit
def header(title: str) -> None:
print(f"\n--- {title} ---")
class EmptyMeta(type):
"""Метакласс, который не делает ничего: цена самого факта его наличия."""
class CallMeta(type):
"""Перехват создания экземпляра — единственное, за что платят временем."""
def __call__(cls, *args, **kwargs):
return super().__call__(*args, **kwargs)
class WithInitSubclass:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
def make_plain() -> type:
class Made:
value = 1
return Made
def make_with_meta() -> type:
class Made(metaclass=EmptyMeta):
value = 1
return Made
def make_with_init_subclass() -> type:
class Made(WithInitSubclass):
value = 1
return Made
class PlainInstance:
__slots__ = ()
class MetaCallInstance(metaclass=CallMeta):
__slots__ = ()
def bench_class_creation() -> None:
header("1. Создание класса")
env = {
"make_plain": make_plain,
"make_with_meta": make_with_meta,
"make_with_init_subclass": make_with_init_subclass,
}
rows = [
("без метакласса", "make_plain()"),
("с пустым метаклассом", "make_with_meta()"),
("с __init_subclass__", "make_with_init_subclass()"),
]
for name, stmt in rows:
best = min(timeit.repeat(stmt, globals=env, number=20000, repeat=7)) / 20000 * 1e6
print(f"{name:<26} {best:>7.2f} мкс")
print("\nРазница в пределах шума: наличие метакласса само по себе не стоит")
print("ничего. И платится это один раз за класс, а не за экземпляр.")
def bench_instance_creation() -> None:
header("2. Создание экземпляра")
env = {"PlainInstance": PlainInstance, "MetaCallInstance": MetaCallInstance}
rows = [
("обычный класс", "PlainInstance()"),
("метакласс перехватил __call__", "MetaCallInstance()"),
]
values = []
for name, stmt in rows:
best = min(timeit.repeat(stmt, globals=env, number=200000, repeat=7)) / 200000 * 1e9
values.append(best)
print(f"{name:<32} {best:>7.1f} нс")
print(f"\nразница: {values[1] / values[0]:.1f}x — и она на КАЖДОМ экземпляре")
print("Вот где на самом деле стоит метакласс. Перехват __call__ добавляет")
print("вызов метода на Python в путь, который иначе идёт целиком в C.")
def main() -> None:
print(f"Python {sys.version.split()[0]}")
bench_class_creation()
bench_instance_creation()
if __name__ == "__main__":
main()