Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

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

Inhaltsverzeichnis

Wichtig

Dieses PEP ist ein historisches Dokument: siehe @override und @typing.override für aktuelle Spezifikationen und Dokumentationen. Kanonische Typisierungsspezifikationen werden auf der Typisierungsspezifikations-Website gepflegt; das Laufzeitverhalten der Typisierung wird in der CPython-Dokumentation beschrieben.

×

Siehe den Prozess zur Aktualisierung der Typ-Spezifikation, um Änderungen an der Typ-Spezifikation vorzuschlagen.

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 Funktion parent_callsite übergeben, wird die Implementierung in Parent.new_foo anstelle von Child.foo aufgerufen. Dies ist wahrscheinlich ein Fehler - wir hätten Child.foo wahrscheinlich nicht geschrieben, wenn wir kein benutzerdefiniertes Verhalten benötigen würden.
  • Unser System verließ sich wahrscheinlich darauf, dass Child.foo sich ähnlich wie Parent.foo verhä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 von new_foo wahrscheinlich auch die Aktualisierung von Child.foo erfordern, 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

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.foo bereits existierte (was in einer großen Codebasis sehr gut möglich ist), könnte er überrascht sein zu sehen, dass call_foo nicht immer Parent.foo aufruft.
  • Wenn die Autoren der Codebasis versucht hätten, @override manuell überall anzuwenden, wenn sie Overrides in Unterklassen schreiben, werden sie wahrscheinlich übersehen, dass Child.foo dies 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_cache ausgefü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 @override muss *vor* vielen speziellen deskriptorbasierten Dekoratoren wie @property, @staticmethod und @classmethod ausgefü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.overrides etwa 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.override am 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.overrides tut) 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 @override als 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.


Quelle: https://github.com/python/peps/blob/main/peps/pep-0698.rst

Zuletzt geändert: 2024-06-11 22:12:09 GMT