Deep Engineering

MEASUREMENT

bench/context-managers/generator_cm.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/python/context-managers
How to run it
for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v traps.py; done

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «Контекстные менеджеры»

скрипт что показывает
protocol.py два метода на ТИПЕ, что связывает as, почему методы на экземпляре не считаются
suppress.py истина из __exit__ глушит исключение; return True «ради успеха» глотает всё подряд
silent.py ошибка, которая не падает: suppress вокруг цикла даёт 100 вместо 175
generator_cm.py @contextmanager без try/finally, исключение бросается в точку yield, один объект — один вход, LIFO у ExitStack, suppress выходит из блока целиком
traps.py упавший __enter__ не зовёт __exit__; несколько менеджеров — это вложение; сообщение TypeError по версиям
bytecode.py BEFORE_WITH против LOAD_SPECIAL и порядок __exit____enter__ в 3.14
cost.py цена трёх форм: без with, класс, @contextmanager

Запускать на всех версиях, которые есть:

for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v traps.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). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.

Что здесь сравнивать можно, а что нельзя

Общее правило замеров: время между версиями не сравнивается вообще. Сравнивается только то, что измерено внутри одного запуска одного интерпретатора. Дело не только в компиляторе: 3.11 и 3.12 собраны GCC 13.3.0, 3.13.7 и 3.14.7 — Clang 20.1.4, но и эти две сборки различаются между собой, причём ровно тем флагом (--with-tail-call-interp), которому «Что нового в 3.14» приписывает «a geometric mean of 3-5% faster».

Поэтому cost.py сравнивает три формы внутри одного запуска, а числа 3.13.7 и 3.14.7 стоят рядом как два независимых результата. Сравнения по времени с 3.11 и 3.12 в уроке нет вовсе.

bytecode.py, protocol.py, suppress.py, generator_cm.py и traps.py от тулчейна не зависят: они смотрят на имена инструкций, порядок вызовов и текст сообщений. Эти наблюдения честны между любыми версиями.

Разброс между запусками

cost.py — лучшее из семи прогонов по 200 000 итераций. Повторные запуски дают разброс около ±4 % у формы с классом и около ±3 % у @contextmanager. Числа в уроке округлены до целых наносекунд, и выводы построены на кратностях (во сколько раз одна форма дороже другой), а не на последней цифре: кратности разброс переживают, а цифра — нет.

Script

97 lines
import sys, gc
from contextlib import contextmanager, ExitStack, suppress
print("PY", sys.version.split()[0])

# 1. @contextmanager БЕЗ try/finally: при исключении уборка не выполняется.
log = []

@contextmanager
def naive():
    log.append("открыли")
    yield
    log.append("закрыли")          # сюда не придут при исключении

@contextmanager
def careful():
    log.append("открыли")
    try:
        yield
    finally:
        log.append("закрыли")

log.clear()
with naive():
    pass
print("1) без исключения, без try/finally:", log)

log.clear()
try:
    with naive():
        raise ValueError
except ValueError:
    pass
print("   С ИСКЛЮЧЕНИЕМ, без try/finally:", log)

log.clear()
try:
    with careful():
        raise ValueError
except ValueError:
    pass
print("   с исключением, с try/finally:", log)

# 2. Куда девается исключение в генераторе: оно БРОСАЕТСЯ в точку yield.
@contextmanager
def catches():
    try:
        yield
    except ValueError as e:
        print("2) генератор поймал в точке yield:", type(e).__name__)
        # ничего не перебрасываем -> исключение подавлено
with catches():
    raise ValueError("внутрь генератора")
print("   выполнение продолжилось")

# 3. Один генератор — один вход. Второй раз тот же ОБЪЕКТ не открыть.
#    Исключение при этом не про повторный вход, а про съеденные атрибуты:
#    __enter__ делает `del self.args`. Сообщение поэтому невразумительное —
#    и это часть находки, а не оговорка.
cm = careful()
log.clear()
with cm:
    pass
try:
    with cm:
        pass
except BaseException as e:
    print("3) повторный вход в тот же объект:", type(e).__name__, "-", e)

#    А ФУНКЦИЮ вызвать заново можно: каждый вызов — новый объект.
with careful():
    pass
print("   новый вызов той же функции:", "ок")

# 4. ExitStack закрывает в обратном порядке.
order = []
@contextmanager
def named(n):
    order.append(f"вход {n}")
    try:
        yield n
    finally:
        order.append(f"выход {n}")

with ExitStack() as stack:
    for n in ("A", "B", "C"):
        stack.enter_context(named(n))
print("4) порядок ExitStack:", " | ".join(order))

# 5. contextlib.suppress — это НЕ try/except вокруг каждой строки:
#    он выходит из блока целиком.
done = []
with suppress(ZeroDivisionError):
    done.append("первая")
    1 / 0
    done.append("вторая")
print("5) после suppress выполнено:", done)