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


    Думаю, мы все потихоньку уже привыкаем, что у 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' : ...

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

    Поделиться публикацией

    Комментарии 13

      +1

      Для меня было открытием понятие "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')
        0

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

          +2
          Это будет работать в 3.7 если добавить
          from __future__ import annotations

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

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


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

            0

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

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

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

            ругаться перестает.
            Вот зачем
            1) ругаться, если и так понятно, что это dict
            2) допускать подавление ошибки таким топорным способом?
            Вопрос конечно очень простой и рядом не стоит с дженериками, но из-за таких простых вещей инструмент кажется каким-то дико недоработанным и прикасаться к нему вообще не хочется.
              0
              Я сейчас со 100% уверенностью не отвечу, потому что так ни разу и не занимался настройкой mypy через конфиг-файл. Мне хватает того, что передает ему Anaconda из ST3.
              Но я почти уверен, что дело в том, что в первом случае MyPy воспринимает выражение как untyped assignement in type context, хоть и выражается короче. В общем и целом, он думает, что вы забыли.
              Во втором случае тип резолвится к `Dict[Any, Any]`. В стандартной поставке это OK, но можно настроить, чтобы он ругался на все места, где фигурирует Any
              0
              В Crystal, компилируемом в нормальный машинный код языке, статическая типизация достигается просто и логично, при том. что синтаксис от python-то не сильно отличается (Ruby-like).
              Но здесь — я как начал читать, у меня просто глаза на лоб полезли и волосы зашевелились. И это в интерпретируемом-то языке такая жесть.
                0

                Благо, что можно не использовать всю "такую жесть", например, с дженериками. Если не хочется упарываться по типизации всего и вся, достаточно указания базовых типов в аннотациях аргументов и переменных где это действительно нужно, например, для помощи линтерам и автокомплитерам.


                И кстати, автор забыл упомянуть, что хоть тайпхинты и typing — это в основном не про рантайм, но свой оверхед всё это добро всё же накладывает, причем в 3.7 этот модуль ускоряли.

                  0

                  в принципе, согласен. И Вы правы, но не так уж и накладно:


                  import cProfile
                  from pyksp.base_types import Var, Type
                  
                  def test(n):
                      for i in range(n):
                          o = Var[int](local=True, name=f'name{i}')
                          isinstance(o, Type[int])
                  
                  cProfile.run('test(100000)')

                           2800010 function calls (2600010 primitive calls) in 1.610 seconds
                  
                     Ordered by: standard name
                  
                     ncalls  tottime  percall  cumtime  percall filename:lineno(function)
                          1    0.000    0.000    1.610    1.610 <string>:1(<module>)
                     300000    0.052    0.000    0.144    0.000 abc.py:137(__instancecheck__)
                     100003    0.021    0.000    0.041    0.000 abc.py:141(__subclasscheck__)
                     100000    0.024    0.000    0.024    0.000 abstract.py:523(__init__)
                     100000    0.039    0.000    0.131    0.000 abstract.py:611(__init__)
                     100000    0.322    0.000    1.065    0.000 base_types.py:292(__call__)
                     100000    0.031    0.000    0.031    0.000 base_types.py:340(__getitem__)
                     100000    0.079    0.000    0.254    0.000 base_types.py:350(__instancecheck__)
                     100000    0.018    0.000    0.018    0.000 base_types.py:365(__getitem__)
                     100000    0.240    0.000    0.491    0.000 base_types.py:450(__init__)
                     100000    0.048    0.000    0.048    0.000 base_types.py:473(_after_init)
                     100000    0.031    0.000    0.038    0.000 base_types.py:477(_get_type_prefix)
                     100000    0.064    0.000    0.555    0.000 base_types.py:588(__init__)
                          1    0.194    0.194    1.610    1.610 simple_test.py:5(test)
                     100000    0.090    0.000    0.105    0.000 typing.py:806(__new__)
                     100000    0.010    0.000    0.010    0.000 typing.py:890(cast)
                     100000    0.015    0.000    0.015    0.000 {built-in method __new__ of type object at 0x61D68078}
                     300000    0.092    0.000    0.092    0.000 {built-in method _abc._abc_instancecheck}
                     100003    0.020    0.000    0.020    0.000 {built-in method _abc._abc_subclasscheck}
                          1    0.000    0.000    1.610    1.610 {built-in method builtins.exec}
                  400000/200000    0.163    0.000    0.394    0.000 {built-in method builtins.isinstance}
                     300000    0.056    0.000    0.098    0.000 {built-in method builtins.issubclass}
                          1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}
                  
                  [Finished in 1.8s]
                0

                Может быть кто-то знает как заставить MyPy понимать Enum'ы? Чтобы передавался Атрибут класса как тип, а не возвращаемое им значение?


                К примеру, поведение в случаях 1 и 2 неверно.


                class Some(Enum):
                   ONE: int
                   TWO: text
                
                # 1 в этом случае тип принимаемой переменной определиться как int
                def func(some: Some.ONE): pass
                # 2 в этом случае, тип определиться как целый объект Some
                def func(some: Some): pass

                Как сделать, что бы MyPy понимал что принимают что я Some.ONE это отдельный тип?

                  0

                  не совсем понимаю, почему он должен воспринимать атрибут как тип. Это было бы как раз неправильное поведение))


                  Я в таких случаях обычно делаю что-то вроде этого:


                  from enum import Enum
                  import typing as ty
                  
                  ONET = ty.NewType('ONET', int)
                  
                  class Some(Enum):
                      ONE: ONET
                      TWO: text
                  
                  # 1 в этом случае тип принимаемой переменной определиться как int
                  def func(some: ONET):
                      reveal_type(some) # Revealed type is 'pyksp.simple_test.ONET'
                  
                  # 2 в этом случае, тип определиться как целый объект Some
                  def func(some: Some):
                      pass

                  Ну а если он должен быть любым int так и делайте int. Или если хотите иметь возможность заменить тип внутри класса и во всех аннотациях с ним связанных – делаете алиас

                    0

                    Вы правы, говорить что поведение неверное не совсем правильно :) Это я на джаву насмотрелся где подобным образом определяется тип enum, тип как объект указывается. Спасибо за пример!

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое