Deep Engineering
Средний·Опубликовано·3.11 · 3.12 · 3.13 · 3.14·30 МИН

*args и **kwargs: две звёздочки, которые стоят по-разному — и одна из них делает неизвестные имена допустимыми

*args — кортеж, **kwargs — словарь, и на этом сходство кончается. На передаче звёздочка почти бесплатна, а две дороже вчетверо. А **kwargs, дописанный «для гибкости», превращает опечатку в имени аргумента из TypeError в молча принятое значение по умолчанию — и заодно выбрасывает подсказку, которой интерпретатор научился в 3.13.

Полное техническое изложение

TL;DR

Звёздочка делает противоположные вещи на двух сторонах вызова. В объявлении она собирает: одна — лишние позиционные аргументы в кортеж args, две — лишние именованные в словарь kwargs. В вызове она раскладывает: одна — последовательность в отдельные позиционные аргументы, две — словарь в отдельные именованные. Собранные объекты функция получает даже когда передавать нечего: при вызове без аргументов внутрь приходят () и {}, а не None.

Отсюда главное следствие: две звёздочки, дописанные в объявление «для гибкости», делают неизвестные имена именованных аргументов допустимыми для этой функции — их теперь есть куда положить. Вызов connect("db", timeuot=5) перестаёт быть TypeError и молча уходит в options, а timeout остаётся равным 30. Второе следствие — цена, и она у двух звёздочек разная: plain(*args) почти бесплатен (×1,1–1,3 от прямого вызова), plain(**kwargs) дороже вчетверо и больше (×4,0–5,2). Устойчиво на всех четырёх версиях; три слоя декораторов — ×8,5–11,9, то есть восемь девятых времени уходит не на работу, а на подход к ней.

Дальше — числа, версии и границы. () и {} — это семантика, а не утверждение о выделении памяти: пустой кортеж в CPython один на всех, и сколько при этом выделяется — вопрос к замеру ниже, а не к языку. Допустимыми становятся ровно неизвестные имена, а остальные правила связывания никуда не деваются — прогон bench/args-kwargs/binding_rules.py проверяет каждое настоящим вызовом:

strict(a=1, unknown=9)                         TypeError
loose(a=1, unknown=9)                          a=1 b=2 kwargs={'unknown': 9}
loose()                                        TypeError        ← обязательный параметр
loose(1, a=2)                                  TypeError        ← дважды переданный
positional_only_loose(a=1, b=2, c=3)           TypeError        ← имя вместо позиции

Последняя строка объясняет, почему / в объявлении — не украшение. А самый неожиданный случай прогон печатает отдельно: у функции def f(a, /, b, *, c, **kwargs) вызов f(1, b=2, c=3, a=99) не ошибка — a=99 уходит в kwargs, потому что позиционно-только параметр своё имя для ключевых аргументов не занимает.

Хуже того: в 3.13 интерпретатор научился подсказывать правильное имя — Did you mean 'timeout'? (Вы имели в виду 'timeout'?). Функция с **kwargs эту помощь выбрасывает и на 3.14 ведёт себя так же, как на 3.11.

/ из PEP 570 нужен не для красоты: пока параметр можно передать по имени, это имя занято, и ключ с таким же названием в **kwargs не попадёт. Так объявлен сам dict: его сигнатура — ($self, /, *args, **kwargs).

Порог входа
Перед уроком достаточно понимать
  • как объявляют функцию с параметрами и как её потом вызывают;
  • разницу между «передать по порядку» и «передать по имени»: f(1, 2) и f(a=1, b=2);
  • что список и словарь — это контейнеры, и словарь хранит пары «имя — значение».
Заранее знать не нужно
  • co_argcount, co_kwonlyargcount, CALL_FUNCTION_EX, BUILD_MAP, DICT_MERGE;
  • зачем в сигнатуре бывают / и голая *, functools.wraps, Unpack[TypedDict].

База: звёздочка на двух сторонах вызова

У звёздочки в Python несколько ролей, и все они пишутся одинаково — одним и тем же символом. Пока роли не разведены, разговор про *args и **kwargs ходит по кругу; разведём их сразу.

Различает роли сторона: там, где функцию объявляют, звёздочка значит одно, а там, где её вызывают, — противоположное.

В объявлении звёздочка собирает. Она берёт то, чему не нашлось отдельного параметра, и кладёт под одно имя:

  • одна звёздочка собирает лишние позиционные аргументы — те, что переданы по порядку;
  • две звёздочки собирают лишние именованные — те, что переданы по имени.

В вызове звёздочка раскладывает. Она берёт готовый контейнер и подставляет его содержимое как отдельные аргументы:

  • одна раскладывает последовательность в позиционные аргументы: f(*seq) — это то же самое, что f(seq[0], seq[1], …);
  • две раскладывают словарь в именованные: f(**mapping) — это f(имя=значение, …) по каждой паре.

Вот те же роли рядом, а последней строкой — запись, которая звёздочку использует совсем иначе:

записьгде стоитчто делает
def f(*args, **kwargs)объявлениеупаковывает лишние аргументы в кортеж и словарь
f(*seq)вызовраспаковывает последовательность в позиционные аргументы
f(**mapping)вызовраспаковывает отображение в именованные аргументы
def f(a, /, b, *, c)объявлениеничего не упаковывает: / и голая * только делят параметры на виды

Четыре разные операции, и путаница между первой и второй — самая частая: f(*args) внутри обёртки не создаёт кортеж, а разбирает уже созданный. Последняя строка к сборке отношения не имеет вовсе, и до неё мы дойдём отдельно.

И вот вопрос, на который отвечает остальной урок: чем за эту сборку платят? Записывается она одним символом и выглядит бесплатной — но у неё есть и цена во времени, и последствие, которое временем не измеряется вовсе.

Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Всё дальнейшее — про то, что собранное приходит кортежем и словарём, а не чем попало; что две звёздочки на передаче стоят вчетверо дороже одной; и что имя, которого у функции нет, перестаёт быть ошибкой, как только в объявлении появились две звёздочки.

Механизм 1: что это за объекты

контракт языкаГарантия языка: *args — кортеж, **kwargs — словарь, и правила связывания аргументов от версии не зависят.

Начнём с того, что проверяется одной функцией.

PYTHON
def collect(*args, **kwargs):
    return type(args).__name__, type(kwargs).__name__

collect(1, 2, x=3) возвращает ('tuple', 'dict'). Не список — кортеж, и это проверяется одной строкой: args.append(3) даёт AttributeError: 'tuple' object has no attribute 'append'. Изменяемого контейнера здесь не будет, сколько бы аргументов ни пришло.

Второе и менее очевидное: объекты создаются даже когда передавать нечего.

PYTHON
def probe(*args, **kwargs):
    return args, kwargs
 
probe()   # -> ((), {})

Не None, а пустой кортеж и пустой словарь. Справочник языка говорит именно так: *identifier is initialized to a tuple receiving any excess positional parameters, defaulting to the empty tuple (инициализируется кортежем, принимающим все лишние позиционные параметры, по умолчанию пустым кортежем). Поэтому if args: — правильная проверка, а if args is not None: — всегда истина.

Что о звёздочках знает сам код функции

PYTHON
def full(a, b=2, *args, c, d=4, **kwargs):
    pass

Объявление выглядит запутанным, но интерпретатор раскладывает его на три независимых числа и два флага:

полезначениечто означает
co_argcount2a и b — обычные: и позиционно, и по имени
co_posonlyargcount0до / ничего нет
co_kwonlyargcount2c и d — только по имени
CO_VARARGSTrueесть *args
CO_VARKEYWORDSTrueесть **kwargs

Отсюда простое следствие, которое снимает главную путаницу: c и d стали только-именованными не потому, что у них есть значения по умолчанию, а потому что они стоят после *args. Это правило из PEP 3102, и голая * в объявлении делает то же самое без сбора кортежа.

Порядок именованных аргументов сохраняется: order(z=1, a=2, m=3) даёт ключи в порядке ['z', 'a', 'm'], а не отсортированные.

Механизм 2: ошибка, которая не падает

Функция подключения. Три понятных параметра, и кто-то дописал четвёртый — «мало ли что понадобится прокинуть в драйвер».

PYTHON
def connect(host, port=5432, timeout=30, **options):
    ...

Через полгода в другом файле пишут вызов:

PYTHON
connect("db", timeuot=5)

Ошибки не будет. timeuot — законный ключ для **options, и функция получит timeout = 30, а пятёрка ляжет в options, куда никто не смотрит. Соединение установится, программа продолжит работу, и разница проявится однажды под нагрузкой — таймаутом, которого не ждали.

Без **options тот же вызов падает сразу, и падает информативно.

Почему это стало хуже, а не лучше

В 3.13 интерпретатор научился подсказывать правильное имя. «Что нового» говорит: The error message now tries to suggest the correct keyword argument when an incorrect keyword argument is passed to a function (Сообщение об ошибке теперь пытается подсказать правильный именованный аргумент, если в функцию передали неверный). Проверено на всех четырёх версиях:

версияconnect("db", timeuot=5) без **options
3.11.15got an unexpected keyword argument 'timeuot'
3.12.3то же
3.13.7... 'timeuot'. Did you mean 'timeout'?
3.14.7то же, что 3.13.7

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

Чем это лечится

Не внимательностью. Если **kwargs действительно нужен — проверкой неизвестных ключей там же, где они принимаются:

PYTHON
KNOWN = {"sslmode", "application_name"}
 
def connect(host, port=5432, timeout=30, **options):
    unknown = set(options) - KNOWN
    if unknown:
        raise TypeError(f"неизвестные параметры: {', '.join(sorted(unknown))}")

Четыре строки возвращают отказ по неизвестному имени — тот самый, который перестал возникать сам, как только имена стали допустимыми. А если **kwargs нужен не был — его отсутствие и есть проверка, причём бесплатная.

Механизм 3: / и голая * — зачем занимать имена

Две вещи из PEP 570 и PEP 3102, у которых есть измеримое следствие, а не только косметическое.

Голая * запрещает передавать всё, что после неё, позиционно:

PYTHON
def open_conn(host, *, timeout=30, retries=3):
    ...

Теперь open_conn("db", 5) — ошибка, а не таймаут в пять секунд, попавший в timeout по счёту. Порядок параметров перестаёт быть частью договора, и его можно менять, не ломая вызывающих.

Слеш / делает обратное — запрещает передавать по имени. И нужен он не для симметрии, а по причине, которую видно только на запуске:

PYTHON
def name_taken(name, **kwargs):
    return name, kwargs
 
def name_free(name, /, **kwargs):
    return name, kwargs
вызоврезультат
name_taken("a", name="b")TypeError: got multiple values for argument 'name'
name_free("a", name="b")('a', {'name': 'b'})

Пока параметр можно передать по имени, это имя занято: ключ с таким же названием в **kwargs попасть не может. PEP 570 называет ровно этот случай: A key scenario is when a function accepts any keyword argument but can also accepts a positional one (Ключевой случай — когда функция принимает любой именованный аргумент, но при этом принимает и позиционный).

Так объявлен и сам dict. Его сигнатура видна прямо из интерпретатора:

PYTHON
dict.__init__.__text_signature__   # ($self, /, *args, **kwargs)
dict(self="x")                     # {'self': 'x'}

Слеш освобождает имя self — без него dict(self="x") давало бы got multiple values for argument 'self'.

Механизм 4: что обёртка делает с сигнатурой

У *args, **kwargs в декораторе есть побочный эффект, которого не видно в коде обёртки.

PYTHON
def wrap(fn):
    def inner(*args, **kwargs):
        return fn(*args, **kwargs)
    return inner

Здесь wrap — декоратор, а inner — обёртка, которую он возвращает и которая встаёт на место исходной функции. Сигнатуру теряет именно обёртка: у декоратора wrap она своя и никуда не девается.

inspect.signature у такой обёртки показывает (*args, **kwargs) — имена параметров исчезли, а вместе с ними подсказки редактора и половина пользы от проверки типов. Лечится это @functools.wraps(fn), и после него сигнатура показывается исходная: (host, port=5432, timeout=30).

Но она не описывает то, что обёртка готова принять. Проверено: обёртка пропускает и пять позиционных аргументов, и любое имя — не проверив ничего. Падает вызов уже внутри: connect() takes from 1 to 3 positional arguments but 5 were given. @wraps возвращает сигнатуру для инструментов, но не заставляет обёртку ей следовать.

Видно это и в трассировке: кадров на один больше, чем слоёв, — свой кадр вызова плюс по кадру на каждую обёртку.

как вызванокадров в трассировке
напрямую1
через одну обёртку2
через три4

Само сообщение всегда называет внутреннюю функцию — @wraps скопировал ей имя, и различить их по тексту нельзя. Различаются кадры: без @wraps в трассировке подряд стоят три inner, и до строки с опечаткой читать три чужих кадра.

Механизм 5: типизация — чем вернуть стёртые имена

Это единственный автоматический способ вернуть то, что **kwargs стирает.

До 3.12 аннотировать **kwargs можно было только одним типом на все значения. PEP 692 называет это ограничение прямо: Currently **kwargs can be type hinted as long as all of the keyword arguments specified by them are of the same type. However, that behaviour can be very limiting (Сейчас **kwargs можно аннотировать, только если все передаваемые через них именованные аргументы одного типа. Но такое поведение может сильно ограничивать). С 3.12 появился Unpack[TypedDict]:

PYTHON
class Options(TypedDict, total=False):
    sslmode: str
    application_name: str
 
def connect(host: str, **options: Unpack[Options]) -> None:
    ...

Теперь проверяющий типы — mypy, pyright — снова видит имена и ловит timeuot=5 до запуска: error: Unexpected keyword argument "timeuot". На сам вызов это не влияет: интерпретатор по-прежнему принимает его молча — для него это имя допустимо. Отказ по неизвестному имени возвращается снаружи, до запуска, раз внутри вызова его больше нет.

Глубже: сколько стоят звёздочки

наблюдение замераbench/args-kwargs/cost.py, CPython 3.11–3.14 на одной машине. Абсолютные наносекунды зависят от машины; содержательна кратность.

Семь форм вызова, измеренные подряд одним процессом: четыре — способы передать plain два числа, три — то же самое через функцию со звёздочками и через обёртки.

Главное здесь — асимметрия. Две звёздочки пишутся рядом, называются одним дыханием и стоят совершенно по-разному:

  • plain(*args) — ×1,1–1,3 от прямого позиционного вызова. Практически бесплатно.
  • plain(**kwargs) — ×4,0–5,2. Вчетверо и больше.

Причина видна в байт-коде, и она конкретнее, чем «словарь сложнее кортежа». Одинаково на всех четырёх версиях:

plain(*ARGS)      LOAD_GLOBAL LOAD_GLOBAL CALL_FUNCTION_EX
plain(**KWARGS)   LOAD_GLOBAL LOAD_CONST BUILD_MAP LOAD_GLOBAL DICT_MERGE CALL_FUNCTION_EX

На стороне вызова кортеж уходит как есть: CALL_FUNCTION_EX получает готовый объект. Внутри функция с *args соберёт уже свой кортеж — g(*T) is T даёт False, — но это происходит и при обычном вызове. А перед вызовом со словарём интерпретатор создаёт новый словарь (BUILD_MAP) и копирует в него переданный (DICT_MERGE) — то есть словарь копируется на каждом вызове, ещё до того, как начнётся сопоставление имён с параметрами. Отсюда и четырёхкратная разница.

Именованная передача устроена иначе и стоит дешевле: она компилируется не в CALL_FUNCTION_EX, а в CALL_KW с готовым кортежем имён в константах — словаря не создаётся вовсе. Отсюда и разрыв: ×1,3–1,9 против ×4 с лишним у plain(**kwargs). Это не довод против именованных аргументов — читаемость дороже десятка наносекунд (на 3.13.7 разница вышла 11,3 нс), — но довод против того, чтобы считать их бесплатными в горячем цикле.

Обёртки складываются

Декоратор на *args, **kwargs делает обе работы сразу: принимает звёздочками (собирает кортеж и словарь) и передаёт звёздочками (разбирает их обратно).

сколько слоёвво сколько раз дороже прямого вызова
один×3,6–4,6
три×8,5–11,9

На трёх слоях восемь девятых времени уходит не на работу, а на подход к ней. Это не аргумент против декораторов — это аргумент за то, чтобы знать, где именно они стоят дорого: в обработчике, который ходит в базу, эти сотни наносекунд не видны совсем; в функции, вызываемой миллион раз за проход, они становятся заметной долей.

История версий

ВерсияИзменениеЧто это значит для кода
3.0PEP 3102 даёт голую * и правило: всё после неё передаётся только по имени. Мотив записан прямо — функция с переменным числом аргументов, у которой есть ещё и «опции»; до этого их доставали из **kwargs руками. Отсюда же co_kwonlyargcount — отдельное поле в коде функции.
3.8PEP 570 даёт /. Нужен он не для симметрии: пока параметр передаётся по имени, это имя занято и ключ с таким же названием в **kwargs не попадёт. Проверено запуском: name_taken("a", name="b")TypeError, name_free("a", name="b") — работает.
3.12PEP 692 разрешил Unpack[TypedDict] для **kwargs: проверяющий снова видит имена и типы. До этого все значения обязаны были иметь один тип — that behaviour can be very limiting (такое поведение может сильно ограничивать). Само имя Unpack появилось раньше, в 3.11 (PEP 646), поэтому такую аннотацию принимают и на более старом целевом уровне.
3.13Интерпретатор подсказывает правильное имя: Did you mean 'timeout'? (Вы имели в виду 'timeout'?). Проверено на всех четырёх версиях — на 3.11 и 3.12 подсказки нет. Важная оговорка: функцию с **kwargs это не касается вовсе, потому что у неё ошибки не возникает.

Как отвечать на собеседовании

Короткий ответ: звёздочка в объявлении собирает, звёздочка в вызове раскладывает. Одна собирает лишние позиционные аргументы в кортеж args, две — лишние именованные в словарь kwargs; в вызове одна раскладывает последовательность в позиционные аргументы, две — словарь в именованные. И оба объекта создаются даже когда передавать нечего: приходят () и {}, а не None.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

Первое — цена, и она у двух звёздочек разная: plain(*args) почти бесплатен, а plain(**kwargs) дороже прямого вызова вчетверо и больше, устойчиво на всех четырёх версиях.

Что отличает хороший ответ: назвать не цену, а то, что **kwargs, дописанный «для гибкости», делает неизвестные имена именованных аргументов допустимыми для этой функции. Вызов connect("db", timeuot=5) перестаёт быть TypeError и молча уходит в options, а timeout остаётся равным 30. Хуже того, с 3.13 интерпретатор умеет подсказывать правильное имя — и функция с **kwargs эту помощь выбрасывает, ведя себя на 3.14 так же, как на 3.11.

Дальше спросят

Спросят дальше

Обёртка стоит с @functools.wraps, и inspect.signature показывает исходную сигнатуру. Значит, обёртка примет ровно то, что примет цель?

Короткий ответ

Нет. wraps возвращает сигнатуру инструментам, но не заставляет обёртку ей следовать: обёртка пропускает и пять позиционных аргументов, и любое имя, ничего не проверив. Падает уже вызов цели внутри — connect() takes from 1 to 3 positional arguments but 5 were given, — и в трассировке это видно по лишним кадрам: их на один больше, чем слоёв.

Спросят дальше

Зачем в сигнатуре голая * и слеш /, если о порядке аргументов можно просто договориться?

Короткий ответ

Голая * запрещает передавать позиционно всё, что после неё: порядок параметров перестаёт быть частью договора, и его можно менять, не ломая вызывающих. Слеш делает обратное и нужен там, где функция принимает **kwargs: пока параметр можно передать по имени, это имя занято, и ключ с таким же названием в **kwargs не попадёт. Так объявлен сам dict($self, /, *args, **kwargs), — и поэтому dict(self="x") работает.

Частые заблуждения

Утверждение

*args — это список

На самом деле

Кортеж. type(args).__name__ даёт tuple на всех четырёх версиях. Менять его нельзя — и это не придирка: он собирается заново на каждом вызове, и изменяемость означала бы, что кто-то может сохранить ссылку и переписать чужие аргументы.

Утверждение

при вызове без аргументов args и kwargs равны None

На самом деле

Пустой кортеж и пустой словарь. Справочник языка: *identifier is initialized to a tuple… defaulting to the empty tuple (инициализируется кортежем… по умолчанию пустым кортежем). Поэтому проверка if args: работает, а if args is not None: истинна всегда — и обе выглядят одинаково правдоподобно.

Утверждение

звёздочки — это просто запись, работы за ними нет

На самом деле

Работа есть, и разная у двух звёздочек. plain(*args) — ×1,1–1,3 от прямого вызова, plain(**kwargs) — ×4,0–5,2. Причина видна в байт-коде на всех четырёх версиях: кортеж уходит в вызов как есть, а перед вызовом со словарём выполняются BUILD_MAP и DICT_MERGE — то есть словарь копируется на каждом вызове.

Утверждение

**kwargs добавляет гибкости и ничего не отнимает

На самом деле

Отнимает отказ по неизвестному имени: с двумя звёздочками неизвестные имена становятся для функции допустимыми. connect("db", timeuot=5) без **kwargsTypeError, с ним — молча принятый вызов, где timeout остался равен 30. И подсказка Did you mean 'timeout'? (Вы имели в виду 'timeout'?), которой интерпретатор научился в 3.13, тоже не сработает: ошибки нет, привязывать подсказку не к чему.

Утверждение

functools.wraps чинит сигнатуру обёртки

На самом деле

Он возвращает сигнатуру ДЛЯ ИНСТРУМЕНТОВ, но не делает её правдой. inspect.signature после @wraps показывает (host, port=5432, timeout=30), а сама обёртка спокойно принимает пять позиционных аргументов и любое имя — исключение бросает внутренняя функция, и в трассировке появляется лишний кадр на каждый слой.

Утверждение

c и d в def f(a, *args, c, d=4) можно передать позиционно

На самом деле

Они только-именованные, и не из-за значений по умолчанию, а из-за позиции: всё после *args передаётся только по имени (PEP 3102). Видно в коде функции: co_kwonlyargcount равен двум. У c значения по умолчанию нет вовсе, и он всё равно только-именованный.

Утверждение

слеш в объявлении — украшение из документации

На самом деле

Без него имя параметра занято навсегда: ключ с таким же названием в **kwargs не попадёт, вызов даст got multiple values for argument. Именно поэтому dict объявлен со слешем: его сигнатура — ($self, /, *args, **kwargs), и dict(self="x") даёт {'self': 'x'}. Второй довод из PEP 570 — имена параметров перестают быть частью публичного договора и их можно менять.

Утверждение

аннотировать **kwargs нельзя

На самом деле

До 3.12 можно было, но одним типом на все значения — PEP 692 называет это «very limiting». С 3.12 есть Unpack[TypedDict], и он возвращает проверяющему имена и типы. Это единственный автоматический способ поймать timeuot=5 до запуска, когда **kwargs убрать нельзя.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

У функции есть **options, дописанный «для гибкости». Второй вызов содержит опечатку в имени аргумента. Что напечатает этот код?
def connect(dsn, timeout=30, **options):
  return timeout, options


print(connect("db", timeout=5))
print(connect("db", timeuot=5))

Практика · оцените

Одна и та же функция от трёх аргументов: прямой вызов и вызов через **kwargs. Во сколько раз дороже второй?
раза

Проверка знаний

Вопрос 1 из 5

Что вернёт probe() для def probe(*args, **kwargs): return args, kwargs?

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. Справочник языка — вызовы и определения функцийОфициальная документация. Грамматика, из которой следует порядок частей объявления и правило «If the form `*identifier` is present, it is initialized to a tuple receiving any excess positional parameters, defaulting to the empty tuple» (Если присутствует форма `*identifier`, она инициализируется кортежем, принимающим все лишние позиционные параметры; по умолчанию — пустым кортежем). Там же про словарь: «If the form `**identifier` is present, it is initialized to a new ordered mapping receiving any excess keyword arguments, defaulting to a new empty mapping of the same type» (Если присутствует форма `**identifier`, она инициализируется новым упорядоченным отображением, принимающим все лишние именованные аргументы; по умолчанию — новым пустым отображением того же типа). Проверено запуском: при вызове без аргументов приходят `()` и `{}`, а не `None`.https://docs.python.org/3.14/reference/compound_stmts.html#function-definitions
  2. PEP 3102 — Keyword-Only ArgumentsPEP. Talin, Final, Python 3.0. Отсюда голая `*` в объявлении и правило, что всё после неё передаётся только по имени. Мотив назван прямо: «One can easily envision a function which takes a variable number of arguments, but also takes one or more 'options' in the form of keyword arguments» (Легко представить функцию, которая принимает переменное число аргументов и вдобавок одну или несколько „опций“ в виде именованных аргументов) — до этого такие «опции» приходилось доставать из `**kwargs` руками.https://peps.python.org/pep-3102/
  3. PEP 570 — Python Positional-Only ParametersPEP. Ларри Хастингс, Пабло Галиндо Сальгадо, Марио Корчеро, Эрик Вандер Виле; Final, Python 3.8. Две причины для `/`, и вторая проверяется запуском в уроке: «Without the ability to specify which parameters are positional-only, library authors must be careful when choosing appropriate parameter names» (Без возможности объявить параметры только-позиционными авторам библиотек приходится осторожничать с выбором имён параметров) и «A key scenario is when a function accepts any keyword argument but can also accepts a positional one» (Ключевой случай — когда функция принимает любой именованный аргумент, но при этом принимает и позиционный). Проверено запуском: `dict.__init__.__text_signature__` — `($self, /, *args, **kwargs)`, и слеш освобождает имя `self`, поэтому `dict(self="x")` даёт `{'self': 'x'}`.https://peps.python.org/pep-0570/
  4. PEP 692 — Using TypedDict for more precise **kwargs typingPEP. Франек Магера, Final, Python 3.12. Названо ограничение, которое до этого делало типизацию `**kwargs` почти бесполезной: «Currently **kwargs can be type hinted as long as all of the keyword arguments specified by them are of the same type. However, that behaviour can be very limiting» (Сейчас **kwargs можно аннотировать, только если все передаваемые через них именованные аргументы одного типа. Но такое поведение может сильно ограничивать). `Unpack[TypedDict]` — единственный способ вернуть проверяющему имена и типы, которые `**kwargs` стирает.https://peps.python.org/pep-0692/
  5. Что нового в Python 3.13 — улучшенные сообщения об ошибкахОфициальная документация. Изменение, прямо относящееся к теме урока: «The error message now tries to suggest the correct keyword argument when an incorrect keyword argument is passed to a function» (Сообщение об ошибке теперь пытается подсказать правильный именованный аргумент, если в функцию передали неверный), с примером `split() got an unexpected keyword argument 'max_split'. Did you mean 'maxsplit'?`. Проверено на всех четырёх версиях: на 3.11 и 3.12 подсказки нет, на 3.13 и 3.14 есть — и функция с `**kwargs` не получает её ни на одной.https://docs.python.org/3.14/whatsnew/3.13.html
  6. functools — wraps и update_wrapperОфициальная документация. Источник поведения, из-за которого сигнатура обёртки лжёт по-разному в зависимости от одной строки. Без `@wraps` обёртка показывает `(*args, **kwargs)`; с ним `inspect.signature` находит `__wrapped__` и показывает сигнатуру исходной функции — ту, которой обёртка не следует. Проверено обоими способами в bench/args-kwargs/wrapper_signature.py.https://docs.python.org/3.14/library/functools.html#functools.wraps