ЗАМЕР
bench/comprehensions/bytecode.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 от версии к версии сравнивать можно: там не время, а имена
инструкций, наличие объектов кода и текст сообщений об ошибках.
Скрипт
114 строк"""Что PEP 709 изменил, чего не изменил — и почему «вынести метод» подорожало.
ТРИ ВОПРОСА, НА КОТОРЫЕ ОТВЕЧАЕТ ЭТОТ СКРИПТ.
1. ЧТО ИЗМЕНИЛОСЬ В 3.12. PEP 709 встроил списковые, словарные и
множественные включения в вызывающий код: отдельного объекта кода у них
больше нет. Видно по `co_consts` и по отсутствию `MAKE_FUNCTION`.
2. ЧЕГО ОН НЕ ИЗМЕНИЛ. Область видимости. PEP говорит, что изоляция
переменных сохраняется — теперь через сохранение и восстановление на
стеке вместо вызова функции. Проверяется не байт-кодом, а `scope.py`:
на всех четырёх версиях включение в теле класса даёт один и тот же
`NameError`.
3. ПОЧЕМУ ПРИЁМ «ВЫНЕСТИ МЕТОД В ЛОКАЛЬНУЮ» БОЛЬШЕ НЕ ПОМОГАЕТ. Совет
старый и когда-то был верен. Замер (`cost.py`) даёт обратный знак около
десяти процентов, и объяснение — здесь: инструкций в теле цикла ровно
столько же, но вызов идёт другим путём.
"""
import dis
import sys
print("PY", sys.version.split()[0])
def rule(title):
print()
print(title)
print("-" * len(title))
def opnames(code):
return [i.opname for i in dis.get_instructions(code)]
# ------------------------------------------------ 1. что изменил PEP 709
rule("1. Встраивание включений (PEP 709, 3.12)")
CASES = [
("списковое включение", "x = [i for i in src]"),
("словарное включение", "x = {i: i for i in src}"),
("множественное включение", "x = {i for i in src}"),
("генераторное выражение", "x = (i for i in src)"),
]
for label, src in CASES:
code = compile(src, "<bench>", "exec")
names = opnames(code)
has_make = "MAKE_FUNCTION" in names
nested = [c for c in code.co_consts if hasattr(c, "co_name")]
print(
f" {label:<26} MAKE_FUNCTION: {'есть' if has_make else 'нет ':<5}"
f" вложенный объект кода: {nested[0].co_name if nested else 'нет'}"
)
print()
print(" Генераторное выражение PEP 709 не касается — у него отдельная функция")
print(" и отдельный кадр остались. Три остальных встроены.")
# ------------------------------------- 3. почему «вынести метод» дороже
rule("2. Почему «вынести метод в локальную» стало дороже")
def by_loop(src, f):
out = []
for x in src:
out.append(f(x))
return out
def by_loop_hoisted(src, f):
out = []
append = out.append
for x in src:
append(f(x))
return out
def loop_body(fn):
"""Инструкции между FOR_ITER и JUMP_BACKWARD — то, что исполняется на
каждом элементе. Всё остальное отрабатывает один раз и в счёт не идёт."""
names = opnames(fn)
start = names.index("FOR_ITER") + 1
end = names.index("JUMP_BACKWARD")
return names[start:end]
for fn in (by_loop, by_loop_hoisted):
body = loop_body(fn)
print(f" {fn.__name__:<18} {len(body)} инструкций в теле цикла")
print(f" {'':<18} {' '.join(body)}")
print()
n_plain = len(loop_body(by_loop))
n_hoist = len(loop_body(by_loop_hoisted))
print(f" Инструкций в теле цикла поровну — {n_plain} и {n_hoist}. Разница не в")
print(" количестве, а в том, ЧТО стоит на месте вызова.")
print()
print(" `out.append(...)` компилируется в LOAD_ATTR с установленным младшим")
print(" битом аргумента. Документация dis описывает эту форму так: «if")
print(" STACK[-1] has a method with the correct name, the bytecode pushes the")
print(" unbound method and STACK[-1]. STACK[-1] will be used as the first")
print(" argument (self) by CALL». То есть self кладётся на стек здесь же, и")
print(" объекта связанного метода не возникает вовсе.")
print()
print(" `append(...)` через локальную переменную — обычный вызов обычного")
print(" объекта: PUSH_NULL плюс вызов уже связанного метода, которому self")
print(" придётся подставить самому, на каждом вызове.")
print()
print(" Замер (cost.py) даёт разницу около десяти процентов не в пользу")
print(" «оптимизации». Совет вынести метод в локальную пришёл из времён, когда")
print(" поиск атрибута был непредсказуемо дорогим; с тех пор подешевел он, а")
print(" не альтернатива ему.")