Deep Engineering

ЗАМЕР

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