2025, Nov 04 03:03
Вывод вариативности дженериков в Python 3.12: как добиться ковариантности для контейнеров только чтения
Разбираем вывод вариативности обобщённых классов в Python 3.12: почему возникает инвариантность и как добиться ковариантности через приватные атрибуты.
Вывод вариативности в Python 3.12 изменил то, как мы смотрим на обобщённые классы. С явным синтаксисом дженериков проверяющие типы выводят, является ли параметр типа инвариантным, ковариантным или контравариантным, исходя из его использования. Это удобно — пока класс, который явно должен быть только для чтения, внезапно не становится инвариантным и не проходит проверку типов. Давайте разберём минимальный пример, поймём, что происходит, и посмотрим, как корректно выразить семантику «только чтение», чтобы тайпчекеры были согласны.
Что пишут в документации
Появление явного синтаксиса для обобщённых классов в Python 3.12 снимает необходимость явно указывать вариативность параметров типа. Вместо этого проверяющие типы выводят вариативность параметров типа по их использованию внутри класса. В зависимости от использования параметры типа могут быть выведены как инвариантные, ковариантные или контравариантные.
Неожиданная инвариантность: минимальный воспроизводимый пример
Представим простой контейнер, который по задумке после создания используется только для чтения. Нам нужна ковариантность: контейнер с подтипом должен подходить там, где ожидается контейнер с супертипом.
class DataBox[V]:
    # Задуман как ковариантный для сценария «только чтение»
    def __init__(self, item: V) -> None:
        self.item = item
    def read(self) -> V:
        return self.item
def show_float(box: DataBox[float]) -> None:
    print(box)
num_box = DataBox(1)
# Проверяющий типы помечает это как несовместимое, потому что DataBox[int]
# не считается подтипом DataBox[float].
show_float(num_box)
Хотя снаружи контейнер выглядит как только для чтения, проверяющий типы выводит параметр типа как инвариантный. На первый взгляд хочется «обвинить» конструктор и решить, что приём V в __init__ и вынуждает инвариантность.
Что на самом деле делает его инвариантным
Дело не в __init__. Корневая причина — атрибут хранения публичный. Поскольку item публично устанавливается на экземпляре, проверяющие типы рассматривают его как доступный для записи пользователями класса. Иными словами, наличие публично записываемого атрибута, связанного с параметром типа, сигнализирует, что тип может изменяться извне, поэтому безопасный вывод — инвариантность. Конструктор лишь передаёт значение; решающим становится то, что атрибут открыт и фактически воспринимается как публичный «сеттер».
Как исправить: сделайте хранилище приватным, чтобы выразить «только чтение»
Если контейнер должен быть только для чтения, внутреннее хранилище стоит сделать приватным. Префикс подчёркивания у атрибута сообщает проверяющим типы, что это не часть публичного интерфейса. С этой правкой тот же параметр типа выводится как ковариантный.
class DataBox[V]:
    def __init__(self, item: V) -> None:
        self._item = item
    def read(self) -> V: ...
С приватным хранением функции, которым нужна конкретная инстанциация, начинают вести себя ожидаемо с учётом вывода вариативности.
def needs_int_box(b: DataBox[int]) -> None: ...
needs_int_box(DataBox[object](object()))  # ошибка
needs_int_box(DataBox[int](int()))        # корректно
needs_int_box(DataBox[bool](bool()))      # корректно
Здесь коробка с object не принимается там, где требуется коробка с int, тогда как коробка с int или совместимым типом проходит — это соответствует ожиданиям при ковариантном дизайне.
Почему это важно в реальном коде
При разработке библиотек и API с явными дженериками Python 3.12 публичные атрибуты, связанные с параметрами типа, напрямую влияют на то, как проверяющие типы выводят вариативность. Если класс задуман как контейнер только для чтения, приватное хранение — ключ к корректной передаче этой идеи. Этот небольшой выбор имени — подчёркивание — может отделить удобные и типобезопасные API от запутанной инвариантности, которая вынуждает к ненужным преобразованиям типов или отклоняет валидные случаи.
Замечание, которое часто сбивает с толку: __init__ можно вызывать несколько раз на одном экземпляре; это __new__ нельзя вызвать повторно, потому что он возвращает новый экземпляр. Ещё один совет: не используйте int и float как пример отношения подтипов — этот частный случай обсуждается в сообществе по типизации.
Выводы
Вывод вариативности в Python 3.12 мощен, но полностью опирается на то, как ваш класс раскрывает и потребляет параметр типа. Если класс должен быть только для чтения, держите внутреннее хранилище приватным и предоставляйте только методы чтения. Не «вешайте» инвариантность на __init__; для проверяющих типов важно, можно ли публично записать значение параметра типа. Такой подход к дизайну API обеспечивает предсказуемое поведение в Mypy и Pyright и помогает коду ясно доносить задуманные ограничения.
Статья основана на вопросе на StackOverflow от Leonardus Chen и ответе от InSync.