Deep Engineering

MEASUREMENT

bench/args-kwargs/binding_rules.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/python/args-kwargs
How to run it
for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v cost.py; done

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «*args и **kwargs»

скрипт что показывает
identity.py *args — кортеж, **kwargs — словарь, и оба создаются даже при пустом вызове; что о звёздочках знает код функции (co_flags, co_kwonlyargcount, co_posonlyargcount)
swallowed_typo.py **kwargs, добавленный «для гибкости», превращает опечатку в имени аргумента из TypeError в молчаливо принятое значение по умолчанию
cost.py цена именованной передачи, распаковки кортежа, распаковки словаря, приёма звёздочками и обёрток-декораторов
wrapper_signature.py что обёртка делает с подписью функции и куда уезжает ошибка

Запускать на всех версиях, которые есть:

for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v cost.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). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.

Что здесь сравнивать можно, а что нельзя

Общее правило замеров: время между версиями не сравнивается вообще. Сравнивается только то, что измерено внутри одного запуска одного интерпретатора. Дело не только в компиляторе: 3.11 и 3.12 собраны GCC 13.3.0, 3.13.7 и 3.14.7 — Clang 20.1.4, но и эти две сборки различаются между собой, причём ровно тем флагом (--with-tail-call-interp), которому «Что нового в 3.14» приписывает «a geometric mean of 3-5% faster».

В cost.py все сравнения делаются внутри одного запуска одного интерпретатора: семь форм вызова одной и той же функции меряются подряд одним процессом. Такое сравнение честно всегда. Числа 3.13.7 и 3.14.7 приводятся рядом как два независимых результата, а не как сравнение.

identity.py, swallowed_typo.py и wrapper_signature.py от тулчейна не зависят вовсе: они смотрят на типы, флаги, подписи и текст исключений.

Разброс между запусками

cost.py — лучшее из семи прогонов. Три повторных запуска подряд на 3.13.7 дали 24,3 / 24,2 / 24,2 нс на позиционном вызове и 211 / 207 / 206 нс на трёх обёртках, то есть разброс около ±2 %. Все кратности, названные в уроке (×1,4 на именованной передаче, ×4 на распаковке словаря, ×8,5 на трёх обёртках), в этот разброс не укладываются — они настоящие.

Чего в cost.py НЕТ и почему

Нет строки plain(*ARGS, **KWARGS). Она передала бы два позиционных и два именованных аргумента в функцию с двумя параметрами, то есть упала бы с got multiple values for argument 'a'. Обе распаковки сразу происходят в строках про обёртки — там они и измерены, на вызове, который действительно работает.

Нет сравнения с functools.partial и с вызовом метода: и то и другое — другой механизм передачи, и их место в отдельном замере, а не в строке, которую прочтут как «звёздочки против непонятно чего».

Замеченное попутно: подсказка имени появилась в 3.13

connect("db", timeuot=5) у функции без **kwargs:

3.11.15 -> connect() got an unexpected keyword argument 'timeuot'
3.12.3  -> connect() got an unexpected keyword argument 'timeuot'
3.13.7  -> connect() got an unexpected keyword argument 'timeuot'. Did you mean 'timeout'?
3.14.7 -> то же, что 3.13.7

Это прямо относится к уроку: интерпретатор научился ловить ровно эту опечатку лучше, а функция, объявленная с **kwargs, эту помощь выбрасывает — и на 3.14 ведёт себя так же, как на 3.11. Проверено swallowed_typo.py и wrapper_signature.py.

Script

93 lines
"""Что `**kwargs` отключает, а что остаётся включённым.

ЗАЧЕМ ЭТОТ ФАЙЛ. Урок говорил: «`**kwargs` отключает проверку имён». Аудит
сузил: отключается ровно одно — отказ от НЕИЗВЕСТНЫХ ключевых имён, потому что
их теперь есть куда положить. Всё остальное связывание аргументов работает как
работало: обязательный параметр обязан быть передан, дважды переданный
параметр — ошибка, позиционно-только остаётся позиционно-только.

Разница практическая, а не терминологическая: «отключает проверку» звучит как
«обёртка с `**kwargs` проглотит любой вызов», и человек, который так понял,
ищет причину падения не там.

КАЖДАЯ СТРОКА — НАСТОЯЩИЙ ВЫЗОВ. Печатается результат или ТИП ошибки: тексты
сообщений между версиями меняются, поведение — нет.

ЗАПУСК: python3.13 bench/args-kwargs/binding_rules.py
Вывод по версиям: runs/binding_rules-3.11.txt и соседние.
"""

import inspect
import sys


def show(title: str) -> None:
    print()
    print(title)
    print("-" * len(title))


def call(label: str, fn, *args, **kwargs) -> None:
    try:
        print(f"  {label:<46} {fn(*args, **kwargs)}")
    except TypeError as exc:
        print(f"  {label:<46} {type(exc).__name__}")


def strict(a, b=2):
    return f"a={a} b={b}"


def loose(a, b=2, **kwargs):
    return f"a={a} b={b} kwargs={kwargs}"


def positional_only(a, /, b, *, c):
    return f"a={a} b={b} c={c}"


def positional_only_loose(a, /, b, *, c, **kwargs):
    return f"a={a} b={b} c={c} kwargs={kwargs}"


def main() -> None:
    print(f"Python {sys.version.split()[0]} ({sys.implementation.name})")

    show("1. То единственное, что меняет **kwargs: неизвестное имя")
    call("strict(a=1, unknown=9)", strict, a=1, unknown=9)
    call("loose(a=1, unknown=9)", loose, a=1, unknown=9)

    show("2. Обязательный параметр остаётся обязательным")
    call("strict()", strict)
    call("loose()", loose)
    call("loose(unknown=9)", loose, unknown=9)

    show("3. Дважды переданный параметр остаётся ошибкой")
    call("strict(1, a=2)", strict, 1, a=2)
    call("loose(1, a=2)", loose, 1, a=2)

    show("4. Позиционно-только и ключевое-только не сдвигаются")
    call("positional_only(a=1, b=2, c=3)", positional_only, a=1, b=2, c=3)
    call("positional_only_loose(a=1, b=2, c=3)", positional_only_loose, a=1, b=2, c=3)
    call("positional_only_loose(1, 2, 3)", positional_only_loose, 1, 2, 3)
    call("positional_only_loose(1, 2, c=3)", positional_only_loose, 1, 2, c=3)

    show("5. Куда попало имя, совпавшее с позиционно-только параметром")
    call("positional_only_loose(1, b=2, c=3, a=99)", positional_only_loose, 1, b=2, c=3, a=99)

    show("6. То же самое до вызова: inspect.Signature.bind")
    signature = inspect.signature(loose)
    for label, kwargs in [
        ("bind(a=1, unknown=9)", {"a": 1, "unknown": 9}),
        ("bind(unknown=9)", {"unknown": 9}),
    ]:
        try:
            bound = signature.bind(**kwargs)
            print(f"  {label:<46} {bound.arguments}")
        except TypeError as exc:
            print(f"  {label:<46} {type(exc).__name__}")


if __name__ == "__main__":
    main()