2025, Nov 24 21:01
Protocol против Callable: типизация позиционных и именованных параметров в Python
Почему Callable ограничен позиционными аргументами и как задать сигнатуру через Protocol с __call__ для только именованных и позиционных параметров в Python.
Аннотировать вызываемые объекты с только позиционными и только именованными параметрами в Python неожиданно непросто. Часто предполагают, что одного Callable хватает, чтобы полностью описать сигнатуру функции, но это перестаёт работать, когда нужно зафиксировать виды аргументов. Разберёмся, что происходит на самом деле и как правильно типизировать такие функции.
Проблема
Предположим, у вас есть вызываемый объект, который совмещает только позиционные и только именованные параметры, и вы хотите аннотировать переменную, чтобы она его принимала. Первая мысль — воспользоваться Callable, но для такой формы API это не сработает.
from typing import Callable
def join_vals(num: int, /, *, suffix: str) -> str:
return f"{num}{suffix}"
fn_ref: Callable[[int, str], str] = join_vals
# Ошибка проверяющего типов:
# `(num: int, /, *, suffix: str) -> str` не совместима с `(int, str) -> str`
Почему это не работает
Callable моделирует только позиционные параметры. Он не умеет выражать только именованные аргументы, маркеры только позиционных параметров, вариативные параметры или значения по умолчанию. Такой дизайн явно зафиксирован в спецификации typing и делает Callable неподходящим инструментом, когда нужно контролировать виды аргументов за пределами позиционного случая.
Параметры, заданные через
Callable, считаются только позиционными. Форма Callable не позволяет указывать только именованные параметры, вариативные параметры или значения аргументов по умолчанию. Для этих случаев смотрите раздел о протоколах обратных вызовов.
Из этого следует важное следствие для присваиваемости. Функцию со стандартными параметрами, которую можно вызывать и позиционно, и по имени, можно присвоить многим типам callable — она гибкая на месте вызова. Напротив, вызываемый объект, который строго требует только именованные или только позиционные аргументы, можно присвоить лишь типу, точно соответствующему его соглашению вызова. Поэтому пример выше и завершается ошибкой.
Решение
Чтобы типизировать вызываемые объекты с только позиционными или только именованными параметрами, используйте Protocol с методом __call__, в котором зафиксируйте нужную сигнатуру. Callable остаётся полезным для чисто позиционных сигнатур, но как только вид аргументов имеет значение, правильный инструмент — Protocol.
from typing import Callable, Protocol
class AnyParams(Protocol):
def __call__(self, v: int) -> None: ...
class OnlyKw(Protocol):
def __call__(self, *, v: int) -> None: ...
class OnlyPos(Protocol):
def __call__(self, v: int, /) -> None: ...
FnSig = Callable[[int], None]
def showcase(p_any: AnyParams, p_kw: OnlyKw, p_call: FnSig, p_pos: OnlyPos):
# Функцию со стандартными параметрами можно присваивать широко
a1: OnlyKw = p_any # ОК
a2: FnSig = p_any # ОК
a3: OnlyPos = p_any # ОК
# Функции с только именованными параметрами требуют соответствующих типов с только именованными параметрами
b1: AnyParams = p_kw # ошибка
b2: FnSig = p_kw # ошибка
b3: OnlyPos = p_kw # ошибка
# Callable[[...]] соответствует формам с только позиционными параметрами
c1: AnyParams = p_call # ошибка
c2: OnlyKw = p_call # ошибка
c3: OnlyPos = p_call # ОК — эквивалентно
d1: AnyParams = p_pos # ошибка
d2: OnlyKw = p_pos # ошибка
d3: FnSig = p_pos # ОК — эквивалентно
Такой приём позволяет точно задать, что обязан принимать колбэк, включая требование только именованных или только позиционных параметров, и даёт проверяющим типы инструмент для корректной валидации присваиваний и вызовов.
Почему это важно
API нередко используют только именованные параметры, чтобы вызовы были самодокументируемыми и чтобы избежать случайной перестановки аргументов. Только позиционные параметры встречаются в публичных интерфейсах, где имена не считаются частью контракта. Если типы не закрепляют эти соглашения о вызове, нарушения проявятся лишь во время исполнения. Аннотации на основе Protocol ловят такие несоответствия на этапе статического анализа — именно там их и хочется обнаруживать.
Не существует такой комбинации аргументов Callable, которая выражала бы только именованные параметры, поэтому полагаться лишь на Callable в подобных случаях — значит незаметно ослабить контракт. Подход с отдельным Protocol сохраняет и читаемость, и корректность.
Выводы
Если нужно типизировать вызываемый объект с только именованными или только позиционными аргументами, опишите сигнатуру через Protocol и его __call__. Используйте Callable, когда параметры чисто позиционные и вид аргументов не важен. Эта простая граница избавляет от путаных ошибок, лучше передаёт замысел вашего API и даёт проверяющим типы всю необходимую информацию.