Algunas trampas de tipeo estático en Python


Creo que nos estamos acostumbrando lentamente al hecho de que Python tiene anotaciones de tipo: se les devolvieron dos versiones (3.5) en las anotaciones de funciones y métodos ( PEP 484 ), y en la última versión (3.6) a variables ( PEP 526 ).


Dado que estos dos PEP se inspiraron en MyPy , te diré qué alegrías mundanas y disonancias cognitivas me esperaban al usar este analizador estático, así como el sistema de escritura en su conjunto.


Descargo de responsabilidad: no planteo la cuestión de la necesidad o el daño del tipeo estático en Python. Solo estoy hablando de las trampas que encontré mientras trabajaba en un contexto de tipo estático.

Genéricos (mecanografía, genéricos)


Es bueno usar algo como List[int] , Callable[[int, str], None] en las anotaciones.
Es muy agradable cuando el analizador resalta el siguiente 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") 

Sin embargo, ¿qué pasa si escribimos una biblioteca y el programador que la usa no usará un analizador estático?
¿Obligar al usuario a inicializar la clase con un valor y luego almacenar su 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 alguna manera no es fácil de usar.
Pero, ¿y si quieres hacerlo?


 b = Gen[A](B()) 

En busca de una respuesta a esta pregunta, me puse a typing un poco y me sumergí en el mundo de las fábricas.

El hecho es que después de inicializar la instancia de la clase Genérica, obtiene el atributo __origin_class__ , que tiene el atributo __args__ , que es una tupla de tipo. Sin embargo, el acceso a él desde __init__ , así como desde __new__ , no lo es. Además, no está en la metaclase __call__ . Y el truco es que en el momento de la inicialización de la subclase de Generic se convierte en otra metaclase _GenericAlias , que establece el tipo final, ya sea después de que se inicializa el objeto, incluidos todos los métodos de su metaclase, o en el momento en que __getithem__ a __getithem__ . Por lo tanto, no hay forma de obtener tipos genéricos al construir un objeto.


Tiramos esta basura, prometimos una solución más universal.

Por lo tanto, escribí un pequeño descriptor que resuelve este 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 

Por supuesto, en consecuencia, será necesario reescribir para un uso más universal, pero la esencia es clara.


[UPD]: Por la mañana decidí intentar hacer lo mismo que en el módulo de typing , pero más simple:


 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]: El desarrollador de typing Ivan Levinsky dijo que ambas opciones podrían romperse de manera impredecible.


De todos modos, puedes usarlo de cualquier manera. Quizás __class_getitem__ es incluso un poco mejor, al menos __class_getitem__ es un método especial documentado (aunque su comportamiento para los genéricos no lo es).

Funciones y alias


Sí, los genéricos no son fáciles en absoluto:
Por ejemplo, si en algún lugar aceptamos una función como argumento, su anotación cambia automáticamente de covariante a contravariante:


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

Y en principio, no tengo quejas sobre la lógica, solo esto debe resolverse mediante alias genéricos:


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

En general, la sección sobre la variabilidad de los tipos debe leerse cuidadosamente y más de una vez.


Compatibilidad con versiones anteriores


Esto no está tan de moda: desde la versión 3.7 Generic es una subclase de ABCMeta , que es muy conveniente y buena. Es malo que esto rompa el código si se está ejecutando en 3.6.


Herencia Estructural (Suptyping Stuctural)


Al principio estaba muy feliz: ¡se entregaron las interfaces! El rol de las interfaces lo realiza la clase Protocol del módulo typing_extensions , que, en combinación con el decorador @runtime , le permite verificar si la clase implementa la interfaz sin herencia directa. MyPy también se destaca en un nivel más profundo.


Sin embargo, no noté muchos beneficios prácticos en tiempo de ejecución en comparación con la herencia múltiple.
Parece que el decorador solo verifica la presencia de un método con el nombre requerido, sin siquiera verificar el número de argumentos, sin mencionar la escritura:


 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 otro lado, MyPy, a su vez, se comporta de manera más inteligente y destaca la incompatibilidad de los 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 del operador


Un tema muy nuevo, porque Al sobrecargar a los operadores con seguridad de tipo completo, toda la diversión desaparece. Esta pregunta ha aparecido repetidamente en el rastreador de errores MyPy, pero aún así se insulta en algunos lugares, y puede desactivarla de forma segura.
Les explico la situación:


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

Si el método de asignación compuesto devuelve NotImplemented , Python primero busca __radd__ , luego usa __add__ y voila.


Lo mismo se aplica a la sobrecarga de cualquier método de subclase del formulario:


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

En algunos lugares, las advertencias ya se han trasladado a la documentación, en algunos lugares todavía funcionan en la producción. Pero la conclusión general de los contribuyentes es dejar tales sobrecargas aceptables.

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


All Articles