2025, Oct 19 03:16

Pylance и union в границе дженерика: почему ломается типизация и что делать

Почему Pylance ругается на union в границе дженерика: пример с merge, риск Holder[PartA|PartB] и безопасное решение через ограничения (PartA, PartB) в Python.

Когда обобщённый класс параметризуется объединением (union), кажущиеся очевидными отношения типов часто расползаются при проверке типов. Типичный случай — метод, где и self, и other имеют одну и ту же конкретную инстанциацию дженерика, но обращение к полю внутри метода не проходит проверку. Ниже — минимальная конфигурация, которая воспроизводит это в Pylance на Python 3.13.3.

Постановка проблемы

from typing import Self


class PartA:
    m: int

    def merge(self, peer: Self) -> None:
        self.m += peer.m


class PartB:
    s: str

    def merge(self, peer: Self) -> None:
        self.s += peer.s


PartMix = PartA | PartB


class Holder[PieceT: PartMix]:

    piece: PieceT

    def join(self, peer_box: Self) -> None:
        self.piece.merge(peer_box.piece)  # Pylance помечает эту строку

Pylance сообщает, что тип аргумента peer_box.piece слишком широк для merge. Сообщение выглядит так:

Аргумент типа "PieceT@Holder" нельзя присвоить параметру "peer" типа "PartA*" в функции "merge". Тип "PartA* | PartB*" не совместим с типом "PartA*". "PartB*" не совместим с "PartA*". Аналогично для "PartB*".

Почему так происходит

Внутри join и self, и peer_box имеют один и тот же Holder[PieceT]. Интуитивно хочется считать, что self.piece и peer_box.piece — одного и того же конкретного типа. Однако из‑за того, что PieceT ограничён объединением, Pylance рассматривает peer_box.piece как PartMix и не может доказать, что на месте вызова merge он соответствует нужной ветке.

Более того, разрешая использовать объединение в качестве верхней границы параметра типа, вы делаете сам дженерик потенциально небезопасным. Такой тип допускает инстанциацию Holder[PartMix], и это нарушает задуманный контракт. Посмотрите на последовательность ниже: она проходит проверку типов, но падает во время выполнения, потому что конкретные части не совпадают:

mix_a: PartMix = PartA()
box_a: Holder[PartMix] = Holder(mix_a)

mix_b: PartMix = PartB()
box_b: Holder[PartMix] = Holder(mix_b)

box_a.join(box_b)  # оба — Holder[PartMix], но части несовместимы во время выполнения

Это объясняет, почему проверяющий отказывает в вызове merge: ему пришлось бы принять сценарий, который нельзя сделать безопасным. Применение typing.cast тут тоже не помогает, ведь корень проблемы — чересчур разрешающая граница, допускающая Holder[PartMix].

(Предположим, что piece — это атрибут экземпляра, задаваемый через конструктор, а не атрибут класса.)

Безопасное решение с ограничениями

Вместо объединения в роли верхней границы задайте для параметра типа фиксированный набор альтернатив. Так сохраняется желаемая вариативность — Holder можно специализировать как PartA или PartB — но предотвращается небезопасная инстанциация Holder[PartA | PartB].

class Holder[PieceT: (PartA, PartB)]:
    piece: PieceT

    def join(self, peer_box: Self) -> None:
        self.piece.merge(peer_box.piece)

С такой декларацией допустимы Holder[PartA] и Holder[PartB], а вот Holder[PartA | PartB] — нет. Вызов join теперь проходит проверку типов и соответствует фактическому поведению. Это удовлетворяет и Pyright, и mypy.

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

Границы параметров типа в виде union кажутся удобными, но нередко вносят неоднозначность прямо в сам дженерик. Эта неоднозначность просачивается в методы, опирающиеся на отношения наподобие Self, и заставляет проверяющий оставлять члены в виде объединения, из‑за чего корректные вызовы нельзя признать безопасными. Хуже того, она допускает неустойчивые инстанциации дженерика, способные падать во время выполнения. Ограничение параметра типа явным набором вариантов снимает эту неопределённость, не жертвуя выразительностью.

Вывод

Если обобщённому типу нужно поддерживать несколько конкретных реализаций, не используйте объединение как верхнюю границу параметра типа. Вместо этого задавайте ограничения, чтобы дженерик можно было инстанцировать только одной совместимой альтернативой за раз. Это сохраняет безопасность тел методов, предотвращает случайные инстанциации Holder[PartA | PartB] и выравнивает гарантии проверяющего с реальным исполнением.

Статья основана на вопросе с StackOverflow от inaku Gyan и ответе от Anerdw.