Несколько подводных камней статической типизации в Python +23




Думаю, мы все потихоньку уже привыкаем, что у Python есть аннотации типов: их завезли два релиза назад (3.5) в аннотации функций и методов (PEP 484), и в прошлом релизе (3.6) к переменным (PEP 526).


Так как оба этих PEP были вдохновлены MyPy, расскажу, какие житейские радости и когнитивные диссонансы подстерегали меня при использовании этого статического анализатора, равно как и системы типизации в целом.


Disclamer: я не поднимаю вопрос о необходимости или вредности статической типизациии в Python. Просто рассказываю о подводных камнях, на которые натолкнулся в процессе работы в статически-типизированном контексте.

Дженерики (typing.Generic)


Приятно пользоваться в аннотациях чем-то вроде List[int], Callable[[int, str], None].
Очень приятно, когда анализатор подсвечивает следующий код:


T = ty.TypeVar('T')
class A(ty.Generic[T]):
    value: T
A[int]().value = 'str'  # error: Incompatible types in assignment
                        # (expression has type "str", variable has type "int")

Однако, что делать, если мы пишем библиотеку, и программист, использующий ее не будет пользоваться статическим анализатором?
Заставлять пользователя инициализировать класс значением, а потом хранить его тип?


T = ty.TypeVar('T')
class Gen(Generic[T]):
    value: T
    ref: Type[T]

    def __init__(self, value: T) -> None:
        self.value = value
        self.ref = type(value)

Как-то не user-friendly.
А что, если хочется сделать так?


b = Gen[A](B())

В поисках ответа на этот вопрос я немного пробежался по модулю typing, и погрузился в мир фабрик.

Дело в том, что после инициализации инстанции Generic-класса, у нее появляется атрибут __origin_class__, у которого есть аттрибут __args__, представляющий собой кортеж типов. Однако, доступа к нему из __init__, равно как и из __new__, нет. Также его нет в __call__ метакласса. А фишка в том, что в момент инициализации сабкласса Generic он оборачивается в еще один метакласс _GenericAlias, который и устанавливает финальный тип, либо после инициализации объекта, включая все методы его метакласса, либо в момент вызова __getithem__ на нем. Таким образом, никакого способа получить типы дженерика при конструкции объекта нет.


Выкидываем этот мусор, обещал же более универсальное решение

Поэтому я написал себе небольшой дескриптор, решающий эту проблему:


def _init_obj_ref(obj: 'Gen[T]') -> None:
    """Set object ref attribute if not one to initialized arg."""
    if not hasattr(obj, 'ref'):
        obj.ref = obj.__orig_class__.__args__[0]  # type: ignore

class ValueHandler(Generic[T]):
    """Handle object _value attribute, asserting it's type."""
    def __get__(self,
                obj: 'Gen[T]',
                cls: Type['Gen[T]']
                ) -> Union[T, 'ValueHandler[T]']:
        if not obj:
            return self
        _init_obj_ref(obj)
        if not obj._value:
            obj._value = obj.ref()
        return obj._value

    def __set__(self, obj: 'Gen[T]', val: T) -> None:
        _init_obj_ref(obj)
        if not isinstance(val, obj.ref):
            raise TypeError(f'has to be of type {obj.ref}, pasted {val}')
        obj._value = val

class Gen(Generic[T]):
    _value: T
    ref: Type[T]
    value = ValueHandler[T]()

    def __init__(self, value: T) -> None:
        self._value = value
class A:
    pass
class B(A):
    pass

b = Gen[A](B())
b.value = A()
b.value = int()  # TypeError: has to be of type <class '__main__.A'>, pasted 0

Конечно, в последствие, надо будет переписать для более универсального использования, но суть понятна.


[UPD]: С утра я решил попробовать сделать также как в самом модуле typing, но попроще:


import typing as ty
T = ty.TypeVar('T')
class A(ty.Generic[T]):
    # __args are unique every instantiation
    __args: ty.Optional[ty.Tuple[ty.Type[T]]] = None
    value: T

    def __init__(self, value: ty.Optional[T]=None) -> None:
        """Get actual type of generic and initizalize it's value."""
        cls = ty.cast(A, self.__class__)
        if cls.__args:
            self.ref = cls.__args[0]
        else:
            self.ref = type(value)
        if value:
            self.value = value
        else:
            self.value = self.ref()
        cls.__args = None

    def __class_getitem__(cls, *args: ty.Union[ty.Type[int], ty.Type[str]]
                          ) -> ty.Type['A']:
        """Recive type args, if passed any before initialization."""
        cls.__args = ty.cast(ty.Tuple[ty.Type[T]], args)
        return super().__class_getitem__(*args, **kwargs)  # type: ignore

a = A[int]()
b = A(int())
c = A[str]()
print([a.value, b.value, c.value])  # [0, 0, '']

[UPD]: Разработчик typing Иван Левинский сказал, что оба варианта могут непредсказуемо сломаться.


Anyway, you can use whatever way. Maybe __class_getitem__ is even slightly better, at least __class_getitem__ is a documented special method (although its behavior for generics is not).

Функции и алиасы


Да, с дженериками вообще не просто:
К примеру, если мы где-то принимаем функцию как аргумент, то ее аннотация автоматически превращается из ковариантной в контрвариантную:


class A:
    pass
class B(A):
    pass

def foo(arg: 'A') -> None:  # принимает инстанции A и B
    ...

def bar(f: Callable[['A'], None]):  # принимает функции с аннотацией не ниже A
    ...

И в принципе, претензий к логике у меня нет, только решать это приходится через дженерик-алиасы:


TA = TypeVar('TA', bound='A')
def foo(arg: 'B') -> None:  # принимает инстанции B и сабклассов
    ...

def bar(f: Callable[['TA'], None]):  # принимает функции с аннотациями A и B
    ...

Вообще раздел про вариантность типов надо прочитать внимательно, и не на раз.


Обратная совместимость


С этим не ахти: с версии 3.7 Generic – сабкласс ABCMeta, что есть очень удобно и хорошо. Плохо, что это ломает код, если он запущен на 3.6.


Cтруктурное наследование (Stuctural Suptyping)


Сначала очень обрадовался: интерфейсы завезли! Роль интерфейсов выполняет класс Protocol из модуля typing_extensions, который, в сочетании с декоратором @runtime, позволяет проверять, имплементирует ли класс интерфейс без прямого наследования. Также подсвечивается MyPy на более глубоком уровне.


Однако, особой практической пользы в рантайме по сравнению со множественным наследованием я не заметил.
Похоже, что декоратор проверяет только наличие метода с требуемым именем, даже не проверяя кол-во аргументов, не говоря уже о типизации:


import typing as ty
import typing_extensions as te
@te.runtime
class IntStackP(te.Protocol):
    _list: ty.List[int]

    def push(self, val: int) -> None:
        ...

class IntStack:
    def __init__(self) -> None:
        self._list: ty.List[int] = list()

    def push(self, val: int) -> None:
        if not isinstance(val, int):
            raise TypeError('wrong pushued val type')
        self._list.append(val)

class StrStack:
    def __init__(self) -> None:
        self._list: ty.List[str] = list()

    def push(self, val: str, weather: ty.Any=None) -> None:
        if not isinstance(val, str):
            raise TypeError('wrong pushued val type')
        self._list.append(val)

def push_func(stack: IntStackP, value: int):
    if not isinstance(stack, IntStackP):
        raise TypeError('is not IntStackP')
    stack.push(value)

a = IntStack()
b = StrStack()
c: ty.List[int] = list()

push_func(a, 1)
push_func(b, 1)  # TypeError: wrong pushued val type
push_func(c, 1)  # TypeError: is not IntStackP

C другой стороны, MyPy, в свою очередь, ведет себя более умно, и подсвечивает несовместимость типов:


push_func(a, 1)
push_func(b, 1)  #  Argument 1 to "push_func" has incompatible type "StrStack"; 
                 #  expected "IntStackP"
                 #  Following member(s) of "StrStack" have conflicts:
                 #      _list: expected "List[int]", got "List[str]"
                 #      Expected:
                 #          def push(self, val: int) -> None
                 #      Got:
                 #          def push(self, val: str, weather: Optional[Any] = ...) -> None

Перегрузка операторов


Совсем свежая тема, т.к. при перегрузке операторов с полной типобезопасностью пропадает все веселье. Этот вопрос уже не раз всплывал в баг-треккере MyPy, но он до сих пор кое-где ругается, и его можно смело выключать.
Поясняю ситуацию:


class A:
    def __add__(self, other) -> int:
        return 3

    def __iadd__(self, other) -> 'A':
        if isinstance(other, int):
            return NotImplemented
        return A()

var = A()
var += 3
# Inferred type is 'A', but runtime type is 'int'?

Если метод составного присваивания возвращает NotImplemented, Python ищет сначала __radd__, потом использует __add__, и вуаля.


То же касается и перегрузки любых методов сабклассов вида:


class A:
    def __add__(self, x : 'A') -> 'A': ...

class B(A):
    @overload
    def __add__(self, x : 'A') -> 'A': ...
    @overload
    def __add__(self, x : 'B') -> 'B' : ...

Кое-где предупреждения уже переехали в документацию, кое-где пока срабатывают на проде. Но общее заключение контрибьютеров: оставить такие перегрузки допустимыми.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (10):

  1. acmnu
    /#19642580

    Для меня было открытием понятие "forward reference". https://legacy.python.org/dev/peps/pep-0484/#forward-references


    Оно в вашем посте используется, но не упоминается напрямую.


    class A:
       def b() -> B:
         return B()
    
    class B:
       def a() -> A:
         return A()
    
    a = A()
    b = B()

    Этот код работать не будет:


    Traceback (most recent call last):
      File "_tmp/class.py", line 1, in <module>
        class A:
      File "_tmp/class.py", line 2, in A
        def b() -> B:
    NameError: name 'B' is not defined
    

    А вот это сработает:


    class A:
       def b() -> "B":
         return B()
    
    class B:
       def a() -> "A":
         return A()
    
    a = A()
    b = B()

    Интересно, что даже вот такое не будет работать:


    class A:
       def b() -> A:
         return A()
    
    a = A()

    Собственно поэтому в посте и используются констукции с кавычками:


    T = ty.TypeVar('T')

    • Levitanus
      /#19643366

      Кстати, никогда не проверял, в аннотациях функции они присутствуют как str, или как ForwardRef объекты… И можно ли что-то с ними делать.

    • Ostman
      /#19644558 / +1

      Это будет работать в 3.7 если добавить

      from __future__ import annotations

      С версии 4.0 будет работать по дефолту

  2. mayorovp
    /#19642592

    Если кому интересно — вот почему нет доступа к __orig_class__: в typing.py#L670 сначала создается объект, а потом уже устанавливается атрибут __orig_class__


    Так что никаких хитростей, просто не предусмотрели что эта информация будет кому-то нужна.

    • Levitanus
      /#19643352

      Ну да :)
      Обновил "параметризованный дженерик" в статье на такой, который может вытаскивать тип в __init__. Правда, не уверен, что такая реализация не сломается при следующем обновлении...

  3. sanyaa
    /#19648150

    А может кто-нибудь объяснить вот этот подводный камень:
    На строчку

    foo = {}

    ругается, что error: Need type annotation for variable, а когда добавляешь очевидный хинт
    foo: typing.Dict = {}

    ругаться перестает.
    Вот зачем
    1) ругаться, если и так понятно, что это dict
    2) допускать подавление ошибки таким топорным способом?
    Вопрос конечно очень простой и рядом не стоит с дженериками, но из-за таких простых вещей инструмент кажется каким-то дико недоработанным и прикасаться к нему вообще не хочется.

    • Levitanus
      /#19648166

      Я сейчас со 100% уверенностью не отвечу, потому что так ни разу и не занимался настройкой mypy через конфиг-файл. Мне хватает того, что передает ему Anaconda из ST3.
      Но я почти уверен, что дело в том, что в первом случае MyPy воспринимает выражение как untyped assignement in type context, хоть и выражается короче. В общем и целом, он думает, что вы забыли.
      Во втором случае тип резолвится к `Dict[Any, Any]`. В стандартной поставке это OK, но можно настроить, чтобы он ругался на все места, где фигурирует Any

  4. DRVTiny
    /#19652554

    В Crystal, компилируемом в нормальный машинный код языке, статическая типизация достигается просто и логично, при том. что синтаксис от python-то не сильно отличается (Ruby-like).
    Но здесь — я как начал читать, у меня просто глаза на лоб полезли и волосы зашевелились. И это в интерпретируемом-то языке такая жесть.