2025, Sep 18 05:03
Сохраняем точный тип в обобщённых методах Python при верхней границе
Разбираем, как в обобщённых методах Python сохранять точный тип аргумента при верхней границе типа. Рабочий приём с объединением сигнатур, Never и дескриптором.
Обобщения в Python, сохраняющие тип, становятся сложными, как только вы хотите, чтобы метод возвращал ровно тип аргумента и при этом соблюдал верхнюю границу на уровне класса. По умолчанию анализаторы типов сводят возвращаемый тип к параметру-обобщению класса, что часто слишком грубо. В этом материале показано, как выразить «вернуть тот же тип, что и на входе, но только если он является подтипом границы класса» с помощью корректного приема типизации, который работает в нынешней системе типов.
Постановка задачи
Рассмотрим простую иерархию наследования и обобщённый контейнер с методом, который по смыслу «эхом» возвращает свой вход. Задача: имея контейнер, параметризованный базовым типом, сделать так, чтобы метод принимал любой подтип этого базового и возвращал ровно тот же подтип.
class Root: 
    def a(self): ...
class Node(Root): 
    def b(self): ...
class Leaf(Node): 
    def c(self): ...
from typing import Generic, TypeVar
G = TypeVar('G')
class Holder(Generic[G]):
    def echo(self, x: G) -> G:
        return x
Если вы объявляете Holder[Root], большинство статических анализаторов будут считать, что echo всегда возвращает Root, даже если вы передаёте Node или Leaf. В итоге теряется точный возвращаемый тип, и точный вывод типов перестаёт работать по назначению.
Почему очевидное исправление не сработает
Возникает желание добавить второй параметр типа внутри класса и ограничить его внешним параметром обобщения. Но для этого нужен тип-переменная, чья граница зависит от параметра обобщения самого класса, то есть фактически требуются типы высшего порядка. Система типизации Python таких типов не поддерживает. Попытка объявить вложенную тип-переменную с границей, ссылающейся на тип-параметр класса, не даст желаемых ограничений. На практике вы получите либо молчаливые пропуски ошибок, либо ситуацию, где ни одно значение не проходит проверку, пока вы не откатитесь к Any.
Рабочий подход
Ключ — арность метода. Когда метод принимает единственный позиционный аргумент, его тип можно смоделировать как объединение (union) двух сигнатур вызываемых объектов. Первая сигнатура сохраняет тип аргумента в возвращаемом значении. Вторая ограничивает аргумент верхней границей класса и возвращает Never, чтобы не влиять на вычисленный результат объединения. Совмещая обе формы, мы одновременно соблюдаем верхнюю границу и сохраняем точный тип аргумента в возвращаемом значении.
Для экземплярных методов это удобно выразить через декоратор на основе дескриптора, который «перекраивает» тип метода в такое объединение. Ниже — готовый способ аннотировать метод с известной сигнатурой вида def(self, obj, /).
Пример кода
from typing import Generic, TypeVar
X = TypeVar('X')
Y = TypeVar('Y')
class Box(Generic[X]):
    def identity(self, item: Y) -> Y:
        return item
Это интуитивная отправная точка. Теперь уточним тип identity так, чтобы для Box[Root] аргумент был ограничен Root, а возвращаемое значение оставалось точным подтипом:
from collections.abc import Callable
from typing_extensions import Concatenate, Generic as TGeneric, Never, ParamSpec, TypeVar, overload
from typing_extensions import Never as Sink
from types import MethodType
P_def = ParamSpec('P_def', default=...)
P_alt = ParamSpec('P_alt')
R_alt = TypeVar('R_alt')
R_def = TypeVar('R_def', default=object)
Self_def = TypeVar('Self_def', default=Never)
Self_alt = TypeVar('Self_alt')
T_bound = TypeVar('T_bound')
T_free = TypeVar('T_free')
class limit_arg_to_bound(TGeneric[T_bound, Self_def, P_def, R_def]):
    """
    Constrain the second parameter of an unbound instance method shaped
    as `def (self, obj, /)` so that `obj: T_bound`, while preserving the
    exact argument type as the return when permissible.
        @limit_arg_to_bound[T_bound]
        def method(self, obj: T_free) -> R: ...
    Only `T_bound` needs to be provided by users; the remaining parameters are
    inferred from the decorated function.
    """
    _fn: Callable[Concatenate[Self_def, P_def], R_def]
    def __init__(
        self: 'limit_arg_to_bound[T_bound, Self_alt, P_alt, R_alt]',
        fn: Callable[Concatenate[Self_alt, P_alt], R_alt],
        /,
    ) -> None:
        self._fn = fn
    @overload
    def __get__(
        self, instance: None, owner: type[Self_def], /
    ) -> (
        Callable[Concatenate[Self_def, P_def], R_def]
        | Callable[[Self_def, T_bound], Sink]
    ): ...
    @overload
    def __get__(
        self, instance: Self_def, owner: type[Self_def], /
    ) -> (
        Callable[P_def, R_def]
        | Callable[[T_bound], Sink]
    ): ...
    def __get__(
        self, instance: Self_def | None, owner: type[Self_def], /
    ) -> (
        Callable[Concatenate[Self_def, P_def], R_def]
        | Callable[[Self_def, T_bound], Sink]
        | Callable[P_def, R_def]
        | Callable[[T_bound], Sink]
    ):
        if instance is None:
            return self._fn
        return MethodType(self._fn, instance)
С этим дескриптором можно аннотировать метод так, чтобы и верхняя граница соблюдалась, и точный подтип входа сохранялся в возвращаемом значении.
from typing import Generic, TypeVar
BaseT = TypeVar('BaseT')
ExactT = TypeVar('ExactT')
class Anchor: ...
class Core(Anchor): ...
class Edge(Core): ...
class Carrier(Generic[BaseT]):
    @limit_arg_to_bound[BaseT]
    def project(self, value: ExactT) -> ExactT:
        return value
obj: Carrier[Anchor] = Carrier()
obj.project(Anchor())  # тип: Anchor
obj.project(Core())    # тип: Core
obj.project(Edge())    # тип: Edge
obj.project(123)       # статическая ошибка: «int» не является подтипом Anchor
Как это работает
Дескриптор формирует тип несвязанного и связанного метода как объединение двух вызываемых объектов. Одна ветка удерживает тип аргумента синхронно с возвращаемым значением — так получается желаемое «вернуть ровно то, что пришло». Другая ветка принимает только аргументы в пределах верхней границы класса и возвращает Never. Поскольку для объединений вызываемых объектов итоговый тип возврата вычисляется по всем веткам, Never ничего не добавляет, поэтому общий возвращаемый тип остаётся точным типом аргумента из разрешающей ветки. Такая комбинация одновременно навязывает верхнюю границу и сохраняет точность типа.
Конструкция опирается на знание арности метода. Она рассчитана на методы вида def(self, obj, /). Эта форма важна: с ней дескриптор может аккуратно выразить и параметр self, и единственный параметр данных через ParamSpec и Concatenate.
Почему нельзя связать TypeVar с параметром Generic изнутри класса
Чтобы связать вторую тип-переменную внутри обобщённого класса с его собственным параметром, нужны типы высшего порядка. В типизации Python нет тип-переменных высшего порядка. Попытка объявить вложенную тип-переменную, чья граница ссылается на параметр обобщения класса, не отразит желаемое пространство ограничений: на практике проверяющие либо примут объявление, но отвергнут все использования, либо сведут всё к лазейкам на основе Any. Подход с дескриптором полностью обходится без такой «высшей» механики.
Замечание о работе во время выполнения
В Python 3.13 и 3.14 наблюдается исключение при выполнении для этого паттерна, когда ParamSpec используется со значением по умолчанию. Текст ошибки: "TypeError: can only concatenate list (not 'tuple') to list". Поскольку дескриптор нужен лишь для типизации, практичный путь — сделать конструкцию «только для типизации» и дать безвредный заменитель на рантайме, который сохраняет тело функции как есть. Так статическая проверка остаётся полноценной, а выполнения код не ломает.
Зачем это нужно
API, которые проксируют неизвестные, но ограниченные подтипы, встречаются часто — особенно во фреймворках и регистрах. Точные возвращаемые типы обеспечивают корректную подсветку, автодополнение и диагностику в последующем коде, при этом не пропуская значения вне задуманной иерархии. Без этого анализаторы уплощают всё до параметра обобщения, и вы теряете важную специфичность.
Выводы
Методы со строгой верхней границей и сохранением точного типа уже возможны сегодня, если опираться на арность метода и объединение сигнатур с веткой, возвращающей Never. Не пытайтесь выражать тип-переменную, ограниченную другой тип-переменной внутри обобщённого класса: для этого потребовались бы типы высшего порядка. Если на конкретных версиях Python сталкиваетесь с проблемой ParamSpec по умолчанию на рантайме, держите типовую конструкцию неактивной при выполнении и подменяйте её бездействующим заменителем. При разумном использовании этот паттерн даёт лучшее из двух миров: строгие границы и точный вывод типов.