ЗАМЕР
bench/defaults/cost.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/interview/python/mutable-default-arg
- Как запустить
python3.11 bench/defaults/where.py python3.12 bench/defaults/where.py python3.13 bench/defaults/where.py python3.14 bench/defaults/where.py python3.13 bench/defaults/cost.py python3.14 bench/defaults/cost.py
Запись прогона
Замеры для урока «Изменяемый аргумент по умолчанию»
| Скрипт | Что делает |
|---|---|
where.py |
наблюдения без времени: где лежит значение по умолчанию, когда оно вычисляется, запрет в датаклассах, единственное осмысленное применение |
cost.py |
цена: из чего складывается разница между сломанным и правильным вариантом, и кеш в аргументе против lru_cache |
python3.11 bench/defaults/where.py
python3.12 bench/defaults/where.py
python3.13 bench/defaults/where.py
python3.14 bench/defaults/where.py
python3.13 bench/defaults/cost.py
python3.14 bench/defaults/cost.py
Практика урока
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). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.
Что здесь важно прочитать правильно
where.py не меряет время. Он печатает содержимое __defaults__ и
счётчик вызовов — наблюдения, которые обязаны совпасть у любого, кто запустит
скрипт на той же версии. Совпали на 3.11, 3.12, 3.13 и 3.14.7.
Первый блок cost.py разбирает разницу на части, а не сравнивает два
варианта. Утверждение «правильный вариант медленнее» верно, и разница
складывается из двух измеримых кусков: создания нового списка и самой
проверки. Без такого разбора число «на 49 % дороже» звучит внушительно и
ничего не значит.
Второй блок опровергает ожидание, с которым он писался. Кеш в аргументе по
умолчанию считался быстрее lru_cache, потому что у него нет обёртки. На
замере он оказался медленнее в 1,43 раза.
Что получилось на 3.13.7:
| вызов | нс | что это |
|---|---|---|
required(given) |
25,65 | вызов без значения по умолчанию вовсе |
broken() |
32,67 | сломанный вариант: общий список |
fresh() |
44,04 | новый список, без проверки |
guard_none() |
48,78 | проверка на None + новый список |
guard_sentinel() |
56,85 | проверка на свой объект-метку + новый список |
guard_none(given) |
31,88 | проверка на None, аргумент передан |
guard_sentinel(given) |
35,46 | проверка на метку, аргумент передан |
Разложение: создание списка — 11,4 нс, проверка на None — 4,7 нс, проверка
на свой объект — ещё 8,1 нс сверх неё. Метка дороже None не из-за сравнения,
а из-за загрузки: None — константа кадра, метка лежит в глобальных именах.
| кеш | нс |
|---|---|
| словарь в аргументе по умолчанию, прогрет | 66,09 |
functools.lru_cache, прогрет |
46,26 |
От запуска к запуску отношение держится в пределах 1,36–1,43.
Наблюдения where.py:
- значение по умолчанию лежит в
__defaults__функции (у keyword-only — в__kwdefaults__) и раздаётся всем вызовам как один объект; - выражение по умолчанию вычисляется РОВНО ОДИН РАЗ, в момент
def; @dataclassс изменяемым значением по умолчанию падает при объявлении класса:ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory. Текст одинаков на 3.11–3.14.
Что этими числами доказать НЕЛЬЗЯ
Что выбор между None и своим объектом-меткой делается по скорости. Разница
8,1 нс, и она никогда не была причиной: метка нужна ровно там, где None —
допустимое значение аргумента, и там альтернативы у неё нет.
Скрипт
139 строк"""
Цена правильного варианта для урока «Изменяемый аргумент по умолчанию».
ЗАПУСК (числа урока сняты на 3.13.7):
python3.13 bench/defaults/cost.py
python3.14 bench/defaults/cost.py
ЧТО МОЖНО СРАВНИВАТЬ. Только строки внутри одного блока одного запуска.
Абсолютные наносекунды с другой машины несравнимы, между версиями — тоже (общее
правило замеров про тулчейн).
ЗАЧЕМ ЭТОТ ЗАМЕР. Единственный аргумент в защиту изменяемого значения по
умолчанию — «зато без проверки быстрее». Он верен и ничего не стоит: разница
между сломанным и правильным вариантом складывается из двух частей, и обе
измеримы по отдельности — создание нового списка и сама проверка.
ПОЧЕМУ ЗАМЕР БЕЗ ЦИКЛА. Тело — двести одинаковых вызовов подряд, отдельными
строками: внутри `for` разница утонула бы в цене итерации.
"""
import functools
import sys
import timeit
REPEATS_IN_BODY = 200
SENTINEL = object()
def header(title: str) -> None:
print(f"\n--- {title} ---")
def ns(stmt: str, env: dict, number: int = 20000) -> float:
body = (stmt + "\n") * REPEATS_IN_BODY
best = min(timeit.repeat(body, globals=env, number=number, repeat=7))
return best / (number * REPEATS_IN_BODY) * 1e9
def broken(value=[]): # noqa: B006 — предмет замера
return value
def required(value):
"""Без значения по умолчанию вовсе — нижняя граница цены вызова."""
return value
def fresh(value=None):
"""Новый список без всякой проверки: цена самого создания списка."""
return []
def guard_none(value=None):
if value is None:
value = []
return value
def guard_sentinel(value=SENTINEL):
if value is SENTINEL:
value = []
return value
def bench_guard() -> None:
header("1. Из чего складывается разница")
env = {
"broken": broken,
"required": required,
"fresh": fresh,
"guard_none": guard_none,
"guard_sentinel": guard_sentinel,
"given": [1],
}
rows = [
("required(given)", "вызов без значения по умолчанию"),
("broken()", "сломанный вариант: общий список"),
("fresh()", "новый список, без проверки"),
("guard_none()", "проверка на None + новый список"),
("guard_sentinel()", "проверка на свой объект + новый список"),
("guard_none(given)", "проверка на None, аргумент передан"),
("guard_sentinel(given)", "проверка на свой объект, аргумент передан"),
]
measured = {}
for stmt, label in rows:
value = ns(stmt, env)
measured[stmt] = value
print(f"{stmt:<24} {value:>7.2f} нс {label}")
print()
print(f"создание списка стоит {measured['fresh()'] - measured['broken()']:>5.1f} нс")
print(f"проверка на None — {measured['guard_none()'] - measured['fresh()']:>5.1f} нс")
print(
f"проверка на свой объект {measured['guard_sentinel()'] - measured['guard_none()']:>5.1f} нс сверх неё"
)
print()
print("Свой объект-метка дороже None не из-за сравнения, а из-за загрузки:")
print("None — константа кадра, а метка лежит в глобальных именах модуля.")
print("Нужна она там, где None — допустимое значение аргумента.")
def bench_cache() -> None:
header("2. Кеш в аргументе по умолчанию против lru_cache")
def fib_default(n: int, _cache={0: 0, 1: 1}): # noqa: B006
if n not in _cache:
_cache[n] = fib_default(n - 1) + fib_default(n - 2)
return _cache[n]
@functools.lru_cache(maxsize=None)
def fib_lru(n: int) -> int:
return n if n < 2 else fib_lru(n - 1) + fib_lru(n - 2)
# Оба кеша прогреты: меряется попадание, а не расчёт.
fib_default(200)
fib_lru(200)
env = {"fib_default": fib_default, "fib_lru": fib_lru}
a = ns("fib_default(100)", env)
b = ns("fib_lru(100)", env)
print(f"{'кеш в аргументе по умолчанию':<32} {a:>7.2f} нс")
print(f"{'прогретый lru_cache':<32} {b:>7.2f} нс")
print(f"\nlru_cache быстрее в {a / b:.2f} раза")
print("Ожидание было обратным: у lru_cache есть обёртка, а у кеша в")
print("аргументе её нет. Но обёртка написана на C и делает ОДИН поиск,")
print("а тело на Python — два (проверка и чтение) плюс сам вызов.")
def main() -> None:
print(f"Python {sys.version.split()[0]}")
bench_guard()
bench_cache()
if __name__ == "__main__":
main()