Deep Engineering

ЗАМЕР

bench/metaclass/order.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. Ошибка при этом появляется в чужом файле.

Скрипт

292 строк
"""
Наблюдения для урока «Метаклассы».

ЗАПУСК (порядок урока снят на 3.13.7 и совпал на всех четырёх версиях):

    for v in 3.11 3.12 3.13 3.14; do python$v bench/metaclass/order.py; done

ЗДЕСЬ НЕТ ЗАМЕРОВ ВРЕМЕНИ. Скрипт печатает ПОРЯДОК вызовов — то, что можно
записать счётчиком, а не секундомером. Время меряется отдельно, в `cost.py`.

ГЛАВНОЕ, РАДИ ЧЕГО ОН НАПИСАН. Порядок шести шагов создания класса нигде не
собран в одном месте: `__prepare__` описан в модели данных, `__set_name__` —
в разделе про дескрипторы, `__init_subclass__` — в разделе про классы. Пока
они не выстроены в одну колонку, невозможно ответить на вопросы вида «увидит
ли __init_subclass__ то, что сделал __new__ метакласса» — а ответ на них
следует из порядка и больше ниоткуда.
"""

import sys

LOG: list[str] = []


def header(title: str) -> None:
    print(f"\n--- {title} ---")


def show(title: str) -> None:
    print(f"{title}:")
    for number, step in enumerate(LOG, 1):
        print(f"  {number}. {step}")
    LOG.clear()


def observe_order() -> None:
    """Семь шагов создания класса и три — создания экземпляра."""
    header("1. Порядок вызовов")

    class Meta(type):
        @classmethod
        def __prepare__(mcls, name, bases, **kwargs):  # noqa: N804
            LOG.append("Meta.__prepare__ — выдаёт словарь под тело класса")
            return {}

        def __new__(mcls, name, bases, namespace, **kwargs):  # noqa: N804
            LOG.append("Meta.__new__ — создаёт объект класса")
            return super().__new__(mcls, name, bases, namespace)

        def __init__(cls, name, bases, namespace, **kwargs):
            LOG.append("Meta.__init__ — дописывает уже созданный класс")
            super().__init__(name, bases, namespace)

        def __call__(cls, *args, **kwargs):
            LOG.append("Meta.__call__ — перехват СОЗДАНИЯ ЭКЗЕМПЛЯРА")
            return super().__call__(*args, **kwargs)

    class Field:
        def __set_name__(self, owner, name):
            LOG.append(f"__set_name__ у дескриптора {name}")

    class Base(metaclass=Meta):
        def __init_subclass__(cls, **kwargs):
            LOG.append("__init_subclass__ у РОДИТЕЛЯ")
            super().__init_subclass__(**kwargs)

    LOG.clear()

    class Child(Base):
        LOG.append("тело класса выполняется")
        field = Field()

        def __new__(cls):
            LOG.append("Child.__new__")
            return super().__new__(cls)

        def __init__(self):
            LOG.append("Child.__init__")

    show("создание класса")

    Child()
    show("создание экземпляра")

    print("Читать так: __set_name__ и __init_subclass__ вызываются ВНУТРИ")
    print("type.__new__, то есть до Meta.__init__. Значит, __init_subclass__")
    print("не может увидеть то, что метакласс делает в __init__, а метакласс")
    print("в __init__ уже видит результат обоих.")


def observe_type_of_type() -> None:
    """Метакласс — это просто тип класса."""
    header("2. Что такое метакласс")

    class Meta(type):
        pass

    class WithMeta(metaclass=Meta):
        pass

    class Plain:
        pass

    print(f"type(Plain)      = {type(Plain).__name__}")
    print(f"type(WithMeta)   = {type(WithMeta).__name__}")
    print(f"type(type)       = {type(type).__name__}")
    print(f"Meta — подкласс type: {issubclass(Meta, type)}")
    print()
    print("Никакой отдельной сущности «метакласс» в языке нет. Есть тип, чьи")
    print("экземпляры — классы. Отсюда и весь протокол: __call__ метакласса")
    print("вызывается при создании ЭКЗЕМПЛЯРА класса ровно потому, что класс —")
    print("экземпляр метакласса.")


def observe_prepare() -> None:
    """То, чего не умеет ничто, кроме метакласса."""
    header("3. __prepare__: перехват ТЕЛА класса")

    class NoDuplicates(dict):
        def __setitem__(self, key, value):
            if key in self:
                raise TypeError(f"имя {key!r} определено в теле класса дважды")
            super().__setitem__(key, value)

    class StrictMeta(type):
        @classmethod
        def __prepare__(mcls, name, bases, **kwargs):  # noqa: N804
            return NoDuplicates()

        def __new__(mcls, name, bases, namespace, **kwargs):  # noqa: N804
            return super().__new__(mcls, name, bases, dict(namespace))

    class Plain:
        timeout = 1
        timeout = 2  # noqa: F811

    print(f"обычный класс: дубль имени проходит молча, timeout = {Plain.timeout}")

    try:
        class Config(metaclass=StrictMeta):
            timeout = 1
            timeout = 2  # noqa: F811
    except TypeError as exc:
        print(f"с __prepare__: {exc}")

    print()
    print("Это и есть ответ на вопрос «зачем нужен метакласс, если есть")
    print("__init_subclass__»: перехватить САМО ВЫПОЛНЕНИЕ тела класса можно")
    print("только через __prepare__, а он бывает только у метакласса.")


def observe_init_subclass_limits() -> None:
    """Что умеет и чего не умеет замена без метакласса."""
    header("4. Граница __init_subclass__")

    class Registry:
        registered: list[str] = []

        def __init_subclass__(cls, **kwargs):
            super().__init_subclass__(**kwargs)
            Registry.registered.append(cls.__name__)

    class First(Registry):
        pass

    class Second(Registry):
        pass

    print(f"зарегистрировались: {Registry.registered}")
    print("сам Registry в список НЕ попал — хук вызывается у родителя,")
    print("когда создаётся ПОТОМОК, и на себя не срабатывает.")
    print()
    print("Метакласс срабатывает и на том классе, где объявлен.")


def observe_conflict() -> None:
    """Цена метакласса, которая платится не временем."""
    header("5. Конфликт метаклассов")

    class MetaA(type):
        pass

    class MetaB(type):
        pass

    class A(metaclass=MetaA):
        pass

    class B(metaclass=MetaB):
        pass

    try:
        class C(A, B):
            pass
    except TypeError as exc:
        print(f"TypeError: {exc}")

    print()
    print("Ошибка возникает при СОЗДАНИИ класса-наследника, то есть у того,")
    print("кто ваш метакласс не выбирал. Это и есть настоящая цена: чужой")
    print("код перестаёт наследоваться от вашего вместе с чем угодно ещё,")
    print("у чего свой метакласс — например, с ABC или с Enum.")


def observe_decorator() -> None:
    """Третий инструмент — и его две границы."""
    header("6. Декоратор класса рядом с остальными")

    def decorate(cls):
        LOG.append(f"декоратор класса {cls.__name__}")
        return cls

    class Field:
        def __set_name__(self, owner, name):
            LOG.append(f"__set_name__ у {name}")

    class Base:
        def __init_subclass__(cls, **kwargs):
            LOG.append(f"__init_subclass__ для {cls.__name__}")
            super().__init_subclass__(**kwargs)

    LOG.clear()

    @decorate
    class Decorated(Base):
        first = Field()

    class Inheritor(Decorated):
        second = Field()

    show("объявление декорированного класса и наследника")

    print("Две границы видны прямо в списке:")
    print("  - декоратор вызывается ПОСЛЕДНИМ, когда класс уже полностью готов;")
    print("  - на наследника он не распространяется вовсе.")


def observe_namespace() -> None:
    """Что именно лежит в пространстве имён, которое получает метакласс."""
    header("7. Пространство имён и базы — они меняются между версиями")

    seen: dict = {}

    class Watch(type):
        @classmethod
        def __prepare__(mcls, name, bases, **kwargs):  # noqa: N804
            seen[f"{name}:bases"] = tuple(b.__name__ for b in bases)
            return {}

        def __new__(mcls, name, bases, namespace, **kwargs):  # noqa: N804
            seen[f"{name}:namespace"] = sorted(namespace)
            return super().__new__(mcls, name, bases, namespace)

    class Plain(metaclass=Watch):
        value = 1

    print(f"обычный класс, базы:            {seen['Plain:bases']}")
    print(f"обычный класс, пространство:    {seen['Plain:namespace']}")

    # Обобщённый класс объявляется через exec: на 3.11 синтаксис class C[T]
    # не разбирается вовсе, и файл не скомпилировался бы целиком.
    if sys.version_info >= (3, 12):
        exec(
            "class Generic1[T](metaclass=Watch):\n    value = 1\n",
            {"Watch": Watch},
        )
        print(f"class C[T], базы:               {seen['Generic1:bases']}")
        print(f"class C[T], пространство:       {seen['Generic1:namespace']}")
    else:
        print("class C[T]: синтаксиса нет до 3.12")

    print()
    print("Метакласс, который перебирает пространство имён и падает на")
    print("незнакомом ключе, ломается при обновлении. Между 3.12 и 3.13")
    print("там появились __firstlineno__ и __static_attributes__, а у")
    print("обобщённого класса в базах оказывается typing.Generic, которого")
    print("автор класса не писал.")


def main() -> None:
    print(f"Python {sys.version.split()[0]}")
    observe_order()
    observe_type_of_type()
    observe_prepare()
    observe_init_subclass_limits()
    observe_conflict()
    observe_decorator()
    observe_namespace()


if __name__ == "__main__":
    main()