Quelques pièges de la saisie statique en Python


Je pense que nous nous habituons lentement au fait que Python a des annotations de type: ils ont été ramenés deux versions (3.5) dans les annotations de fonctions et méthodes ( PEP 484 ), et dans la dernière version (3.6) aux variables ( PEP 526 ).


Étant donné que ces deux PPE ont été inspirés par MyPy , je vais vous dire quelles joies mondaines et dissonances cognitives m'attendaient lors de l'utilisation de cet analyseur statique, ainsi que du système de typage dans son ensemble.


Disclamer: Je ne pose pas la question de la nécessité ou de la nocivité du typage statique en Python. Je parle juste des pièges que j'ai rencontrés en travaillant dans un contexte de type statique.

Génériques (typage.Générique)


C'est agréable d'utiliser quelque chose comme List[int] , Callable[[int, str], None] dans les annotations.
C'est très agréable lorsque l'analyseur met en évidence le code suivant:


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

Cependant, que se passe-t-il si nous écrivons une bibliothèque et que le programmeur qui l'utilise n'utilisera pas d'analyseur statique?
Forcer l'utilisateur à initialiser la classe avec une valeur, puis à stocker son type?


 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) 

D'une certaine manière, pas convivial.
Mais si vous voulez le faire?


 b = Gen[A](B()) 

A la recherche d'une réponse à cette question, j'ai parcouru un peu la typing , et plongé dans le monde des usines.

Le fait est qu'après avoir initialisé l'instance de la classe Generic, elle a l'attribut __origin_class__ , qui a l'attribut __args__ , qui est un tuple de type. Cependant, son accès à partir de __init__ , ainsi que de __new__ , ne l'est pas. De plus, il ne fait pas partie de la métaclasse __call__ . Et l'astuce est qu'au moment de l'initialisation de la sous-classe de Generic elle se transforme en une autre métaclasse _GenericAlias , qui définit le type final, soit après que l'objet est initialisé, y compris toutes les méthodes de sa métaclasse, soit au moment où __getithem__ dessus. Ainsi, il n'y a aucun moyen d'obtenir des types génériques lors de la construction d'un objet.


Nous jetons ces ordures, promis une solution plus universelle.

Par conséquent, je me suis écrit un petit descripteur qui résout ce problème:


 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 

Bien sûr, en conséquence, il sera nécessaire de réécrire pour une utilisation plus universelle, mais l'essence est claire.


[UPD]: Le matin, j'ai décidé d'essayer de faire la même chose que dans le module de typing lui-même, mais plus 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]: le développeur de typing Ivan Levinsky a déclaré que les deux options pourraient se casser de façon imprévisible.


Quoi qu'il en soit, vous pouvez utiliser n'importe quel moyen. Peut-être que __class_getitem__ est encore légèrement meilleur, au moins __class_getitem__ est une méthode spéciale documentée (bien que son comportement pour les génériques ne le soit pas).

Fonctions et alias


Oui, les génériques ne sont pas faciles du tout:
Par exemple, si nous acceptons quelque part une fonction comme argument, son annotation passe automatiquement de covariante à contravariante:


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

Et en principe, je n'ai rien à redire sur la logique, seulement cela doit être résolu par des alias génériques:


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

En général, la section sur la variabilité des types doit être lue attentivement et plus d'une fois.


Compatibilité descendante


Ce n'est pas si chaud: à partir de la version 3.7, Generic est une sous-classe d' ABCMeta , ce qui est très pratique et bon. Il est mauvais que cela casse le code s'il fonctionne sur 3.6.


Hérédité structurelle (sur-typage structurel)


Au début j'étais très content: les interfaces étaient livrées! Le rôle des interfaces est assuré par la classe Protocol du module typing_extensions , qui, en combinaison avec le décorateur @runtime , vous permet de vérifier si la classe implémente l'interface sans héritage direct. MyPy est également mis en évidence à un niveau plus profond.


Cependant, je n'ai pas remarqué beaucoup d'avantages pratiques à l'exécution par rapport à l'héritage multiple.
Il semble que le décorateur ne vérifie que la présence d'une méthode avec le nom requis, sans même vérifier le nombre d'arguments, sans parler de la frappe:


 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 

D'un autre côté, MyPy, à son tour, se comporte plus intelligemment et met en évidence l'incompatibilité des types:


 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 

Surcharge de l'opérateur


Un sujet très frais, car lors de la surcharge des opérateurs avec une sécurité totale, tout le plaisir disparaît. Cette question a refait surface à plusieurs reprises dans le suivi des bogues MyPy, mais elle jure toujours à certains endroits, et vous pouvez la désactiver en toute sécurité.
J'explique la situation:


 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 la méthode d'affectation composée renvoie NotImplemented , Python recherche d'abord __radd__ , puis utilise __add__ et le tour est joué.


La même chose s'applique à la surcharge des méthodes de sous-classe du formulaire:


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

À certains endroits, les avertissements ont déjà été transférés dans la documentation, à certains endroits, ils fonctionnent toujours sur la prod. Mais la conclusion générale des contributeurs est de laisser de telles surcharges acceptables.

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


All Articles