Deep Engineering

ЗАМЕР

bench/closures/scopes.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/python/closures-and-scope

Запись прогона

Замеры и наблюдения: замыкания и области видимости

Числа урока «Замыкания и области видимости» берутся отсюда, а не из головы.

for v in 3.12 3.13 3.14; do python$v bench/closures/scopes.py; done

Практика урока

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). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.

Что тут измеряется, а что наблюдается

Скрипт делает две разные вещи, и урок ссылается на них по-разному.

Наблюдения (разделы 1–5) — устройство, а не время: какие опкоды порождает компилятор, что лежит в __closure__, где проходит граница областей. Они не зависят от машины: любой, кто запустит скрипт на той же версии, увидит то же самое. Именно поэтому в уроке они поданы как «проверено запуском», без оговорок про железо.

Замер (раздел 6) — время, и оно от машины зависит. Сравнивать можно только строки одного запуска: три вида доступа меряются подряд, в одинаковых условиях.

Оговорки, без которых числа врут

Между версиями время не сравнивается. 3.12.3 собран GCC 13.3.0, а 3.13.7 и 3.14.7 — Clang 20.1.4. Разница сборок больше разницы, которую мы меряем. Числа урока сняты на 3.13.7.

3.14 — это 3.14.7. На машине замеров доступен только кандидат в релизы, и везде, где версия влияет на результат, она названа полностью. Подписывать результат кандидата как «3.14» нельзя.

Доступ меряется без цикла. Первая версия замера ставила обращение внутрь for, и разница утонула: на каждое обращение приходились ещё итерация, сложение и проверка границы — мерился в основном цикл. Сейчас тело функции это двести обращений к одному имени подряд, сложенных в одно выражение.

Берётся минимум из девяти повторов, а не среднее. Минимум — нижняя граница, на которую не влияют соседи по машине; среднее меряло бы заодно и их.

Что получилось на 3.13.7

доступ опкод на обращение к локальной
локальная переменная LOAD_FAST 5,6 нс ×1,00
ячейка замыкания LOAD_DEREF 6,2 нс ×1,10
глобальная LOAD_GLOBAL 6,2 нс ×1,10

Числа устойчивы между запусками в пределах ±0,1 нс (проверено тремя прогонами).

Вывод, который из этого делает урок: разница есть, но она порядка половины наносекунды на обращение — то есть переписывать код ради неё бессмысленно. Отдельно стоит того, что глобальная стоит столько же, сколько ячейка: расхожее «глобальные медленные» на этих числах не подтверждается.

Скрипт

235 строк
"""
Замер и наблюдение для урока «Замыкания и области видимости».

ЗАПУСК (числа урока сняты на 3.13.7):

    for v in 3.12 3.13 3.14; do python$v bench/closures/scopes.py; done

ЧТО ЗДЕСЬ ДВЕ РАЗНЫЕ ВЕЩИ, И ИХ НЕЛЬЗЯ ПУТАТЬ.

НАБЛЮДЕНИЯ (разделы 1-5) — про устройство: какие опкоды порождает компилятор,
что лежит в `__closure__`, где проходит граница областей. Они не зависят от
машины и обязаны совпадать у любого, кто запустит скрипт на той же версии.

ЗАМЕР (раздел 6) — про время, и он от машины зависит. Сравнивать его можно
только между строками ОДНОГО запуска: три вида доступа к имени меряются подряд,
в одинаковых условиях. Сравнивать абсолютные наносекунды с другой машиной или
между версиями бессмысленно — 3.12 собран GCC, а 3.13 и 3.14 Clang.

ПОЧЕМУ ДОСТУП МЕРЯЕТСЯ БЕЗ ЦИКЛА. Первая версия замера ставила обращение внутрь
`for` — и разница утонула: на каждое обращение приходились ещё итерация, сложение
и проверка границы, то есть мерился в основном цикл. Здесь тело функции — двести
обращений к одному имени подряд, сложенных в одно выражение, и накладного расхода
на них почти нет.
"""

import dis
import sys
import timeit


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


def observe_cells() -> None:
    """Ячейка: кто её заводит, кто на неё ссылается, и одна ли она на всех."""
    header("1. Ячейки замыкания")

    def outer():
        x = 1

        def inner():
            return x

        return inner

    inner = outer()
    print(f"  co_cellvars у внешней   : {outer.__code__.co_cellvars}")
    print(f"  co_freevars у внутренней: {inner.__code__.co_freevars}")
    print(f"  cell_contents           : {inner.__closure__[0].cell_contents}")

    def two():
        x = 1

        def a():
            return x

        def b():
            return x

        return a, b

    a, b = two()
    # Ключевой факт урока: ячейка ОДНА, а не по копии на функцию.
    print(f"  одна ячейка у двух функций: {a.__closure__[0] is b.__closure__[0]}")

    def plain():
        def f():
            return 42

        return f

    print(f"  __closure__ без свободных переменных: {plain().__closure__}")


def observe_runtime_resolution() -> None:
    """Захвачена ПЕРЕМЕННАЯ, а не значение: свободное имя читается при вызове."""
    header("2. Свободное имя читается во время выполнения")

    def make():
        v = "первое"

        def show():
            return v

        v = "второе"  # меняем ПОСЛЕ создания show
        return show

    print(f"  значение менялось после создания функции, отдаётся: {make()()!r}")


def observe_local_trap() -> None:
    """Присваивание где угодно в теле делает имя локальным для ВСЕГО тела."""
    header("3. Ловушка локального имени")

    x = "из объемлющей"  # noqa: F841 — нужна, чтобы показать, что её не видно

    def trap_real():
        try:
            value = x
        except UnboundLocalError as exc:
            return f"UnboundLocalError: {exc}"
        x = "локальная"  # noqa: F841
        return value

    print(f"  {trap_real()}")
    # Решение принято КОМПИЛЯТОРОМ: имя лежит в co_varnames, а не в co_names.
    print(f"  co_varnames: {trap_real.__code__.co_varnames}")


def observe_class_skip() -> None:
    """Тело класса пропускается в цепочке областей (PEP 227)."""
    header("4. Тело класса в цепочку не входит")

    def make():
        x = "из функции"

        class C:
            x = "из класса"

            def get(self):
                return x  # берёт из ФУНКЦИИ, минуя одноимённый атрибут класса

        return C

    print(f"  метод видит: {make()().get()!r}")

    def make_fail():
        class C:
            y = "класс"

            def get(self):
                try:
                    return y
                except NameError as exc:
                    return f"NameError: {exc}"

        return C

    print(f"  имя из тела класса: {make_fail()().get()}")


def observe_bytecode() -> None:
    """Опкоды, которыми ячейка заводится и читается."""
    header("5. Байткод")
    src = "def outer():\n    x = 1\n    def inner():\n        return x\n    return inner\n"
    module = compile(src, "<bench>", "exec")
    outer = next(c for c in module.co_consts if getattr(c, "co_name", None) == "outer")
    inner = next(c for c in outer.co_consts if getattr(c, "co_name", None) == "inner")
    print("  внешняя:")
    for ins in dis.get_instructions(outer):
        print(f"    {ins.opname:24s} {ins.argrepr}")
    print("  внутренняя:")
    for ins in dis.get_instructions(inner):
        print(f"    {ins.opname:24s} {ins.argrepr}")


ACCESS_COUNT = 200


def _body(indent: int) -> str:
    pad = " " * indent
    return pad + "t = " + " + ".join(["x"] * ACCESS_COUNT) + "\n" + pad + "return t\n"


def measure_access() -> None:
    """Сколько стоит обращение к локальной, к ячейке и к глобальной."""
    header(f"6. Цена обращения к имени ({ACCESS_COUNT} обращений в одном вызове)")

    setups = {
        "LOAD_FAST  (локальная)": "def f():\n    x = 1\n" + _body(4),
        "LOAD_DEREF (ячейка)": (
            "def outer():\n    x = 1\n    def f():\n" + _body(8) + "    return f\nf = outer()\n"
        ),
        "LOAD_GLOBAL (глобальная)": "x = 1\ndef f():\n" + _body(4),
    }

    # min из девяти повторов, а не среднее: измеряется нижняя граница, на которую
    # не влияют посторонние процессы. Среднее меряло бы заодно и соседей по машине.
    def one_pass() -> dict[str, float]:
        return {
            name: min(timeit.repeat("f()", setup=setup, number=20_000, repeat=9)) / 20_000
            for name, setup in setups.items()
        }

    # ЗАЧЕМ ВЕСЬ ЗАМЕР ПОВТОРЯЕТСЯ ЦЕЛИКОМ.
    #
    # Урок утверждает не только числа, но и то, КАК их читать: что ячейка и
    # глобальная стоят «примерно одинаково». Такое утверждение держится лишь
    # тогда, когда расстояние между этими двумя строками меньше, чем шатание
    # каждой из них от повтора к повтору. Одиночный прогон этого не знает: он
    # печатает три числа и молчит о том, воспроизводимы ли они. Поэтому весь
    # замер повторяется OUTER раз, и рядом с кратностями печатается размах —
    # чтобы читатель (и следующий правящий эту статью) видел прибор, а не
    # только показание.
    OUTER = 5
    series: dict[str, list[float]] = {name: [] for name in setups}
    for _ in range(OUTER):
        for name, t in one_pass().items():
            series[name].append(t)

    times = {name: min(vals) for name, vals in series.items()}
    base = times["LOAD_FAST  (локальная)"]
    for name, t in times.items():
        per = t / ACCESS_COUNT * 1e9
        print(f"    {name:26s} {t * 1e6:6.2f} мкс   x{t / base:.2f}   {per:5.1f} нс на обращение")

    print(f"\n    РАЗМАХ ПО {OUTER} ПОВТОРАМ ВСЕГО ЗАМЕРА, в кратностях к локальной")
    ratios = {name: sorted(v / base for v in vals) for name, vals in series.items()}
    for name in setups:
        lo, hi = ratios[name][0], ratios[name][-1]
        print(f"    {name:26s} x{lo:.2f} .. x{hi:.2f}   размах {hi - lo:.2f}")

    cell = ratios["LOAD_DEREF (ячейка)"]
    glob = ratios["LOAD_GLOBAL (глобальная)"]
    gap = abs(min(glob) - min(cell))
    spread = max(cell[-1] - cell[0], glob[-1] - glob[0])
    print(f"\n    расстояние между ячейкой и глобальной   {gap:.2f}")
    print(f"    наибольший размах этих же двух строк    {spread:.2f}")
    print(f"    различима ли разница прибором           {'да' if gap > spread else 'нет'}")


def main() -> None:
    print(f"Python {sys.version.split()[0]}")
    observe_cells()
    observe_runtime_resolution()
    observe_local_trap()
    observe_class_skip()
    observe_bytecode()
    measure_access()


if __name__ == "__main__":
    main()