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

в 22:28, , рубрики: generics, mypy, python, статический анализ кода

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

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

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

Автор: Levitanus

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js