Несколько подводных камней статической типизации в Python
Думаю, мы все потихоньку уже привыкаем, что у Python есть аннотации типов: их завезли два релиза назад (3.5) в аннотации функций и методов (PEP 484), и в прошлом релизе (3.6) к переменным (PEP 526).
Так как оба этих PEP были вдохновлены MyPy, расскажу, какие житейские радости и когнитивные диссонансы подстерегали меня при использовании этого статического анализатора, равно как и системы типизации в целом.
Disclamer: я не поднимаю вопрос о необходимости или вредности статической типизациии в Python. Просто рассказываю о подводных камнях, на которые натолкнулся в процессе создания дженерик-классов.
Приятно пользоваться в аннотациях чем-то вроде 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 , pasted 0
Конечно, в последствие, надо будет переписать для более универсального использования, но суть понятна.
Функции и алиасы
Да, с дженериками вообще не просто:
К примеру, если мы где-то принимаем функцию как аргумент, то ее аннотация автоматически превращается из ковариантной в контрвариантную:
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.
Сначала очень обрадовался: интерфейсы завезли! Роль интерфейсов выполняет класс 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' : ...
Кое-где предупреждения уже переехали в документацию, кое-где пока срабатывают на проде. Но общее заключение осталось оставить такие перегрузки допустимыми.