
Думаю, мы все потихоньку уже привыкаем, что у 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' : ...
Кое-где предупреждения уже переехали в документацию, кое-где пока срабатывают на проде. Но общее заключение контрибьютеров: оставить такие перегрузки допустимыми.
