Deep Engineering

ЗАМЕР

bench/comprehensions/walrus.py

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

Цитируется в статье
/ru/interview/python/comprehensions
Как запустить
for v in 3.11 3.12 3.13 3.14; do python$v bench/comprehensions/scope.py; done

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

Замеры для урока «Включения: где проходит граница»

Скрипт Что показывает
scope.py где именно проходит граница области видимости: переменная не течёт, первый итерируемый вычисляется снаружи, тело класса даёт NameError там, где функция не даёт
walrus.py единственное, что ходит через границу наружу: := связывает имя в объемлющей области (PEP 572), плюс два запрета и заявленный мотив
silent.py две ошибки, которые не падают: подменённая константа в теле класса и утёкший морж
cost.py включение против цикла против map — и что на самом деле решает
bytecode.py что изменил PEP 709, чего не изменил, и почему «вынести метод в локальную» больше не помогает
for v in 3.11 3.12 3.13 3.14; do python$v bench/comprehensions/scope.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). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.

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

Граница проходит не по скобкам включения. Справочник: «aside from the iterable expression in the leftmost for clause, the comprehension is executed in a separate implicitly nested scope». Отсюда четыре строки подряд в одном теле класса, из которых работают первая и последняя:

[x for x in raw]                  -> [2, 3, 5]
[x * multiplier for x in raw]     -> NameError: name 'multiplier' is not defined
[x for x in raw if x > limit]     -> NameError: name 'limit' is not defined
[x + y for x in raw for y in raw] -> NameError: name 'raw' is not defined
обычный for/append по raw         -> [20, 30, 50]

Четвёртая строка — самая наглядная: raw в ней написано дважды, и первое вхождение видно, а второе нет.

Правило не менялось четыре версии подряд. PEP 709 (3.12) встроил включения в вызывающий код — MAKE_FUNCTION и вложенного объекта кода у них больше нет, — но область видимости осталась прежней. Проверено: на 3.11, 3.12, 3.13 и 3.14 scope.py печатает одни и те же три NameError.

Выбор «включение или цикл или map» почти ничего не стоит. На 3.13.7 разброс между тремя способами — 1,13 раза. Решает другое: убрать вызов кода на Python из тела даёт 1,7 раза, а заменить условие на Python вызовом filter(None, …) — ещё 1,8.

Приём «вынести метод в локальную» стоит дороже, чем не выносить. Около десяти процентов, устойчиво. Инструкций в теле цикла при этом поровну — девять и девять; разница в том, что out.append(...) компилируется в LOAD_ATTR с установленным младшим битом (форма, которая кладёт на стек метод и self сразу), а вызов через локальную переменную идёт обычным путём и подставляет self на каждом вызове.

Чего здесь нет и почему

Сравнения спискового включения с генераторным выражением. Оно разобрано в уроке «Итераторы и генераторы» — там же и про то, что PEP 709 генераторных выражений не касается. Повторять его здесь значило бы дать два разных набора чисел на один и тот же вопрос.

Времени для walrus.py и scope.py. Область видимости — вопрос семантики; наносекунды в этих разделах ничего бы не подкрепили.

Правило версий

Время между версиями не сравнивается. Числа 3.13.7 и 3.14.7 в cost.py приводятся рядом как два независимых результата; сравнивать можно только кратности внутри одного прогона. Наблюдения scope.py, walrus.py и bytecode.py от версии к версии сравнивать можно: там не время, а имена инструкций, наличие объектов кода и текст сообщений об ошибках.

Скрипт

87 строк
"""Морж во включении: единственное, что ходит через границу наружу.

ЗАЧЕМ ЭТО. Правило «переменная включения наружу не течёт» читатель достраивает
до «из включения наружу ничего не течёт». Одно исключение есть, и оно
записано в PEP 572 нормативно:

    an assignment expression occurring in a list, set or dict comprehension or
    in a generator expression (below collectively referred to as
    "comprehensions") binds the target in the containing scope, honoring a
    `nonlocal` or `global` declaration for the target in that scope, if one
    exists.

То есть `:=` внутри включения связывает имя СНАРУЖИ. Это не побочный эффект
реализации: PEP называет два мотива, и первый из них — ради чего это и сделано:

    allows us to conveniently capture a "witness" for an `any()` expression,
    or a counterexample for `all()`

Здесь проверяется и правило, и оба его ограничения, и заявленный мотив.

ЧЕГО ЗДЕСЬ НЕТ. Времени. Морж во включении — вопрос области видимости, а не
скорости; наносекунды тут ничего бы не подкрепили.
"""
import sys

print("PY", sys.version.split()[0])


def rule(title):
    print()
    print(title)
    print("-" * len(title))


# ------------------------------------------------------------ 1. утечка
rule("1. Переменная цикла остаётся внутри, морж выходит наружу")

items = [4, 7, 2]
result = [(seen := v) for v in items]
print(f"  [(seen := v) for v in {items}] -> {result}")
print(f"  v    после включения -> {'не определено' if 'v' not in dir() else repr(v)}")
print(f"  seen после включения -> {seen!r}   <- последнее значение")

# То же самое в генераторном выражении — и это важно: генераторные выражения
# PEP 709 не встраивает, отдельная функция у них осталась. Значит утечка
# моржа — не следствие встраивания, а отдельное правило языка.
del seen
gen_result = list((seen := v) for v in items)
print(f"  то же в генераторном выражении -> seen = {seen!r}")


# --------------------------------------------------------- 2. запреты
rule("2. Два запрета, оба с собственным сообщением")

FORBIDDEN = [
    ("имя моржа = переменной цикла", "[(i := i) for i in range(3)]"),
    ("морж в первой итерируемой части", "[x for x in (n := range(3))]"),
    ("морж во второй итерируемой части", "[x for r in [[1]] for x in (n := r)]"),
]
for label, src in FORBIDDEN:
    try:
        compile(src, "<bench>", "eval")
        print(f"  {label:<34} скомпилировалось")
    except SyntaxError as e:
        print(f"  {label:<34} SyntaxError: {e.msg}")


# ------------------------------------------------------------- 3. мотив
rule("3. Ради чего это сделано: свидетель для any()")

data = [1, 3, 8, 5]
if any((witness := d) > 4 for d in data):
    print(f"  данные {data}")
    print(f"  any((witness := d) > 4 for d in data) -> True, witness = {witness}")
    print("  Первое значение, на котором условие сработало, осталось снаружи.")
    print("  Без моржа пришлось бы искать его вторым проходом.")

# Обратная сторона мотива: если условие не сработало ни разу, имя всё равно
# связано — последним проверенным значением, а не «ничем». Ловушка ровно там,
# где ей проще всего сработать: в ветке else.
small = [1, 2, 3]
if any((w2 := d) > 100 for d in small):
    print("  недостижимо")
else:
    print(f"\n  any(... > 100 ...) -> False, но w2 = {w2} — последнее проверенное,")
    print("  а не «ничего». Проверять надо результат any(), а не наличие имени.")