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 в разработке, чтобы подтверждать, что именно вывел проверщик на каждом шаге, и избегать сюрпризов.

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