Когда я пытаюсь обойтись без *args и **kwargs в сигнатурах функций, это не всегда можно сделать, не вредя удобству использования API. Особенно — когда надо писать функции, которые обращаются к вспомогательным функциям с одинаковыми сигнатурами.

Типизация *args и **kwargs всегда меня расстраивала, так как их нельзя было заблаговременно снабдить точными аннотациями. Например, если и позиционные, и именованные аргументы функции могут содержать лишь значения одинаковых типов, можно было поступить так:

def foo(*args: int, **kwargs: bool) -> None:
    ...

Применение такой конструкции указывает на то, что args — это кортеж, все элементы которого являются целыми числами, а kwargs — это словарь, ключи которого являются строками, а значения имеют логический тип.

Но нельзя было адекватно аннотировать *args и **kwargs в ситуации, когда значения, которые можно передавать в качестве позиционных и именованных аргументов, могут, в разных обстоятельствах, относиться к различным типам. В таких случаях приходилось прибегать к Any, что противоречило цели типизации аргументов функции.

Взгляните на следующий пример:

def foo(*args: tuple[int, str], **kwargs: dict[str, bool | None]) -> None:
    ...

Тут система проверки типов воспринимает каждый из позиционных аргументов в виде кортежа из целого числа и строки. Кроме того, она считает каждый именованный аргумент словарём, ключи которого являются строками, а значения — либо сущностями логического типа, либо объектами None.

При использовании вышеописанных аннотаций mypy не пропустит следующий код:

foo(*(1, "hello"), **{"key1": 1, "key2": False})
error: Argument 1 to "foo" has incompatible type "*tuple[int, str]";
expected "tuple[int, str]"  [arg-type]

error: Argument 2 to "foo" has incompatible type "**dict[str, int]";
expected "dict[str, bool | None]"  [arg-type]

А вот такое будет признано нормальным:

foo((1, "hello"), kw1={"key1": 1, "key2": False})

Программисту, вероятно, хотелось бы воспользоваться первым вариантом кода, а вот системе проверки типов нужен второй его вариант.

Для того чтобы правильно аннотировать второй пример — нужно прибегнуть к инструментам из PEP-589, PEP-646, PEP-655, и PEP-692. А именно, мы воспользуемся Unpack и TypedDict из модуля typing. Вот как это сделать:

from typing import TypedDict, Unpack  # Python 3.12+

# from typing_extensions import TypedDict, Unpack # < Python 3.12


class Kw(TypedDict):
    key1: int
    key2: bool


def foo(*args: Unpack[tuple[int, str]], **kwargs: Unpack[Kw]) -> None:
    ...


args = (1, "hello")
kwargs: Kw = {"key1": 1, "key2": False}

foo(*args, **kwargs)  # Ok

Тип TypedDict появился в Python 3.8. Он позволяет аннотировать словари, поддерживающие значения различных типов. Если все значения словаря имеют один и тот же тип — для его аннотирования можно просто воспользоваться конструкцией dict[str, T]. А вот тип TypedDict ориентирован на ситуации, когда все ключи словаря являются строками, а значения могут иметь различные типы.

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

from typing import TypedDict


class Movie(TypedDict):
    name: str
    year: int


movies: Movie = {"name": "Mad Max", "year": 2015}

С помощью оператора Unpack можно показать, что объекты являются распакованными.

Использование TypedDict с Unpack позволяет указать системе проверки типов на то, что ей не надо допускать ошибку, считая каждый позиционный и именованный аргументы, соответственно, кортежем и словарём.

Система проверки типов не возражает, когда *args и **kwargs передают в таком виде:

foo(*args, **kwargs)

Но её не устраивает, когда передаются не все именованные аргументы:

foo(*args, key1=1)  # error: Missing named argument "key2" for "foo"

Для того чтобы сделать все именованные аргументы необязательными, можно отключить флаг total в определении класса, в котором используется TypedDict:

# ...
class Kw(TypedDict, total=False):
    key1: int
    key2: str


# ...

Или можно, воспользовавшись typing.NotRequired, указать на необязательность отдельных именованных аргументов:

# ...
class Kw(TypedDict):
    key1: int
    key2: NotRequired[str]


# ...

Это позволит передавать неполные наборы необязательных именованных аргументов, не вызывая при этом недовольство у системы проверки типов.

Вот и всё!

О, а приходите к нам работать? ???? ????

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде

Комментарии (2)


  1. ko_0n
    15.01.2024 22:12

    А что mypy скажет в случае передачи key3? Вопрос риторический. В этом примере вообще нет пользы от TypedDict, ведь можно просто явно передать ключи и их типы. Можно было упомянуть namedtuple. И да, я понимаю, что это перевод, но это какой-то обрубок.


  1. omaxx
    15.01.2024 22:12

    Может быть пример

    class Kw(TypedDict):
        key1: int
        key2: bool
    
    
    def foo(*args: Unpack[tuple[int, str]], **kwargs: Unpack[Kw]) -> None:
        ...

    и показывает как можно аннотировать функцию, но он точно не отвечает на вопрос, а зачем так писать, когда можно записать проще:

    def foo(arg1: int, arg2: str, /, kwarg1: int, kwarg2: bool):
        ...