Deep Engineering

ЗАМЕР

bench/exceptions/groups.py

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

Цитируется в статье
/ru/interview/python/exceptions-and-finally
Как запустить
for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v finally_traps.py; done

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

Замеры для урока «Исключения и finally»

скрипт что показывает
finally_traps.py return/break в finally проглатывают исключение и перекрывают возврат; SyntaxWarning в 3.14 (PEP 765)
silent.py ошибка, которая не падает: 100 вместо 175 — те же строки, что в замере про контекстные менеджеры
chaining.py __context__, __cause__, raise ... from None, исчезновение имени после except ... as e
scope.py голый except: ловит BaseException; что делает else и почему его тело не защищено
groups.py ExceptionGroup и except*: один raise — несколько срабатываний
bytecode.py почему try бесплатен, пока не сработал: нет SETUP_FINALLY, есть ExceptionTable
cost.py цена трёх форм: без try, try не сработал, try сработал

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

for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v finally_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, finally_traps.py, chaining.py, scope.py и groups.py от тулчейна не зависят: они смотрят на имена инструкций, наличие таблицы исключений, порядок вызовов и текст сообщений. Эти наблюдения честны между любыми версиями — и именно они объясняют числа из cost.py.

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

cost.py — лучшее из семи прогонов по 200 000 итераций. Повторные запуски дают разброс около ±5 %. Числа в уроке округлены до целых наносекунд, а главный вывод — что «не сработавший try» и «без try» неразличимы, — разброс переживает: разница между ними меньше разброса каждого из них, и это и есть утверждение.

Откуда ±5 %, и почему раньше стояло ±3 %. 21 августа 2026 сделано по пять свежих прогонов cost.py на каждой версии. Строка «без try» дала 25,7–26,9 нс на 3.13.7 и 26,6–28,1 нс на 3.14.7 — размах 1,2 и 1,5 нс. Прежняя оценка ±3 % (то есть ±0,8 нс) была меньше разрыва между первыми двумя строками на 3.14 (1,2 нс): подпись к ExceptionCost утверждала «разницы нет» вопреки собственной арифметике. С ±5 % арифметика сходится на обеих версиях.

Сильнее любого процента здесь другое наблюдение: в одном из пяти прогонов на 3.13.7 строки поменялись местами — 26,9 нс без try против 26,7 нс с ним. Порядок, который меняется от запуска к запуску, и есть определение «неразличимо».

Скрипт

41 строк
"""Группы исключений и except* (PEP 654, 3.11)."""
import sys
print("PY", sys.version.split()[0])

group = ExceptionGroup("две беды", [ValueError("значение"), KeyError("ключ")])

# 1. Обычный except ловит ГРУППУ ЦЕЛИКОМ, а не её части.
try:
    raise group
except Exception as e:
    print("1) обычный except поймал:", type(e).__name__, "| внутри:",
          [type(x).__name__ for x in e.exceptions])

# 2. except* разбирает группу и может сработать НЕСКОЛЬКО раз за один raise.
hits = []
try:
    raise ExceptionGroup("две беды", [ValueError("значение"), KeyError("ключ")])
except* ValueError as eg:
    hits.append(("ValueError", [str(x) for x in eg.exceptions]))
except* KeyError as eg:
    hits.append(("KeyError", [str(x) for x in eg.exceptions]))
print("2) except* сработал", len(hits), "раза:", hits)

# 3. Необработанная часть группы летит дальше — сама группой.
try:
    try:
        raise ExceptionGroup("три", [ValueError(), KeyError(), TypeError()])
    except* ValueError:
        pass
except BaseExceptionGroup as rest:
    print("3) осталось:", [type(x).__name__ for x in rest.exceptions])

# 4. Группа из одного исключения НЕ схлопывается в него: это по-прежнему
#    группа, и обычный `except ValueError` её не поймает.
try:
    raise ExceptionGroup("одна", [ValueError("одинокая")])
except ValueError:
    print("4) поймали как ValueError")
except ExceptionGroup as e:
    print("4) поймали как группу, хотя внутри одна ошибка:", type(e).__name__)