2025, Sep 27 17:16

Аннотация «любого» дженерика в Python 3.13: верхняя материализация

Как принять любой экземпляр обобщённого класса в Python 3.13 без Any: вариантность, верхняя материализация (object, Never), границы типов и нюансы basedpyright

Аннотировать метод, который принимает любой экземпляр обобщённого класса, на первый взгляд просто — пока не вмешивается вариантность. В Python 3.13 с новой синтаксической формой дженериков интуитивная мысль «примите любой Foo[T], меня не волнует, какой именно T» быстро сталкивается с ожиданиями типизатора, особенно в инструментах вроде basedpyright. Вопрос в том, как выразить это намерение, не разбрасываясь Any и не перегружая сигнатуру лишними параметрами типа.

Постановка задачи

Предположим, есть дженерик-класс, а его параметр типа в конкретной функции не нужен. Сам класс прост; проблемы начинаются в месте вызова.

class Crate[U]: ...
def handle_crate_a(x: Crate) -> None: ...
from typing import Any
def handle_crate_b(x: Crate[Any]) -> None: ...
def handle_crate_c[U](x: Crate[U]) -> None: ...

Первый вариант вовсе опускает квадратные скобки и вызывает ошибку в basedpyright. Во втором подключают Any — это приводит к предупреждению, которое многие стараются подавить локально или глобально, но тогда смысл предупреждения теряется. Третий дублирует параметр типа и, если у U задано верхнее ограничение, вынуждает повторять его в сигнатуре функции, усиливая связность и создавая шум там, где U на самом деле не используется.

Что происходит на самом деле

Самый общий полностью статический тип для обобщённого класса определяется вариантностью его параметров типа. Такой тип называется верхней материализацией (top materialization; также встречается «materialization по верхней границе»). Идея в том, чтобы выбрать конкретный аргумент параметра типа, который представляет «наиболее широкий безопасный тип», но выбор зависит от того, ковариантен параметр, контравариантен, бивариантен или инвариантен.

Если параметр ковариантен, верхняя материализация — это Crate[object], либо Crate[UpperBound], если задана верхняя граница. Если параметр контравариантен — Crate[Never]. Если параметр инвариантен, на данный момент нет обозначаемой верхней материализации; на практике лучшим доступным вариантом остаётся Crate[Any].

Решение в коде

Используйте верхнюю материализацию, соответствующую вариантности параметра. Если параметр ковариантен или бивариантен — ставьте аннотацию object или верхнюю границу. Если контравариантен — Never. Если инвариантен — остаётся Any.

from typing import Any, Never
# Ковариантный или бивариантный параметр типа
def handle_crate(x: Crate[object]) -> None: ...
# Контравариантный параметр типа
def handle_crate(x: Crate[Never]) -> None: ...
# Инвариантный параметр типа (сейчас нет лучшего статического варианта)
def handle_crate(x: Crate[Any]) -> None: ...

Если у параметра типа есть верхняя граница и он ковариантен, укажите в аннотации эту границу вместо object. Так вы точно обозначите, что значит «любой Crate» при данном ограничении, при этом сохранив полную статичность.

Почему это важно

Правильный выбор верхней материализации помогает избежать хрупких сигнатур и лишних дженериков там, где параметр типа не используется. Это также избавляет от соблазна опираться на Any. Как многие убедились на практике, Any фактически выключает проверку типов по этому пути: поначалу это кажется удобным, но затем может скрыть важные диагностические сообщения, которые вы рассчитывали увидеть. Предупреждение basedpyright reportExplicitAny как раз и существует, чтобы сделать этот компромисс заметным.

Небольшое отступление: как это выглядит на практике

Некоторые инструменты автоматизируют вычисление верхней материализации. Один из примеров — резолвер ty.

class BothWays[V]: ...
class GoesOut[T]:
    def show(self) -> T: ...
class GoesIn[T]:
    def add(self, _: T) -> None: ...
class Strict[T]:
    def mix(self, _: T) -> T: ...
from typing import Any
from ty_extensions import Top
def probe(
    a: Top[BothWays[Any]],
    b: Top[GoesOut[Any]],
    c: Top[GoesIn[Any]],
    d: Top[Strict[Any]]
):
    reveal_type(a)      # BothWays[object]
    reveal_type(b)      # GoesOut[object]
    reveal_type(c)      # GoesIn[Never]
    reveal_type(d)      # Top[Strict[Any]]

Итоги и рекомендации

Если функции нужно принимать любой экземпляр обобщённого класса, не завися от конкретного параметра типа, используйте верхнюю материализацию. Для ковариантного или бивариантного параметра указывайте object или объявленную верхнюю границу. Для контравариантного — Never. Для инвариантного — Any, потому что обозначаемой верхней материализации пока нет. Такой подход сохраняет сигнатуры точными и статическими, избавляет от лишних параметров типа и подчёркивает, что функция работает с любым допустимым инстанцированием класса.

Если вы задумываетесь о том, чтобы сделать параметр ковариантным ради возможности аннотировать object или верхнюю границу, внимательно оцените, соответствует ли API требованиям ковариантности. Если да — решение получится выразительным и долговечным. Если нет — выбирайте корректную вариантность и соответствующую ей верхнюю материализацию, даже если для инвариантов придётся смириться с Any.

Статья основана на вопросе на StackOverflow от Frank William Hammond и ответе от InSync.