PEP 698 – Override-Dekorator für statische Typisierung
- Autor:
- Steven Troxler <steven.troxler at gmail.com>, Joshua Xu <jxu425 at fb.com>, Shannon Zhu <szhu at fb.com>
- Sponsor:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Typ:
- Standards Track
- Thema:
- Typisierung
- Erstellt:
- 05. Sep. 2022
- Python-Version:
- 3.12
- Post-History:
- 20. Mai 2022, 17. Aug. 2022, 11. Okt. 2022, 07. Nov. 2022
- Resolution:
- Discourse-Nachricht
Zusammenfassung
Dieses PEP schlägt die Hinzufügung eines @override-Dekorators zum Python-Typsystem vor. Dies wird es Typüberprüfern ermöglichen, eine Klasse von Fehlern zu verhindern, die auftreten, wenn eine Basisklasse Methoden ändert, die von abgeleiteten Klassen geerbt werden.
Motivation
Ein Hauptzweck von Typüberprüfern ist es, darauf hinzuweisen, wenn Refactorings oder Änderungen vorbestehende semantische Strukturen im Code brechen, damit Benutzer diese in ihrem Projekt identifizieren und beheben können, ohne eine manuelle Prüfung ihres Codes durchführen zu müssen.
Sicheres Refactoring
Pythons Typsystem bietet keine Möglichkeit, Aufrufstellen zu identifizieren, die geändert werden müssen, um konsistent zu bleiben, wenn sich die API einer überschriebenen Funktion ändert. Dies macht das Refactoring und die Transformation von Code gefährlicher.
Betrachten Sie diese einfache Vererbungsstruktur
class Parent:
def foo(self, x: int) -> int:
return x
class Child(Parent):
def foo(self, x: int) -> int:
return x + 1
def parent_callsite(parent: Parent) -> None:
parent.foo(1)
def child_callsite(child: Child) -> None:
child.foo(1)
Wenn die überschriebene Methode in der Superklasse umbenannt oder gelöscht wird, weisen uns Typüberprüfer nur darauf hin, Aufrufstellen zu aktualisieren, die direkt mit dem Basistyp arbeiten. Aber der Typüberprüfer kann nur den neuen Code sehen, nicht die Änderung, die wir vorgenommen haben, sodass er keine Möglichkeit hat zu wissen, dass wir wahrscheinlich auch die gleiche Methode in Kindklassen umbenennen mussten.
Ein Typüberprüfer akzeptiert diesen Code gerne, obwohl wir wahrscheinlich Fehler einführen
class Parent:
# Rename this method
def new_foo(self, x: int) -> int:
return x
class Child(Parent):
# This (unchanged) method used to override `foo` but is unrelated to `new_foo`
def foo(self, x: int) -> int:
return x + 1
def parent_callsite(parent: Parent) -> None:
# If we pass a Child instance we’ll now run Parent.new_foo - likely a bug
parent.new_foo(1)
def child_callsite(child: Child) -> None:
# We probably wanted to invoke new_foo here. Instead, we forked the method
child.foo(1)
Dieser Code wird typsicher, aber es gibt zwei potenzielle Fehlerquellen
- Wenn wir eine
Child-Instanz an die Funktionparent_callsiteübergeben, wird die Implementierung inParent.new_fooanstelle vonChild.fooaufgerufen. Dies ist wahrscheinlich ein Fehler - wir hättenChild.foowahrscheinlich nicht geschrieben, wenn wir kein benutzerdefiniertes Verhalten benötigen würden. - Unser System verließ sich wahrscheinlich darauf, dass
Child.foosich ähnlich wieParent.fooverhält. Aber es sei denn, wir fangen dies frühzeitig ab, haben wir nun die Methoden gegabelt, und in zukünftigen Refactorings werden wahrscheinlich niemandem auffallen, dass wichtige Änderungen am Verhalten vonnew_foowahrscheinlich auch die Aktualisierung vonChild.fooerfordern, was später zu größeren Fehlern führen könnte.
Der falsch refaktorierte Code ist typsicher, aber wahrscheinlich nicht das, was wir beabsichtigt haben, und könnte dazu führen, dass unser System falsch funktioniert. Der Fehler kann schwer zu verfolgen sein, da unser neuer Code wahrscheinlich ohne Ausnahmen ausgeführt wird. Tests fangen das Problem weniger wahrscheinlich ab, und stille Fehler können in der Produktion länger dauern, bis sie gefunden werden.
Wir sind uns mehrerer Produktionsausfälle in mehreren typisierten Codebasen bewusst, die durch solche fehlerhaften Refactorings verursacht wurden. Dies ist unsere Hauptmotivation für die Hinzufügung eines @override-Dekorators zum Typsystem, der es Entwicklern ermöglicht, die Beziehung zwischen Parent.foo und Child.foo auszudrücken, damit Typüberprüfer das Problem erkennen können.
Begründung
Unterklassen-Implementierungen werden expliziter
Wir glauben, dass explizite Overrides das Lesen von unbekanntem Code erleichtern als implizite Overrides. Ein Entwickler, der die Implementierung einer Unterklasse liest, die @override verwendet, kann sofort sehen, welche Methoden Funktionalitäten in einer Basisklasse überschreiben; ohne diesen Dekorator ist der einzige Weg, dies schnell herauszufinden, die Verwendung eines statischen Analysewerkzeugs.
Präzedenzfall in anderen Sprachen und Laufzeitbibliotheken
Statische Override-Prüfungen in anderen Sprachen
Viele beliebte Programmiersprachen unterstützen Override-Prüfungen. Zum Beispiel
- C++ hat
override. - C# hat
override. - Hack hat
<<__Override>>. - Java hat
@Override. - Kotlin hat
override. - Scala hat
override. - Swift hat
override. - TypeScript hat
override.
Laufzeit-Override-Prüfungen in Python
Heute gibt es eine Overrides-Bibliothek, die Dekoratoren @overrides [sic] und @final bereitstellt und diese zur Laufzeit erzwingt.
PEP 591 fügte einen @final-Dekorator mit denselben Semantiken wie die der Overrides-Bibliothek hinzu. Aber die Override-Komponente der Laufzeitbibliothek wird überhaupt nicht statisch unterstützt, was zu einiger Verwirrung über die gemischte Unterstützung geführt hat.
Die Bereitstellung von Unterstützung für @override in statischen Prüfungen würde einen Mehrwert bringen, da
- Fehler früher, oft in der IDE, erkannt werden können.
- Statische Prüfungen mit keinen Leistungseinbußen verbunden sind, im Gegensatz zu Laufzeitprüfungen.
- Fehler auch in selten genutzten Modulen schnell erkannt werden, während sie bei Laufzeitprüfungen ohne automatisierte Tests aller Importe eine Zeit lang unentdeckt bleiben könnten.
Nachteile
Die Verwendung von @override den Code ausführlicher macht.
Spezifikation
Wenn Typüberprüfer auf eine Methode stoßen, die mit @typing.override dekoriert ist, sollten sie dies als Typreview behandeln, es sei denn, diese Methode überschreibt eine kompatible Methode oder ein Attribut in einer Oberklasse.
from typing import override
class Parent:
def foo(self) -> int:
return 1
def bar(self, x: str) -> str:
return x
class Child(Parent):
@override
def foo(self) -> int:
return 2
@override
def baz(self) -> int: # Type check error: no matching signature in ancestor
return 1
Der @override-Dekorator sollte überall dort zulässig sein, wo ein Typüberprüfer eine Methode als gültigen Override betrachtet, was typischerweise nicht nur normale Methoden, sondern auch @property, @staticmethod und @classmethod einschließt.
Keine neuen Regeln für Override-Kompatibilität
Dieses PEP befasst sich ausschließlich mit der Behandlung des neuen @override-Dekorators, der angibt, dass die dekorierte Methode ein Attribut in einer Oberklasse überschreiben muss. Dieses PEP schlägt keine neuen Regeln bezüglich der Typsignaturen solcher Methoden vor.
Strikte Durchsetzung pro Projekt
Wir glauben, dass @override am nützlichsten ist, wenn Überprüfer Entwicklern auch erlauben, in einen strengen Modus zu wechseln, in dem Methoden, die eine Elternklasse überschreiben, den Dekorator verwenden müssen. Die strikte Durchsetzung sollte aus Gründen der Abwärtskompatibilität opt-in sein.
Motivation
Der Hauptgrund für einen strikten Modus, der @override erfordert, ist, dass Entwickler nur darauf vertrauen können, dass Refactorings override-sicher sind, wenn sie wissen, dass der @override-Dekorator im gesamten Projekt verwendet wird.
Es gibt eine weitere Klasse von Fehlern im Zusammenhang mit Overrides, die wir nur mit einem strikten Modus erfassen können.
Betrachten Sie den folgenden Code
class Parent:
pass
class Child(Parent):
def foo(self) -> int:
return 2
Stellen Sie sich vor, wir refaktorisieren ihn wie folgt
class Parent:
def foo(self) -> int: # This method is new
return 1
class Child(Parent):
def foo(self) -> int: # This is now an override!
return 2
def call_foo(parent: Parent) -> int:
return parent.foo() # This could invoke Child.foo, which may be surprising.
Die Semantik unseres Codes hat sich hier geändert, was zwei Probleme verursachen könnte
- Wenn der Autor der Codeänderung nicht wusste, dass
Child.foobereits existierte (was in einer großen Codebasis sehr gut möglich ist), könnte er überrascht sein zu sehen, dasscall_foonicht immerParent.fooaufruft. - Wenn die Autoren der Codebasis versucht hätten,
@overridemanuell überall anzuwenden, wenn sie Overrides in Unterklassen schreiben, werden sie wahrscheinlich übersehen, dassChild.foodies hier benötigt.
Auf den ersten Blick mag diese Art von Änderung unwahrscheinlich erscheinen, aber sie kann tatsächlich oft vorkommen, wenn ein oder mehrere Unterklassen Funktionalität haben, die Entwickler später erkennen, dass sie in die Basisklasse gehört.
Mit einem strikten Modus werden wir Entwickler immer benachrichtigen, wenn dies geschieht.
Präzedenzfall
Die meisten der typisierten, objektorientierten Programmiersprachen, die wir uns angesehen haben, haben eine einfache Möglichkeit, explizite Overrides im gesamten Projekt zu verlangen.
- C#, Kotlin, Scala und Swift erfordern immer explizite Overrides.
- TypeScript hat eine –no-implicit-override Flagge, um explizite Overrides zu erzwingen.
- In Hack und Java behandelt der Typüberprüfer Overrides immer als opt-in, aber weit verbreitete Linter können warnen, wenn explizite Overrides fehlen.
Abwärtskompatibilität
Standardmäßig ist der @override-Dekorator opt-in. Codebasen, die ihn nicht verwenden, werden wie bisher typsicher geprüft, ohne die zusätzliche Typsicherheit.
Laufzeitverhalten
Setze __override__ = True, wenn möglich
Zur Laufzeit versucht @typing.override nach besten Kräften, ein Attribut __override__ mit dem Wert True zu seinem Argument hinzuzufügen. Mit "nach besten Kräften" meinen wir, dass wir versuchen werden, das Attribut hinzuzufügen, aber wenn das fehlschlägt (z. B. weil die Eingabe ein Deskriptortyp mit festen Slots ist), geben wir das Argument stillschweigend zurück, wie es ist.
Dies ist genau das, was der @typing.final-Dekorator tut, und die Motivation ist ähnlich: er gibt Laufzeitbibliotheken die Möglichkeit, @override zu verwenden. Als konkretes Beispiel könnte eine Laufzeitbibliothek __override__ prüfen, um das __doc__-Attribut von Kindklassenmethoden automatisch mit dem Docstring der Elternmethode zu füllen.
Einschränkungen beim Setzen von __override__
Wie oben beschrieben, kann das Hinzufügen von __override__ zur Laufzeit fehlschlagen. In diesem Fall geben wir das Argument einfach unverändert zurück.
Außerdem kann es selbst in Fällen, in denen es funktioniert, schwierig für Benutzer sein, korrekt mit mehreren Dekoratoren zu arbeiten, da die erfolgreiche Sicherstellung, dass das __override__-Attribut im endgültigen Ergebnis gesetzt ist, das Verständnis der Implementierung jedes Dekorators erfordert.
- Der
@override-Dekorator muss *nach* gewöhnlichen Dekoratoren wie@functools.lru_cacheausgeführt werden, die Wrapper-Funktionen verwenden, da wir__override__auf den äußersten Wrapper setzen möchten. Das bedeutet, er muss über all diesen anderen Dekoratoren stehen. - Aber
@overridemuss *vor* vielen speziellen deskriptorbasierten Dekoratoren wie@property,@staticmethodund@classmethodausgeführt werden. - Wie oben diskutiert, kann es in einigen Fällen (z. B. ein Deskriptor mit festen Slots oder ein Deskriptor, der ebenfalls wrapt) unmöglich sein, das
__override__-Attribut überhaupt zu setzen.
Daher ist die Laufzeitunterstützung für das Setzen von __override__ nur best effort, und wir erwarten nicht, dass Typüberprüfer die Reihenfolge der Dekoratoren validieren.
Abgelehnte Alternativen
Verlasse dich auf integrierte Entwicklungsumgebungen für Sicherheit
Moderne integrierte Entwicklungsumgebungen (IDEs) bieten oft die Möglichkeit, Unterklassen beim Umbenennen einer Methode automatisch zu aktualisieren. Aber wir halten das aus mehreren Gründen für unzureichend.
- Wenn eine Codebasis in mehrere Projekte aufgeteilt ist, hilft eine IDE nicht, und der Fehler tritt beim Upgrade von Abhängigkeiten auf. Typüberprüfer sind ein schneller Weg, um fehlerhafte Änderungen in Abhängigkeiten zu erkennen.
- Nicht alle Entwickler nutzen solche IDEs. Und Bibliotheksbetreuer müssen, auch wenn sie eine IDE nutzen, nicht davon ausgehen, dass Pull-Request-Autoren dieselbe IDE nutzen. Wir ziehen es vor, Probleme in der kontinuierlichen Integration erkennen zu können, ohne Annahmen über die Wahl des Editors der Entwickler zu treffen.
Laufzeiterzählung
Wir haben erwogen, dass @typing.override die Override-Sicherheit zur Laufzeit erzwingt, ähnlich wie es @overrides.overrides heute tut.
Wir haben dies aus vier Gründen abgelehnt.
- Für Benutzer der statischen Typüberprüfung ist unklar, ob dies Vorteile bringt.
- Es gäbe mindestens einige Leistungseinbußen, was dazu führt, dass Projekte mit Laufzeiterzählung langsamer importieren. Wir schätzen, dass die Implementierung von
@overrides.overridesetwa 100 Mikrosekunden benötigt, was schnell ist, aber immer noch zu einer zusätzlichen Initialisierungszeit von einer Sekunde oder mehr in Codebasen mit über einer Million Zeilen führen könnte, genau dort, wo wir denken, dass@typing.overrideam nützlichsten sein wird. - Eine Implementierung kann Randfälle haben, in denen sie nicht gut funktioniert (wir haben von einem Betreuer einer solchen Closed-Source-Bibliothek gehört, dass dies ein Problem war). Wir erwarten, dass die statische Erzwingung einfach und zuverlässig sein wird.
- Die Implementierungsansätze, die wir kennen, sind nicht einfach. Der Dekorator wird ausgeführt, bevor die Klasse vollständig ausgewertet ist, sodass die uns bekannten Optionen entweder die Bytecode-Analyse des Aufrufers sind (wie
@overrides.overridestut) oder die Verwendung eines Metaklassen-basierten Ansatzes. Keiner der Ansätze scheint ideal.
Markiere eine Basisklasse, um explizite Overrides auf Unterklassen zu erzwingen
Wir haben erwogen, einen Klassen-Dekorator @require_explicit_overrides einzubinden, der eine Möglichkeit für Basisklassen bieten würde, zu deklarieren, dass alle Unterklassen den @override-Dekorator bei Method-Overrides verwenden müssen. Die Overrides-Bibliothek hat eine Mixin-Klasse, EnforceExplicitOverrides, die ein ähnliches Verhalten bei Laufzeitprüfungen bietet.
Wir haben uns dagegen entschieden, da wir erwarten, dass Besitzer großer Codebasen am meisten von @override profitieren werden, und für diese Anwendungsfälle bietet ein strikter Modus, in dem explizites @override erforderlich ist (siehe Abschnitt Abwärtskompatibilität), mehr Vorteile als eine Möglichkeit, Basisklassen zu markieren.
Darüber hinaus glauben wir, dass Autoren von Projekten, die den zusätzlichen Typsicherheitsvorteil nicht als zusätzlichen Aufwand durch die Verwendung von @override betrachten, nicht dazu gezwungen werden sollten. Ein optionaler strikter Modus gibt Projektbesitzern die Entscheidung, während die Verwendung von @require_explicit_overrides in Bibliotheken Projektbesitzer zwingen würde, @override zu verwenden, auch wenn sie es vorziehen würden, dies nicht zu tun.
Gib den Namen der zu überschreibenden Oberklasse an
Wir haben in Erwägung gezogen, dem Aufrufer von @override zu erlauben, eine bestimmte Oberklasse anzugeben, in der die überschriebene Methode definiert werden soll.
class Parent0:
def foo(self) -> int:
return 1
class Parent1:
def bar(self) -> int:
return 1
class Child(Parent0, Parent1):
@override(Parent0) # okay, Parent0 defines foo
def foo(self) -> int:
return 2
@override(Parent0) # type error, Parent0 does not define bar
def bar(self) -> int:
return 2
Dies könnte für die Lesbarkeit des Codes nützlich sein, da es die Override-Struktur für tiefe Vererbungsbäume expliziter macht. Es könnte auch Fehler abfangen, indem es Entwickler auffordert zu prüfen, ob die Implementierung eines Overrides immer noch Sinn ergibt, wenn eine überschriebene Methode von einer Basisklasse in eine andere verschoben wird.
Wir haben uns dagegen entschieden, weil
- Die Unterstützung dafür würde die Implementierung von sowohl
@overrideals auch der Typüberprüfer-Unterstützung dafür komplexer machen, sodass erhebliche Vorteile erforderlich wären. - Wir glauben, dass es selten verwendet und relativ wenige Fehler abfangen würde.
- Der Autor des Overrides-Pakets hat bemerkte, dass frühe Versionen seiner Bibliothek diese Funktion enthielten, sie aber selten nützlich war und wenig Nutzen zu haben schien. Nachdem sie entfernt wurde, wurde die Funktion nie von Benutzern angefordert.
Referenzimplementierung
Pyre: Ein Proof of Concept ist in Pyre implementiert.
- Der Dekorator @pyre_extensions.override kann Overrides markieren.
- Pyre kann diesen Dekorator typsicher prüfen, wie in diesem PEP spezifiziert.
Urheberrecht
Dieses Dokument wird in die Public Domain oder unter die CC0-1.0-Universal-Lizenz gestellt, je nachdem, welche Lizenz permissiver ist.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0698.rst
Zuletzt geändert: 2024-06-11 22:12:09 GMT