2025, Dec 09 03:01
Оборачивание обобщенной функции в Python: почему теряется тип и что делать
Разбираем, почему при оборачивании обобщенной функции в Python тип T схлопывается в Never в mypy/pyright и как сохранить тип через дескриптор и декоратор.
Как обернуть обобщенную функцию в Python и не потерять ее тип: что идет не так и как с этим жить
Превратить функцию типа T → T в безаргументный thunk, который возвращает держатель для последующего выполнения, кажется простой задачей. Идея прозрачна: принять вызываемый объект, сохранить его в контейнере и вызвать, когда понадобится. Сложности начинаются, когда сам вызываемый объект — обобщенный. Средства статической проверки типов, такие как mypy и pyright, в этой схеме не сохраняют переменную типа, и итоговый тип схлопывается в Never. Разберемся, почему так происходит и что можно сделать уже сейчас.
Исходные данные
Ниже — минимальная реализация. Небольшой враппер принимает функцию типа T → T и возвращает функцию без аргументов, производящую контейнер, в котором хранится исходная функция.
from collections.abc import Callable
class Hold[U]:
def __init__(self, stored: Callable[[U], U]):
self.stored = stored
def packer[U](fn: Callable[[U], U]) -> Callable[[], Hold[U]]:
def make() -> Hold[U]:
return Hold(fn)
return make
При использовании с негенерик-функцией это ведет себя ожидаемо для статических проверяющих типов.
@packer
def echo_str(arg: str) -> str:
return arg
reveal_type(echo_str)
reveal_type(echo_str())
reveal_type(echo_str().stored)
reveal_type(echo_str().stored("test"))
Но при применении к обобщенной функции типовый аргумент пропадает, и вся цепочка выводится как Never.
@packer
def echo_generic[T](arg: T) -> T:
return arg
reveal_type(echo_generic)
reveal_type(echo_generic())
reveal_type(echo_generic().stored)
reveal_type(echo_generic().stored("test"))
Логично ожидать, что echo_generic будет иметь тип def[T]() -> Hold[T], echo_generic().stored — def[T](T) -> T, а последний вызов разрешится в str. Однако ни mypy, ни pyright сейчас так не делают.
Что происходит на самом деле
Аннотации пытаются повторно использовать один и тот же символ T в разных областях видимости переменных типов. Обобщенные функции и обобщенные классы связывают переменные типов внутри своих тел. Переменные уровня модуля, однако, не могут нести свободную переменную типа. В данном случае после декорирования echo_generic становится переменной уровня модуля, и тип вроде Hold[T] на уровне модуля несостоятелен, потому что в этой области нет связывателя для T.
Поэтому проверяющий получает Hold[Never]. Это обозначает случай, который нельзя параметризовать конкретным типом на уровне модуля. Здесь напрямую проявляется разница между def (T) -> T для некоторого T и def[T](T) -> T для любого T. Эти формы принципиально различаются, и в системе типов Python нет единого интерфейса, который их объединяет. Это также вопрос раннего против позднего связывания. Когда применяется декоратор, его переменные типов должны быть разрешены, и текущие проверяющие не проводят обобщенные функции через этот этап. Спецификация подобного поведения не требует.
Прагматичный обходной путь с дескриптором
Если на уровне модуля доступен конкретный тип, сохраненный вызываемый объект обязан принимать и возвращать именно этот конкретный тип. Если конкретного типа нет, максимум, что можно сделать, — предоставить вызываемый объект, который возвращает то же, что получил. Это различие можно смоделировать дескриптором, меняющим тип атрибута stored в зависимости от того, параметризован ли Hold конкретным типом или нет.
Следующий шаблон кодирует это поведение. Он опирается на механизмы, активные только при TYPE_CHECKING, и перегрузки для наведения проверяющего, не меняя поведение во время выполнения. Учтите, это хак, зависящий от текущего вывода Hold[Never]. Если когда-нибудь проверяющий начнет выводить Hold[Any], он перестанет работать как задумано. Кроме того, он не распространяет границы или ограничения переменных типов; в обобщенной форме вызываемого они становятся неограниченными.
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Never, Self, overload
if TYPE_CHECKING:
class StoredView:
@overload # type: ignore[no-overload-impl]
def __get__(self, instance: None, owner: type[object], /) -> Self: ...
@overload
def __get__[R](
self, instance: Hold[Never], owner: type[Hold[Never]], /
) -> Callable[[R], R]:
"""
Конкретная параметризация недоступна; вернуть вызываемый объект,
который просто возвращает тот же тип, что и получил
"""
@overload
def __get__[U](
self, instance: Hold[U], owner: type[Hold[U]], /
) -> Callable[[U], U]:
"""
Есть конкретная параметризация; вернуть вызываемый объект,
ограниченный этим конкретным типом
"""
def __set__[U](
self, instance: Hold[Any], value: Callable[[U], U], /
) -> None: ...
class Hold[U]:
if TYPE_CHECKING:
stored = StoredView()
def __init__(self, stored: Callable[[U], U]):
self.stored = stored
def packer[U](fn: Callable[[U], U]) -> Callable[[], Hold[U]]:
def make() -> Hold[U]:
return Hold(fn)
return make
@packer
def echo_str(arg: str) -> str:
return arg
reveal_type(echo_str)
reveal_type(echo_str())
reveal_type(echo_str().stored)
reveal_type(echo_str().stored("test"))
reveal_type(echo_str().stored(1))
@packer
def echo_generic[T](arg: T) -> T:
return arg
reveal_type(echo_generic)
reveal_type(echo_generic())
reveal_type(echo_generic().stored)
reveal_type(echo_generic().stored("test"))
reveal_type(echo_generic().stored(1))
С такой настройкой, когда конкретный тип известен, stored типизируется как вызываемый объект, принимающий и возвращающий именно этот тип. Когда конкретного типа нет и контейнер фактически равен Hold[Never], stored выставляется как обобщенный вызываемый объект def[R](R) -> R.
Почему это важно
Декораторы и утилиты высшего порядка, работающие с функциями T → T, в Python встречаются часто. Не понимая, как устроены области видимости переменных типов, легко ненароком стереть обобщенность и потерять типобезопасность или получить Never в ключевых местах. На практике проявляется разница между существованием и универсальной квантификацией: def (T) -> T для некоторого T — не то же самое, что def[T](T) -> T для любого T, и система типов проводит это различие на границах модуля.
Итоги
Ожидайте, что переменные типов связываются только внутри обобщенных классов и функций, а не на уровне модуля. Не рассчитывайте, что декоратор сохранит свободную переменную типа, когда вы превращаете обобщенную функцию в безаргументный thunk, возвращающий контейнер. Если нужно сохранить полезную типизацию при доступе к атрибуту, дескрипторный обходной путь позволяет развести два случая: когда конкретный тип известен и когда нет. Этот подход сознательно хаковый и может зависеть от текущих деталей вывода типов вроде Hold[Never]; он также не сохраняет границы и ограничения переменных типов. Если можете переработать API так, чтобы переменная типа оставалась связанной в списке параметров функции, вы полностью избегаете проблемы раннего связывания. В остальных случаях используйте дескрипторный трюк осознанно и документируйте компромиссы.