
Думаю, мы все потихоньку уже привыкаем, что у 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 IntStackPC другой стороны, 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)

mayorovp
22.01.2019 09:06Если кому интересно — вот почему нет доступа к
__orig_class__: в typing.py#L670 сначала создается объект, а потом уже устанавливается атрибут__orig_class__
Так что никаких хитростей, просто не предусмотрели что эта информация будет кому-то нужна.

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

sanyaa
23.01.2019 07:29А может кто-нибудь объяснить вот этот подводный камень:
На строчку
foo = {}
ругается, что error: Need type annotation for variable, а когда добавляешь очевидный хинт
foo: typing.Dict = {}
ругаться перестает.
Вот зачем
1) ругаться, если и так понятно, что это dict
2) допускать подавление ошибки таким топорным способом?
Вопрос конечно очень простой и рядом не стоит с дженериками, но из-за таких простых вещей инструмент кажется каким-то дико недоработанным и прикасаться к нему вообще не хочется.
Levitanus Автор
23.01.2019 07:33Я сейчас со 100% уверенностью не отвечу, потому что так ни разу и не занимался настройкой mypy через конфиг-файл. Мне хватает того, что передает ему Anaconda из ST3.
Но я почти уверен, что дело в том, что в первом случае MyPy воспринимает выражение как untyped assignement in type context, хоть и выражается короче. В общем и целом, он думает, что вы забыли.
Во втором случае тип резолвится к `Dict[Any, Any]`. В стандартной поставке это OK, но можно настроить, чтобы он ругался на все места, где фигурирует Any

DRVTiny
23.01.2019 19:37В Crystal, компилируемом в нормальный машинный код языке, статическая типизация достигается просто и логично, при том. что синтаксис от python-то не сильно отличается (Ruby-like).
Но здесь — я как начал читать, у меня просто глаза на лоб полезли и волосы зашевелились. И это в интерпретируемом-то языке такая жесть.
          
 
acmnu
Для меня было открытием понятие "forward reference". https://legacy.python.org/dev/peps/pep-0484/#forward-references
Оно в вашем посте используется, но не упоминается напрямую.
Этот код работать не будет:
А вот это сработает:
Интересно, что даже вот такое не будет работать:
Собственно поэтому в посте и используются констукции с кавычками:
Levitanus Автор
Кстати, никогда не проверял, в аннотациях функции они присутствуют как
str, или какForwardRefобъекты… И можно ли что-то с ними делать.Ostman
Это будет работать в 3.7 если добавить
С версии 4.0 будет работать по дефолту