2025, Sep 25 09:17

Короткое замыкание и Optional в Python: подводные камни mypy и решение с @final

Разбираем, почему выражение x and x.method() с Optional в Python путает mypy: истинность, наследование и __bool__. Показываем решение с @final и альтернативы.

Логическое короткое замыкание с Optional-значениями в Python часто выглядит изящно: одна компактная конструкция — и аккуратный уход в None. Но как только подключается статическая типизация, удобство может расходиться с тем, как анализаторы типов трактуют истинность объектов и наследование. Ниже — наглядный пример с mypy, который кажется ложным срабатыванием, пока не взглянешь на поведение во время выполнения.

Если класс Python не определяет __bool__ или __len__, bool(instance) по умолчанию возвращает True.

Проблема

У вас есть класс с простым методом и вспомогательная функция, которая принимает экземпляр или None. Кажущийся естественным однострочник x and x.method() должен вернуть либо результат метода, либо None. Однако mypy сообщает о несовместимом типе возвращаемого значения.

class BaseObj:
    def __init__(self) -> None:
        self._n = 42
    def value(self) -> int:
        return self._n
def maybe_value(x: BaseObj | None) -> int | None:
    # случай: x равен None => вернёт None
    # случай: x не равен None => вернёт 42
    # однако: mypy сообщает об ошибке...
    #   Несовместимый тип возвращаемого значения (получен BaseObj | int | None), ожидалось "int | None"
    return x and x.value()

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

Выражение x and x.value() опирается на истинность во время выполнения. Если x — истинный объект, Python вычисляет и возвращает x.value(); если x — ложный, он возвращает сам x. Поскольку BaseObj не определяет __bool__ или __len__, экземпляр BaseObj действительно является истинным. Поэтому легко предположить, что результатом могут быть только int или None.

Подвох в наследовании. Подкласс может переопределить __bool__ и сделать экземпляр ложным. Тогда короткое замыкание вернёт сам экземпляр вместо вызова value(), и тип выражения на самом деле превращается в BaseObj | int | None во время выполнения.

class DerivedObj(BaseObj):
    def __bool__(self) -> bool:
        return False
reveal_type(maybe_value(DerivedObj()))  # "int | None" во время проверки типов,
                                        # а во время выполнения — DerivedObj.

Осторожность mypy здесь оправдана. Поскольку BaseObj можно наследовать, а подклассы вправе переопределить __bool__, mypy обязан учитывать ветку, где левый операнд оказывается ложным и возвращается как есть.

Решение

Если вы хотите, чтобы mypy исходил из того, что экземпляры не могут быть ложными, кроме случая None, исключите наследование, которое способно изменить истинность. Пометьте класс как @final. Для final‑класса mypy может уверенно считать, что bool(x) всегда True для любых значений, отличных от None, и сузить тип соответствующим образом.

from typing import final
@final
class BaseObj:
    def __init__(self) -> None:
        self._n = 42
    def value(self) -> int:
        return self._n
def maybe_value(x: BaseObj | None) -> int | None:
    return x and x.value()

Если ищете другой способ записывать optional_obj and optional_obj.attribute, см. Как заставить Pylance игнорировать возможность None?.

Зачем это важно

Статический анализ обязан учитывать все допустимые пути выполнения. Из-за наследования ваша, казалось бы, непробиваемая уверенность в истинности объекта может быть нарушена без изменений исходного класса. Понимание этого частного случая избавляет от неожиданных ошибок типов и, что важнее, от скрытых багов во время выполнения, когда в кодовой базе появляются ложные подклассы.

Выводы

Если короткое замыкание объединяет объект и значение, результатом может оказаться сам объект, когда он способен быть ложным. mypy отмечает это, потому что наследование позволяет переопределить истинность. Если класс не предполагает расширения или вы хотите гарантировать стабильную истинность для сужения типов, объявляйте его @final. А когда нужен другой стиль доступа к атрибутам у опциональных значений, используйте приёмы из упомянутого материала вместо «цепочек» с and.

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