Deep Engineering

ЗАМЕР

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()