Algumas armadilhas da digitação estática em Python


Acho que estamos nos acostumando lentamente ao fato de o Python ter anotações de tipo: elas foram trazidas de volta dois releases (3.5) nas anotações de funções e métodos ( PEP 484 ) e, no último release (3.6), às variáveis ​​( PEP 526 ).


Como os dois PEPs foram inspirados pelo MyPy , vou lhe dizer quais alegrias mundanas e dissonâncias cognitivas me aguardavam ao usar este analisador estático, bem como o sistema de digitação como um todo.


Disclamer: Não levanto a questão da necessidade ou nocividade da digitação estática em Python. Estou apenas falando das armadilhas que me deparei enquanto trabalhava em um contexto estaticamente digitado.

Genéricos (digitando.Generic)


É bom usar algo como List[int] , Callable[[int, str], None] nas anotações.
É muito bom quando o analisador destaca o seguinte código:


 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") 

No entanto, e se escrevermos uma biblioteca e o programador que a usar não usará um analisador estático?
Forçando o usuário a inicializar a classe com um valor e, em seguida, armazene seu tipo?


 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) 

De alguma forma, não é fácil de usar.
Mas e se você quiser fazer isso?


 b = Gen[A](B()) 

Em busca de uma resposta para essa pergunta, repassei um pouco a typing e mergulhei no mundo das fábricas.

O fato é que, após inicializar a instância da classe Generic, ela possui o atributo __origin_class__ , que possui o atributo __args__ , que é uma tupla de tipo. No entanto, o acesso a partir de __init__ , bem como de __new__ , não é. Também não está na metaclasse __call__ . E o truque é que, no momento da inicialização da subclasse de Generic ele se transforma em outra metaclasse _GenericAlias , que define o tipo final, depois que o objeto é inicializado, incluindo todos os métodos de sua metaclasse, ou no momento em que __getithem__ . Portanto, não há como obter tipos genéricos ao construir um objeto.


Jogamos esse lixo, prometeu uma solução mais universal.

Portanto, escrevi para mim um pequeno descritor que resolve esse problema:


 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 

Obviamente, em conseqüência, será necessário reescrever para uso mais universal, mas a essência é clara.


[UPD]: De manhã, decidi tentar fazer o mesmo que no próprio módulo de typing , mas mais simples:


 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]: O desenvolvedor de typing Ivan Levinsky disse que as duas opções podem quebrar imprevisivelmente.


De qualquer forma, você pode usar de qualquer maneira. Talvez __class_getitem__ seja ainda um pouco melhor, pelo menos __class_getitem__ é um método especial documentado (embora seu comportamento para genéricos não seja).

Funções e Aliases


Sim, os genéricos não são nada fáceis:
Por exemplo, se em algum lugar aceitarmos uma função como argumento, sua anotação passa automaticamente de covariante para contravariante:


 class A: pass class B(A): pass def foo(arg: 'A') -> None: #   A  B ... def bar(f: Callable[['A'], None]): #       A ... 

E, em princípio, não tenho queixas sobre lógica, apenas isso deve ser resolvido através de aliases genéricos:


 TA = TypeVar('TA', bound='A') def foo(arg: 'B') -> None: #   B   ... def bar(f: Callable[['TA'], None]): #     A  B ... 

Em geral, a seção sobre variabilidade de tipos deve ser lida com cuidado e mais de uma vez.


Compatibilidade com versões anteriores


Isso não é tão bom: da versão 3.7 Generic é uma subclasse do ABCMeta , que é muito conveniente e boa. É ruim que isso quebre o código se estiver sendo executado no 3.6.


Herança Estrutural (Suputagem Estrutural)


No começo fiquei muito feliz: as interfaces foram entregues! O papel das interfaces é desempenhado pela classe Protocol do módulo typing_extensions , que, em combinação com o decorador @runtime , permite verificar se a classe implementa a interface sem herança direta. O MyPy também é destacado em um nível mais profundo.


No entanto, não notei muitos benefícios práticos no tempo de execução em comparação à herança múltipla.
Parece que o decorador verifica apenas a presença de um método com o nome necessário, sem mesmo verificar o número de argumentos, sem mencionar a digitação:


 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 

Por outro lado, o MyPy, por sua vez, se comporta de maneira mais inteligente e destaca a incompatibilidade dos tipos:


 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 

Sobrecarga do operador


Um tópico muito recente, porque ao sobrecarregar os operadores com toda a segurança do tipo, toda a diversão desaparece. Essa pergunta apareceu várias vezes no rastreador de erros do MyPy, mas ainda é difícil em alguns lugares, e você pode desativá-lo com segurança.
Eu explico a situação:


 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'? 

Se o método de atribuição composto retornar NotImplemented , o Python procurará primeiro __radd__ , depois usará __add__ e voila.


O mesmo se aplica à sobrecarga de qualquer método de subclasse do formulário:


 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' : ... 

Em alguns lugares, os avisos já foram movidos para a documentação, em alguns locais eles ainda trabalham no produto. Mas a conclusão geral dos colaboradores é deixar essas sobrecargas aceitáveis.

Source: https://habr.com/ru/post/pt437018/


All Articles