ЗАМЕР
bench/comprehensions/silent.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 от версии к версии сравнивать можно: там не время, а имена
инструкций, наличие объектов кода и текст сообщений об ошибках.
Скрипт
85 строк"""Две ошибки, которые не падают: подменённая константа и утёкший морж.
ЗАЧЕМ ЭТО. `NameError` из раздела про область видимости — ошибка честная: она
падает, и её видно. Хуже, когда имя всё-таки находится, но не то.
1. ПОДМЕНЁННАЯ КОНСТАНТА. Включение в теле класса не видит атрибутов
класса — но прекрасно видит имена модуля. Если имя есть и там и там,
включение возьмёт модульное, а обычный цикл строкой ниже — классовое.
Исключения не будет: будут просто разные числа.
2. УТЁКШИЙ МОРЖ. `:=` внутри включения связывает имя снаружи (PEP 572).
Если такое имя уже занято, включение его затрёт — и код после включения
будет работать с последним значением из цикла.
Оба случая печатают неверный и верный ответ рядом.
"""
import sys
print("PY", sys.version.split()[0])
# ----------------------------------------------- 1. подменённая константа
SCALE = 1 # модульная константа: «единица измерения по умолчанию»
class Prices:
"""Класс переопределяет SCALE — и думает, что переопределил везде."""
SCALE = 100 # копейки
raw = [2, 3, 5]
# Включение: SCALE ищется НЕ в теле класса, а в модуле.
scaled = [x * SCALE for x in raw]
# Тот же расчёт обычным циклом: SCALE ищется в теле класса.
loop = []
for x in raw:
loop.append(x * SCALE)
print()
print("1) включение в теле класса берёт модульное имя, а не классовое")
print(f" SCALE в модуле : {SCALE}")
print(f" Prices.SCALE : {Prices.SCALE}")
print(f" через включение : {Prices.scaled}")
print(f" через обычный цикл : {Prices.loop}")
print(f" верно : {[x * Prices.SCALE for x in Prices.raw]}")
print(" исключений не было ни одного")
# ------------------------------------------------------ 2. утёкший морж
print()
print("2) морж из включения затирает имя снаружи")
EVENTS = [
{"id": 1, "status": "ok"},
{"id": 2, "status": "ok"},
{"id": 3, "status": "failed"},
]
def report_broken(events):
"""Проверка, написанная «компактно». Работает до первого чтения status."""
status = "ok"
failures = [e for e in events if (status := e["status"]) != "ok"]
return {"failures": len(failures), "overall": status}
def report_right(events):
"""То же самое без моржа: имя снаружи не трогается."""
status = "ok"
failures = [e for e in events if e["status"] != "ok"]
return {"failures": len(failures), "overall": status}
print(f" события: {[e['status'] for e in EVENTS]}")
print(f" неправильно: {report_broken(EVENTS)}")
print(f" правильно : {report_right(EVENTS)}")
print(" Поле failures совпадает, overall — нет. Тест на число ошибок пройдёт.")
# И то, из-за чего ошибка живёт долго: она зависит от ПОСЛЕДНЕГО элемента.
REORDERED = [EVENTS[2], EVENTS[0], EVENTS[1]]
print(f"\n те же события в другом порядке: {[e['status'] for e in REORDERED]}")
print(f" неправильно: {report_broken(REORDERED)} <- теперь overall верный")
print(" Один и тот же код на одних и тех же данных отвечает по-разному")
print(" в зависимости от порядка. Такое не ловится ни одним прогоном.")