ЗАМЕР
bench/defaults/where.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 —
допустимое значение аргумента, и там альтернативы у неё нет.
Скрипт
148 строк"""
Наблюдения для урока «Изменяемый аргумент по умолчанию».
ЗАПУСК (наблюдения совпали на всех четырёх версиях):
for v in 3.11 3.12 3.13 3.14; do python$v bench/defaults/where.py; done
ЗДЕСЬ НЕТ ЗАМЕРОВ ВРЕМЕНИ. Скрипт показывает, ГДЕ лежит значение по умолчанию
и КОГДА оно вычисляется — это видно по атрибутам функции и по счётчику
вызовов, а не по секундомеру. Время меряется отдельно, в `cost.py`.
ПОЧЕМУ ЭТО ВАЖНЕЕ САМОГО ПРАВИЛА. Правило «не пиши изменяемый объект в
аргумент по умолчанию» запоминают все и всё равно нарушают, потому что оно
выглядит произвольным запретом. Оно перестаёт быть произвольным, как только
видно, что значение по умолчанию — это ПОЛЕ ФУНКЦИИ, `__defaults__`, и что оно
одно на все вызовы. Дальше ничего запоминать не надо: всё выводится.
"""
import sys
from dataclasses import dataclass, field
def header(title: str) -> None:
print(f"\n--- {title} ---")
def observe_where() -> None:
"""Значение по умолчанию — поле функции."""
header("1. Где лежит значение по умолчанию")
def accumulate(item, into=[]): # noqa: B006 — ровно то, что разбирается
into.append(item)
return into
print(f"до вызовов: accumulate.__defaults__ = {accumulate.__defaults__}")
first = accumulate(1)
second = accumulate(2)
print(f"первый вызов вернул: {first}")
print(f"второй вызов вернул: {second}")
print(f"после вызовов: accumulate.__defaults__ = {accumulate.__defaults__}")
print(f"возвращается тот же объект, что в __defaults__: {first is accumulate.__defaults__[0]}")
print(f"оба вызова вернули один объект: {first is second}")
print()
print("Никакой особой магии нет: список создан один раз, положен в поле")
print("функции и оттуда раздаётся. Функция — обычный объект, поле у неё")
print("обычное, и его даже можно переписать руками.")
def keyword_only(*, into=[]): # noqa: B006
return into
print(f"\nу keyword-only аргументов своё поле: __kwdefaults__ = {keyword_only.__kwdefaults__}")
def observe_when() -> None:
"""Значение вычисляется в момент def, а не при вызове."""
header("2. Когда вычисляется")
calls: list[int] = []
def expensive() -> int:
calls.append(1)
return len(calls)
def uses_default(value=expensive()):
return value
print(f"expensive() вызвана раз при объявлении функции: {len(calls)}")
print(f"три вызова uses_default(): {uses_default()}, {uses_default()}, {uses_default()}")
print(f"expensive() вызвана раз всего: {len(calls)}")
print()
print("Отсюда же вторая, менее известная ловушка того же механизма:")
print("def log(at=time.time()) заморозит время запуска модуля навсегда,")
print("а def f(x=CONST) возьмёт значение CONST на момент объявления —")
print("и не заметит, если CONST потом переприсвоят.")
def observe_dataclass() -> None:
"""Стандартная библиотека считает эту ошибку достаточно частой, чтобы её запретить."""
header("3. Датаклассы запрещают это прямо")
try:
@dataclass
class Bad:
items: list = []
except ValueError as exc:
print(f"ValueError: {exc}")
@dataclass
class Good:
items: list = field(default_factory=list)
first, second = Good(), Good()
print(f"с default_factory списки разные: {first.items is not second.items}")
# А вот граница этой проверки: она идёт по ХЕШИРУЕМОСТИ, а не по
# изменяемости. Свой класс с изменяемым состоянием хешируем — и проходит.
class Box:
def __init__(self) -> None:
self.items: list = []
@dataclass
class Sneaky:
box: Box = Box()
one, two = Sneaky(), Sneaky()
print(f"свой класс с изменяемым состоянием объявился без ошибки, общий объект: {one.box is two.box}")
print()
print("Проверка стоит в самом декораторе dataclass и срабатывает при")
print("ОБЪЯВЛЕНИИ класса — это единственное место в стандартной")
print("библиотеке, где ошибка ловится автоматически. Но идёт она по")
print("ХЕШИРУЕМОСТИ, а не по изменяемости: list, dict и set запрещены,")
print("а свой класс с изменяемым состоянием проходит молча.")
def observe_legit_use() -> None:
"""Единственное применение, ради которого приём иногда пишут нарочно."""
header("4. Когда это делают нарочно")
def fib(n: int, _cache={0: 0, 1: 1}): # noqa: B006
if n not in _cache:
_cache[n] = fib(n - 1) + fib(n - 2)
return _cache[n]
fib(30)
print(f"кеш пережил вызовы, в нём записей: {len(fib.__defaults__[0])}")
print()
print("Приём рабочий: словарь в __defaults__ — это состояние, живущее")
print("между вызовами. Но у него две беды: очистить кеш снаружи можно")
print("только через __defaults__, и любой вызывающий может подменить")
print("его вторым аргументом, случайно или нарочно. Третья беда —")
print("скорость, и она неожиданная: см. cost.py, блок 2.")
def main() -> None:
print(f"Python {sys.version.split()[0]}")
observe_where()
observe_when()
observe_dataclass()
observe_legit_use()
if __name__ == "__main__":
main()