Deep Engineering

MEASUREMENT

bench/args-kwargs/wrapper_signature.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

76 lines
"""Что обёртка на `*args, **kwargs` делает с подписью функции и с ошибкой.

Обёртка принимает что угодно — в этом её смысл. Отсюда два следствия, о
которых обычно не думают, и оба проверяются запуском:

  1. ЧТО ВИДЯТ ИНСТРУМЕНТЫ. Без `functools.wraps` подпись обёрнутой функции
     превращается в `(*args, **kwargs)`, и подсказки редактора, справка и
     проверка типов теряют имена параметров. С `wraps` подпись возвращается —
     но она уже не описывает то, что обёртка на самом деле готова принять.

  2. ГДЕ ВОЗНИКАЕТ ОШИБКА. Неверное имя аргумента обёртка пропускает молча
     (ей всё годится) — исключение бросает уже внутренняя функция. В
     трассировке появляется лишний кадр, а на нескольких обёртках — несколько.

От тулчейна ничего не зависит: смотрим подписи, имена и текст исключения.
"""
import functools
import inspect
import sys
import traceback


def connect(host, port=5432, timeout=30):
    return host, port, timeout


def bare(fn):
    def inner(*args, **kwargs):
        return fn(*args, **kwargs)

    return inner


def wrapped(fn):
    @functools.wraps(fn)
    def inner(*args, **kwargs):
        return fn(*args, **kwargs)

    return inner


no_wraps = bare(connect)
with_wraps = wrapped(connect)
three_deep = wrapped(wrapped(wrapped(connect)))

print("PY", sys.version.split()[0])

print("  что показывает inspect.signature:")
for label, fn in (("исходная", connect), ("обёртка без wraps", no_wraps), ("обёртка с wraps", with_wraps)):
    print(f"    {label:<20} {fn.__name__:<10} {inspect.signature(fn)}")

print("  но принимает обёртка что угодно:")
print(f"    with_wraps(1, 2, 3, 4, 5, что_угодно=6) -> ", end="")
try:
    with_wraps(1, 2, 3, 4, 5, что_угодно=6)
    print("прошло")
except TypeError as exc:
    print(f"TypeError от ВНУТРЕННЕЙ функции: {exc}")

print("  откуда прилетает ошибка при опечатке в имени:")
for label, fn in (("напрямую", connect), ("через одну обёртку", with_wraps), ("через три", three_deep)):
    try:
        fn("db", timeuot=5)
    except TypeError as exc:
        frames = traceback.extract_tb(exc.__traceback__)
        # Кадр 0 — эта строка; остальные — обёртки и сама функция.
        print(f"    {label:<20} кадров в трассировке: {len(frames)}")
        print(f"    {'':<20} {exc}")

print("  подсказка про правильное имя (появилась в 3.13):")
try:
    connect("db", timeuot=5)
except TypeError as exc:
    has_hint = "Did you mean" in str(exc)
    print(f"    интерпретатор подсказывает имя: {has_hint}")