Deep Engineering

ЗАМЕР

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:

Создание класса — шесть шагов:

  1. Meta.__prepare__ — выдаёт словарь под тело класса
  2. тело класса выполняется
  3. Meta.__new__ — создаёт объект класса
  4. __set_name__ у дескрипторов
  5. __init_subclass__ у родителя
  6. Meta.__init__ — дописывает уже созданный класс

Создание экземпляра — три:

  1. Meta.__call__
  2. Class.__new__
  3. 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()