2025, Sep 28 13:17
Почему list и Sequence в Python ведут себя по-разному: двунаправленный вывод и анализ потока
Почему list[SubA] как Sequence[BaseItem] запрещает append(SubB): инвариантность list, ковариантность, двунаправленный вывод и анализ потока типов в Python.
Проверяющие типы строги не просто так, и порой эта строгость кажется нелогичной. Классический пример — смешивание подтипов в списках по сравнению с использованием ковариантных представлений вроде Sequence. Ниже — разбор, откуда берётся путаница и как такие механизмы, как двунаправленный вывод типов и анализ потока, формируют итог.
Воспроизводим ситуацию
from typing import Sequence
class BaseItem: ...
class SubA(BaseItem): ...
class SubB(BaseItem): ...
items: list[BaseItem] = [SubA()]
items.append(SubB())  # ОК: список BaseItem может хранить и SubA, и SubB
Пока всё логично. Теперь вынесем в отдельную функцию создание списка конкретного подтипа и попробуем присвоить его списку базового типа.
def build_suba_list() -> list[SubA]:
    return [SubA(), SubA()]
items2: list[BaseItem] = build_suba_list()  # Ошибка инвариантности для list
Эта ошибка ожидаема, потому что list инвариантен. Ковариантное представление вроде Sequence выглядит заманчиво:
more_items: Sequence[BaseItem] = build_suba_list()
more_items.append(SubB())  # Ошибка проверщика типов
Почему вызов append здесь падает, а такая вариация проходит?
extra_items: Sequence[BaseItem] = []
extra_items.extend(build_suba_list())  # ОК
extra_items.append(SubB())              # ОК
Что происходит на самом деле
Действуют два механизма: двунаправленный вывод типов и анализ потока. Этого достаточно, чтобы объяснить поведение целиком.
Двунаправленный вывод использует ожидаемый тип, чтобы уточнить обычный вывод. Минимальный пример:
from typing import Iterable, Literal
xs = [3]
# Сам по себе этот литерал может означать разное: list[int], Iterable[object] и т. п.
ys: Iterable[Literal[3]] = [3]
reveal_type(ys)  # list[Literal[3]] благодаря ожидаемому типу Iterable[Literal[3]]
Анализ потока может сузить тип символа до более точного, чем объявленный или выведенный, когда это безопасно:
num: int = 3
reveal_type(num)  # Literal[3], более узкий, чем int
res = num + 2
reveal_type(res)  # Literal[5], а не просто int
Возвращаемся к «list против Sequence»
Присваивание значения, которое возвращает функция и фактически является конкретным подтипом list, переменной, аннотированной как Sequence, даёт точный тип фактического значения, а не только аннотированного представления. Включается анализ потока и сохраняет, чем выражение является на самом деле.
from typing import Sequence
class BaseItem: ...
class SubA(BaseItem): ...
class SubB(BaseItem): ...
def build_suba_list() -> list[SubA]:
    return [SubA(), SubA()]
view1: Sequence[BaseItem] = build_suba_list()
reveal_type(view1)  # list[SubA] благодаря анализу потока правой части
# Поэтому разрешение метода нацелено на list[SubA].append,
# который не принимает SubB.
view1.append(SubB())  # Ошибка: не совместимо с list[SubA].append
Для сравнения, создание пустого списка там, где ожидается Sequence[BaseItem], направляет вывод иначе. Пустой литерал плюс ожидаемый тип и последующие изменения заставляют проверщик типов считать лежащий под капотом список list[BaseItem], и он ведёт себя как нужно.
view2: Sequence[BaseItem] = []
reveal_type(view2)  # list[BaseItem] из-за ожидаемого типа плюс анализ потока
# Расширять list[BaseItem] значениями из Iterable[SubA] — нормально,
# поскольку list[SubA] — подтип Iterable[BaseItem].
view2.extend(build_suba_list())  # ОК
# Добавлять SubB в list[BaseItem] тоже корректно.
view2.append(SubB())  # ОК
Почему различие тонкое, но важное
Когда вы привязываете значение к имени, проверщик несёт максимально точный тип для этого значения. Если правая часть — list[SubA], имя фактически рассматривается как list[SubA] для разрешения методов, даже если в аннотации указан Sequence[BaseItem]. Поэтому в первом случае append падает: базовый контейнер всё ещё анализируется как list[SubA], и SubB туда не подходит.
Когда вы создаёте контейнер на месте пустым списком при ожидаемом базовом представлении, проверщик трактует сформировавшийся контейнер как list[BaseItem], и последующие изменения сверяются с этим целевым типом. Расширение с list[SubA] работает, потому что расширение list[BaseItem] потребляет Iterable[BaseItem], и list[SubA] подходит. Добавление SubB тоже проходит, потому что это подтип BaseItem.
Практическое решение
Если вам нужна ковариантная «обёртка», но вы планируете мутации, совместимые с базовым типом, инициализируйте контейнер в контексте, который сначала задаёт ожидаемый базовый тип, а затем наполняйте его. Присваивание заранее собранного list[SubA] имени типа Sequence[BaseItem] сужает эффективный тип до list[SubA], и разрешение методов следует этому более узкому типу, блокируя добавление других подтипов.
Выводы
Это поведение напрямую следует из двунаправленного вывода и анализа потока. Понимание того, что проверщик несёт точную информацию о фактических значениях, объясняет, почему Sequence с уже готовым списком не «магически» ослабляет сигнатуры методов и почему создание контейнера под ожидаемый базовый тип даёт желаемый результат. Используйте reveal_type в разработке, чтобы подтверждать, что именно вывел проверщик на каждом шаге, и избегать сюрпризов.