Einige Fallstricke bei der statischen Eingabe in Python


Ich denke, wir gewöhnen uns langsam an die Tatsache, dass Python Typanmerkungen hat: Sie wurden zwei Releases (3.5) in den Annotationen von Funktionen und Methoden ( PEP 484 ) und in der letzten Version (3.6) an Variablen ( PEP 526 ) zurückgebracht.


Da diese beiden PEPs von MyPy inspiriert wurden, werde ich Ihnen sagen, welche weltlichen Freuden und kognitiven Dissonanzen mich bei der Verwendung dieses statischen Analysators sowie des gesamten Typisierungssystems erwarteten.


Disclamer: Ich stelle nicht die Frage nach der Notwendigkeit oder Schädlichkeit der statischen Typisierung in Python. Ich spreche nur von den Fallstricken, die mir bei der Arbeit in einem statisch typisierten Kontext begegnet sind.

Generika (typing.Generic)


Es ist schön, etwas wie List[int] , Callable[[int, str], None] in Anmerkungen zu verwenden.
Es ist sehr schön, wenn der Analysator den folgenden Code hervorhebt:


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

Was ist jedoch, wenn wir eine Bibliothek schreiben und der Programmierer, der sie verwendet, keinen statischen Analysator verwendet?
Den Benutzer zwingen, die Klasse mit einem Wert zu initialisieren und dann ihren Typ zu speichern?


 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) 

Irgendwie nicht benutzerfreundlich.
Aber was ist, wenn Sie das wollen?


 b = Gen[A](B()) 

Auf der Suche nach einer Antwort auf diese Frage ging ich ein wenig typing und tauchte in die Welt der Fabriken ein.

Tatsache ist, dass nach dem Initialisieren der Instanz der generischen Klasse das Attribut __args__ Attribut __args__ wird, bei dem es sich um ein Tupel von Typen handelt. Der Zugriff von __init__ sowie von __new__ ist jedoch nicht möglich. Es ist auch nicht in der Metaklasse __call__ . Der Trick besteht darin, dass zum Zeitpunkt der Initialisierung der Unterklasse von Generic andere Metaklasse _GenericAlias , die den endgültigen Typ festlegt, entweder nachdem das Objekt einschließlich aller Methoden seiner Metaklasse initialisiert wurde oder wenn __getithem__ wird. Daher gibt es beim Erstellen eines Objekts keine Möglichkeit, generische Typen abzurufen.


Wir werfen diesen Müll, versprachen eine universellere Lösung.

Deshalb habe ich mir einen kleinen Deskriptor geschrieben, der dieses Problem löst:


 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 

Infolgedessen wird es natürlich notwendig sein, für eine universellere Verwendung umzuschreiben, aber das Wesentliche ist klar.


[UPD]: Am Morgen habe ich beschlossen, dasselbe wie im typing selbst zu versuchen, aber einfacher:


 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]: Der typing Ivan Levinsky sagte, beide Optionen könnten unvorhersehbar brechen.


Wie auch immer, Sie können jeden Weg verwenden. Vielleicht ist __class_getitem__ sogar etwas besser, zumindest ist __class_getitem__ eine dokumentierte Spezialmethode (obwohl dies bei Generika nicht der __class_getitem__ ist).

Funktionen und Aliase


Ja, Generika sind gar nicht so einfach:
Wenn wir beispielsweise irgendwo eine Funktion als Argument akzeptieren, wechselt ihre Annotation automatisch von kovariant zu kontravariant:


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

Und im Prinzip habe ich keine Beschwerden über Logik, nur muss dies durch generische Aliase gelöst werden:


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

Im Allgemeinen muss der Abschnitt über die Variabilität von Typen sorgfältig und mehrmals gelesen werden.


Abwärtskompatibilität


Das ist nicht so heiß: Ab Version 3.7 ist Generic eine Unterklasse von ABCMeta , die sehr praktisch und gut ist. Es ist schlecht, dass dies den Code bricht, wenn es auf 3.6 läuft.


Strukturelle Vererbung (Stuctural Suptyping)


Anfangs war ich sehr glücklich: Die Schnittstellen wurden geliefert! Die Rolle der Schnittstellen wird von der Protocol aus dem Modul @runtime , mit der Sie in Kombination mit dem Dekorator @runtime überprüfen können, ob die Klasse die Schnittstelle ohne direkte Vererbung implementiert. MyPy wird auch auf einer tieferen Ebene hervorgehoben.


Allerdings habe ich in der Laufzeit im Vergleich zur Mehrfachvererbung keinen großen praktischen Nutzen festgestellt.
Es scheint, dass der Dekorateur nur das Vorhandensein einer Methode mit dem erforderlichen Namen überprüft, ohne auch nur die Anzahl der Argumente zu überprüfen, ganz zu schweigen von der Eingabe:


 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 

Auf der anderen Seite verhält sich MyPy wiederum intelligenter und hebt die Inkompatibilität von Typen hervor:


 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 

Bedienerüberlastung


Ein sehr frisches Thema, weil Wenn Bediener mit voller Sicherheit überlastet werden, verschwindet der ganze Spaß. Diese Frage ist im MyPy-Bug-Tracker wiederholt aufgetaucht, schwört jedoch an einigen Stellen, und Sie können sie sicher deaktivieren.
Ich erkläre die 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'? 

Wenn die zusammengesetzte Zuweisungsmethode NotImplemented , sucht Python zuerst nach __radd__ , verwendet dann __add__ und voila.


Gleiches gilt für das Überladen von Unterklassenmethoden des Formulars:


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

An einigen Stellen wurden bereits Warnungen in die Dokumentation verschoben, an einigen Stellen arbeiten sie noch am Produkt. Die allgemeine Schlussfolgerung der Mitwirkenden ist jedoch, solche Überlastungen akzeptabel zu lassen.

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


All Articles