ЗАМЕР
bench/closures/scopes.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/interview/python/closures-and-scope
Запись прогона
Замеры и наблюдения: замыкания и области видимости
Числа урока «Замыкания и области видимости» берутся отсюда, а не из головы.
for v in 3.12 3.13 3.14; do python$v bench/closures/scopes.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). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.
Что тут измеряется, а что наблюдается
Скрипт делает две разные вещи, и урок ссылается на них по-разному.
Наблюдения (разделы 1–5) — устройство, а не время: какие опкоды порождает
компилятор, что лежит в __closure__, где проходит граница областей. Они не
зависят от машины: любой, кто запустит скрипт на той же версии, увидит то же
самое. Именно поэтому в уроке они поданы как «проверено запуском», без оговорок
про железо.
Замер (раздел 6) — время, и оно от машины зависит. Сравнивать можно только строки одного запуска: три вида доступа меряются подряд, в одинаковых условиях.
Оговорки, без которых числа врут
Между версиями время не сравнивается. 3.12.3 собран GCC 13.3.0, а 3.13.7 и 3.14.7 — Clang 20.1.4. Разница сборок больше разницы, которую мы меряем. Числа урока сняты на 3.13.7.
3.14 — это 3.14.7. На машине замеров доступен только кандидат в
релизы, и везде, где версия влияет на результат, она названа полностью.
Подписывать результат кандидата как «3.14» нельзя.
Доступ меряется без цикла. Первая версия замера ставила обращение внутрь
for, и разница утонула: на каждое обращение приходились ещё итерация, сложение
и проверка границы — мерился в основном цикл. Сейчас тело функции это двести
обращений к одному имени подряд, сложенных в одно выражение.
Берётся минимум из девяти повторов, а не среднее. Минимум — нижняя граница, на которую не влияют соседи по машине; среднее меряло бы заодно и их.
Что получилось на 3.13.7
| доступ | опкод | на обращение | к локальной |
|---|---|---|---|
| локальная переменная | LOAD_FAST |
5,6 нс | ×1,00 |
| ячейка замыкания | LOAD_DEREF |
6,2 нс | ×1,10 |
| глобальная | LOAD_GLOBAL |
6,2 нс | ×1,10 |
Числа устойчивы между запусками в пределах ±0,1 нс (проверено тремя прогонами).
Вывод, который из этого делает урок: разница есть, но она порядка половины наносекунды на обращение — то есть переписывать код ради неё бессмысленно. Отдельно стоит того, что глобальная стоит столько же, сколько ячейка: расхожее «глобальные медленные» на этих числах не подтверждается.
Скрипт
235 строк"""
Замер и наблюдение для урока «Замыкания и области видимости».
ЗАПУСК (числа урока сняты на 3.13.7):
for v in 3.12 3.13 3.14; do python$v bench/closures/scopes.py; done
ЧТО ЗДЕСЬ ДВЕ РАЗНЫЕ ВЕЩИ, И ИХ НЕЛЬЗЯ ПУТАТЬ.
НАБЛЮДЕНИЯ (разделы 1-5) — про устройство: какие опкоды порождает компилятор,
что лежит в `__closure__`, где проходит граница областей. Они не зависят от
машины и обязаны совпадать у любого, кто запустит скрипт на той же версии.
ЗАМЕР (раздел 6) — про время, и он от машины зависит. Сравнивать его можно
только между строками ОДНОГО запуска: три вида доступа к имени меряются подряд,
в одинаковых условиях. Сравнивать абсолютные наносекунды с другой машиной или
между версиями бессмысленно — 3.12 собран GCC, а 3.13 и 3.14 Clang.
ПОЧЕМУ ДОСТУП МЕРЯЕТСЯ БЕЗ ЦИКЛА. Первая версия замера ставила обращение внутрь
`for` — и разница утонула: на каждое обращение приходились ещё итерация, сложение
и проверка границы, то есть мерился в основном цикл. Здесь тело функции — двести
обращений к одному имени подряд, сложенных в одно выражение, и накладного расхода
на них почти нет.
"""
import dis
import sys
import timeit
def header(title: str) -> None:
print(f"\n--- {title} ---")
def observe_cells() -> None:
"""Ячейка: кто её заводит, кто на неё ссылается, и одна ли она на всех."""
header("1. Ячейки замыкания")
def outer():
x = 1
def inner():
return x
return inner
inner = outer()
print(f" co_cellvars у внешней : {outer.__code__.co_cellvars}")
print(f" co_freevars у внутренней: {inner.__code__.co_freevars}")
print(f" cell_contents : {inner.__closure__[0].cell_contents}")
def two():
x = 1
def a():
return x
def b():
return x
return a, b
a, b = two()
# Ключевой факт урока: ячейка ОДНА, а не по копии на функцию.
print(f" одна ячейка у двух функций: {a.__closure__[0] is b.__closure__[0]}")
def plain():
def f():
return 42
return f
print(f" __closure__ без свободных переменных: {plain().__closure__}")
def observe_runtime_resolution() -> None:
"""Захвачена ПЕРЕМЕННАЯ, а не значение: свободное имя читается при вызове."""
header("2. Свободное имя читается во время выполнения")
def make():
v = "первое"
def show():
return v
v = "второе" # меняем ПОСЛЕ создания show
return show
print(f" значение менялось после создания функции, отдаётся: {make()()!r}")
def observe_local_trap() -> None:
"""Присваивание где угодно в теле делает имя локальным для ВСЕГО тела."""
header("3. Ловушка локального имени")
x = "из объемлющей" # noqa: F841 — нужна, чтобы показать, что её не видно
def trap_real():
try:
value = x
except UnboundLocalError as exc:
return f"UnboundLocalError: {exc}"
x = "локальная" # noqa: F841
return value
print(f" {trap_real()}")
# Решение принято КОМПИЛЯТОРОМ: имя лежит в co_varnames, а не в co_names.
print(f" co_varnames: {trap_real.__code__.co_varnames}")
def observe_class_skip() -> None:
"""Тело класса пропускается в цепочке областей (PEP 227)."""
header("4. Тело класса в цепочку не входит")
def make():
x = "из функции"
class C:
x = "из класса"
def get(self):
return x # берёт из ФУНКЦИИ, минуя одноимённый атрибут класса
return C
print(f" метод видит: {make()().get()!r}")
def make_fail():
class C:
y = "класс"
def get(self):
try:
return y
except NameError as exc:
return f"NameError: {exc}"
return C
print(f" имя из тела класса: {make_fail()().get()}")
def observe_bytecode() -> None:
"""Опкоды, которыми ячейка заводится и читается."""
header("5. Байткод")
src = "def outer():\n x = 1\n def inner():\n return x\n return inner\n"
module = compile(src, "<bench>", "exec")
outer = next(c for c in module.co_consts if getattr(c, "co_name", None) == "outer")
inner = next(c for c in outer.co_consts if getattr(c, "co_name", None) == "inner")
print(" внешняя:")
for ins in dis.get_instructions(outer):
print(f" {ins.opname:24s} {ins.argrepr}")
print(" внутренняя:")
for ins in dis.get_instructions(inner):
print(f" {ins.opname:24s} {ins.argrepr}")
ACCESS_COUNT = 200
def _body(indent: int) -> str:
pad = " " * indent
return pad + "t = " + " + ".join(["x"] * ACCESS_COUNT) + "\n" + pad + "return t\n"
def measure_access() -> None:
"""Сколько стоит обращение к локальной, к ячейке и к глобальной."""
header(f"6. Цена обращения к имени ({ACCESS_COUNT} обращений в одном вызове)")
setups = {
"LOAD_FAST (локальная)": "def f():\n x = 1\n" + _body(4),
"LOAD_DEREF (ячейка)": (
"def outer():\n x = 1\n def f():\n" + _body(8) + " return f\nf = outer()\n"
),
"LOAD_GLOBAL (глобальная)": "x = 1\ndef f():\n" + _body(4),
}
# min из девяти повторов, а не среднее: измеряется нижняя граница, на которую
# не влияют посторонние процессы. Среднее меряло бы заодно и соседей по машине.
def one_pass() -> dict[str, float]:
return {
name: min(timeit.repeat("f()", setup=setup, number=20_000, repeat=9)) / 20_000
for name, setup in setups.items()
}
# ЗАЧЕМ ВЕСЬ ЗАМЕР ПОВТОРЯЕТСЯ ЦЕЛИКОМ.
#
# Урок утверждает не только числа, но и то, КАК их читать: что ячейка и
# глобальная стоят «примерно одинаково». Такое утверждение держится лишь
# тогда, когда расстояние между этими двумя строками меньше, чем шатание
# каждой из них от повтора к повтору. Одиночный прогон этого не знает: он
# печатает три числа и молчит о том, воспроизводимы ли они. Поэтому весь
# замер повторяется OUTER раз, и рядом с кратностями печатается размах —
# чтобы читатель (и следующий правящий эту статью) видел прибор, а не
# только показание.
OUTER = 5
series: dict[str, list[float]] = {name: [] for name in setups}
for _ in range(OUTER):
for name, t in one_pass().items():
series[name].append(t)
times = {name: min(vals) for name, vals in series.items()}
base = times["LOAD_FAST (локальная)"]
for name, t in times.items():
per = t / ACCESS_COUNT * 1e9
print(f" {name:26s} {t * 1e6:6.2f} мкс x{t / base:.2f} {per:5.1f} нс на обращение")
print(f"\n РАЗМАХ ПО {OUTER} ПОВТОРАМ ВСЕГО ЗАМЕРА, в кратностях к локальной")
ratios = {name: sorted(v / base for v in vals) for name, vals in series.items()}
for name in setups:
lo, hi = ratios[name][0], ratios[name][-1]
print(f" {name:26s} x{lo:.2f} .. x{hi:.2f} размах {hi - lo:.2f}")
cell = ratios["LOAD_DEREF (ячейка)"]
glob = ratios["LOAD_GLOBAL (глобальная)"]
gap = abs(min(glob) - min(cell))
spread = max(cell[-1] - cell[0], glob[-1] - glob[0])
print(f"\n расстояние между ячейкой и глобальной {gap:.2f}")
print(f" наибольший размах этих же двух строк {spread:.2f}")
print(f" различима ли разница прибором {'да' if gap > spread else 'нет'}")
def main() -> None:
print(f"Python {sys.version.split()[0]}")
observe_cells()
observe_runtime_resolution()
observe_local_trap()
observe_class_skip()
observe_bytecode()
measure_access()
if __name__ == "__main__":
main()