PEP 707 – Eine vereinfachte Signatur für __exit__ und __aexit__
- Autor:
- Irit Katriel <irit at python.org>
- Discussions-To:
- Discourse thread
- Status:
- Abgelehnt
- Typ:
- Standards Track
- Erstellt:
- 18. Feb. 2023
- Python-Version:
- 3.12
- Post-History:
- 02. Mär. 2023
- Resolution:
- Discourse-Nachricht
Ablehnungsbescheid
Wir haben die PEP besprochen und entschieden, sie abzulehnen. Unser Gedanke war, dass der magische Aspekt und das Risiko potenzieller Brüche die Vorteile nicht rechtfertigten. Wir sind jedoch voll und ganz dafür, eine potenzielle Context Manager v2 API oder__leave__zu erkunden.
Zusammenfassung
Diese PEP schlägt vor, den Interpreter so anzupassen, dass er Context Manager akzeptiert, deren Methode __exit__() / __aexit__() nur eine einzelne Ausnahmeinstanz entgegennimmt, während die aktuelle Signatur (typ, exc, tb) aus Gründen der Abwärtskompatibilität weiterhin unterstützt wird.
Dieser Vorschlag ist Teil einer laufenden Bemühung, die Redundanz der 3-Element-Ausnahmedarstellung aus der Sprache zu entfernen, ein Relikt früherer Python-Versionen, das die Sprachbenutzer heute verwirrt und gleichzeitig Komplexität und Overhead für den Interpreter hinzufügt.
Die vorgeschlagene Implementierung verwendet Introspektion, die auf die Anforderungen dieses Anwendungsfalls zugeschnitten ist. Die Lösung gewährleistet die Sicherheit der neuen Funktion, indem sie diese nur in nicht mehrdeutigen Fällen unterstützt. Insbesondere wird bei jeder Signatur, die *drei* Argumente akzeptieren *könnte*, davon ausgegangen, dass sie diese erwartet.
Da eine zuverlässige Introspektion von aufrufbaren Objekten in Python derzeit nicht möglich ist, ist die hier vorgeschlagene Lösung insofern begrenzt, als nur die gängigen Arten von Single-Argument-Aufrufbaren als solche identifiziert werden, während einige der esoterischeren weiterhin mit drei Argumenten aufgerufen werden. Diese unvollkommene Lösung wurde unter mehreren unvollkommenen Alternativen im Geiste der Praktikabilität gewählt. Ich hoffe, dass die Diskussion über diese PEP die anderen Optionen untersuchen und uns zum besten Weg führen wird, der darin bestehen könnte, bei unserem unvollkommenen Status quo zu bleiben.
Motivation
In der Vergangenheit wurde eine Ausnahme in vielen Teilen von Python durch ein Tupel aus drei Elementen dargestellt: dem Typ der Ausnahme, ihrem Wert und ihrem Traceback. Obwohl es dafür gute Gründe gab, halten diese Gründe heute nicht mehr stand, da Typ und Traceback nun zuverlässig aus der Ausnahmeinstanz abgeleitet werden können. In den letzten Jahren gab es mehrere Bemühungen, die Darstellung von Ausnahmen zu vereinfachen.
Seit Version 3.10 akzeptieren die Funktionen des traceback-Moduls in CPython PR #70577 entweder ein 3-Tupel wie oben beschrieben oder nur eine Ausnahmeinstanz als einzelnes Argument.
Intern stellt der Interpreter Ausnahmen nicht mehr als Triplett dar. Dies wurde in 3.11 für die behandelte Ausnahme und in 3.12 für die ausgelöste Ausnahme entfernt. Infolgedessen können mehrere APIs, die das Triplett offenlegen, nun durch einfachere Alternativen ersetzt werden.
| Legacy API | Alternative | |
|---|---|---|
| Behandelte Ausnahme abrufen (Python) | sys.exc_info() |
sys.exception() |
| Behandelte Ausnahme abrufen (C) | PyErr_GetExcInfo() |
PyErr_GetHandledException() |
| Behandelte Ausnahme setzen (C) | PyErr_SetExcInfo() |
PyErr_SetHandledException() |
| Ausgelöste Ausnahme abrufen (C) | PyErr_Fetch() |
PyErr_GetRaisedException() |
| Ausgelöste Ausnahme setzen (C) | PyErr_Restore() |
PyErr_SetRaisedException() |
| Ausnahmeinstanz aus dem 3-Tupel erstellen (C) | PyErr_NormalizeException() |
N/A |
Der aktuelle Vorschlag ist ein Schritt in diesem Prozess und betrachtet den Weg für einen weiteren Fall, in dem die 3-Tupel-Darstellung in die Sprache übergegangen ist. Die Motivation für all diese Arbeit ist zweifach.
Vereinfachung der Implementierung der Sprache
Die durch die Reduzierung der internen Darstellung der behandelten Ausnahme im Interpreter auf ein einziges Objekt erzielte Vereinfachung war bedeutend. Zuvor musste der Interpreter drei Elemente auf den/vom Stack pushen/poppen, wann immer er etwas mit Ausnahmen zu tun hatte. Dies erhöhte die Stack-Tiefe (was die Caches und Register belastet) und verkomplizierte einige Bytecodes. Die Reduzierung auf ein Element entfernte etwa 100 Zeilen Code aus ceval.c (die Implementierung der Eval-Schleife des Interpreters) und wurde später gefolgt von der Entfernung des POP_EXCEPT_AND_RERAISE-Opcodes, der so einfach geworden war, dass er durch generische Stack-Manipulationsanweisungen ersetzt werden konnte. Mikro-Benchmarks zeigten eine Beschleunigung von etwa 10 % beim Fangen und Auslösen einer Ausnahme sowie beim Erstellen von Generatoren. Zusammenfassend lässt sich sagen, dass die Entfernung dieser Redundanz in den Interna von Python den Interpreter vereinfachte und ihn schneller machte.
Die Leistung des Aufrufs von __exit__/__aexit__ beim Verlassen eines Context Managers kann ebenfalls durch den Ersatz eines Mehrfachargument-Aufrufs durch einen Einzelfunktionsaufruf verbessert werden. Mikro-Benchmarks zeigten, dass das Betreten und Verlassen eines Context Managers mit einem Einzelfunktions- __exit__ etwa 13 % schneller ist.
Vereinfachung der Sprache selbst
Einer der Gründe für die Popularität von Python ist seine Einfachheit. Das Triplett sys.exc_info() ist für neue Lerner kryptisch, und die Redundanz darin ist verwirrend für diejenigen, die es verstehen.
Es wird mehrere Releases dauern, bis wir an einem Punkt sind, an dem wir das Deprecaten von sys.exc_info() in Erwägung ziehen können. Wir können jedoch relativ schnell einen Stand erreichen, an dem neue Lerner es nicht kennen müssen, oder die 3-Tupel-Darstellung, zumindest bis sie alten Code warten.
Begründung
Der einzige Grund, heute gegen die Entfernung der letzten verbleibenden Erscheinungsformen des 3-Tupels aus der Sprache zu protestieren, sind Bedenken hinsichtlich der Störungen, die solche Änderungen mit sich bringen können. Das Ziel dieser PEP ist es, einen sicheren, schrittweisen und minimal störenden Weg vorzuschlagen, um diese Änderung im Fall von __exit__ vorzunehmen, und damit eine Diskussion über unsere Optionen zur Weiterentwicklung seiner Methodensignatur anzustoßen.
Im Falle der API des traceback-Moduls ist die Weiterentwicklung der Funktionen zu einer hybriden Signatur relativ unkompliziert und sicher. Die Funktionen nehmen ein positionelles und zwei optionale Argumente entgegen und interpretieren sie anhand ihrer Typen. Dies ist sicher, wenn Sentinel für Standardwerte verwendet werden. Die Signaturen von Callbacks, die durch das Programm des Benutzers definiert sind, sind schwieriger weiterzuentwickeln.
Die sicherste Option ist, den Benutzer explizit angeben zu lassen, welche Signatur der Callback erwartet, indem er sie mit einem zusätzlichen Attribut markiert oder ihr einen anderen Namen gibt. Zum Beispiel könnten wir den Interpreter veranlassen, nach einer __leave__-Methode auf dem Context Manager zu suchen und diese mit einem einzelnen Argument aufzurufen, falls sie existiert (andernfalls sucht er nach __exit__ und fährt fort wie bisher). Die hier vorgeschlagene introspektionsbasierte Alternative zielt darauf ab, es für Benutzer einfacher zu machen, neuen Code zu schreiben, da sie einfach die Einzelfunktionsversion verwenden und die Legacy-API ignorieren können. Wenn die Einschränkungen der Introspektion jedoch als zu schwerwiegend erachtet werden, sollten wir eine explizite Option in Betracht ziehen. Das Vorhandensein von sowohl __exit__ als auch __leave__ für 5-10 Jahre mit ähnlicher Funktionalität ist nicht ideal, aber es ist eine Option.
Betrachten wir nun die Einschränkungen des aktuellen Vorschlags. Er identifiziert 2-Argument-Python-Funktionen und METH_O C-Funktionen als Single-Argument-Signatur und geht davon aus, dass alles andere 3 Argumente erwartet. Offensichtlich ist es möglich, Falsch-Negative für diese Heuristik zu erstellen (Single-Argument-Aufrufbare, die sie nicht identifiziert). So geschriebene Context Manager funktionieren nicht, sie werden weiterhin fehlschlagen, wie sie es jetzt tun, wenn ihre __exit__-Funktion mit drei Argumenten aufgerufen wird.
Ich glaube nicht, dass das ein Problem in der Praxis sein wird. Erstens wird der gesamte funktionierende Code weiterhin funktionieren, daher ist dies eine Einschränkung für neuen Code und kein Problem, das bestehenden Code betrifft. Zweitens werden exotische Aufrufbare-Typen selten für __exit__ verwendet, und wenn einer benötigt wird, kann er immer von einer einfachen Vanilla-Methode umwickelt werden, die an den Aufrufbaren delegiert. Zum Beispiel können wir dies schreiben
class C:
__enter__ = lambda self: self
__exit__ = ExoticCallable()
wie folgt
class CM:
__enter__ = lambda self: self
_exit = ExoticCallable()
__exit__ = lambda self, exc: CM._exit(exc)
Bei der Diskussion der realen Auswirkungen des Problems in dieser PEP ist es erwähnenswert, dass die meisten __exit__-Funktionen nichts mit ihren Argumenten tun. Typischerweise wird ein Context Manager so implementiert, dass sichergestellt wird, dass beim Verlassen bestimmte Aufräumarbeiten stattfinden. Es ist selten angemessen, dass die __exit__-Funktion Ausnahmen behandelt, die innerhalb des Kontexts auftreten, und sie dürfen normalerweise aus __exit__ an die aufrufende Funktion weitergegeben werden. Das bedeutet, dass die meisten __exit__-Funktionen ihre Argumente überhaupt nicht nutzen, und wir sollten dies bei der Bewertung der Auswirkungen verschiedener Lösungen auf die Python-Benutzerbasis berücksichtigen.
Spezifikation
Die Methode __exit__/__aexit__ eines Context Managers kann eine Single-Argument-Signatur haben, in diesem Fall wird sie vom Interpreter mit dem Argument gleich einer Ausnahmeinstanz oder None aufgerufen.
>>> class C:
... def __enter__(self):
... return self
... def __exit__(self, exc):
... print(f'__exit__ called with: {exc!r}')
...
>>> with C():
... pass
...
__exit__ called with: None
>>> with C():
... 1/0
...
__exit__ called with: ZeroDivisionError('division by zero')
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero
Wenn __exit__/__aexit__ eine andere Signatur hat, wird sie mit dem 3-Tupel (typ, exc, tb) aufgerufen, wie es derzeit geschieht.
>>> class C:
... def __enter__(self):
... return self
... def __exit__(self, *exc):
... print(f'__exit__ called with: {exc!r}')
...
>>> with C():
... pass
...
__exit__ called with: (None, None, None)
>>> with C():
... 1/0
...
__exit__ called with: (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x1039cb570>)
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero
Diese __exit__-Methoden werden ebenfalls mit einem 3-Tupel aufgerufen.
def __exit__(self, typ, *exc):
pass
def __exit__(self, typ, exc, tb):
pass
Eine Referenzimplementierung ist in CPython PR #101995 enthalten.
Wenn der Interpreter das Ende des Gültigkeitsbereichs eines Context Managers erreicht und die entsprechende Funktion __exit__ oder __aexit__ aufrufen wird, introspektiert er diese Funktion, um festzustellen, ob es sich um die Single-Argument- oder die Legacy-3-Argument-Version handelt. In der Entwurfsversion wird diese Introspektion von der Funktion is_legacy___exit__ durchgeführt.
static int is_legacy___exit__(PyObject *exit_func) {
if (PyMethod_Check(exit_func)) {
PyObject *func = PyMethod_GET_FUNCTION(exit_func);
if (PyFunction_Check(func)) {
PyCodeObject *code = (PyCodeObject*)PyFunction_GetCode(func);
if (code->co_argcount == 2 && !(code->co_flags & CO_VARARGS)) {
/* Python method that expects self + one more arg */
return false;
}
}
}
else if (PyCFunction_Check(exit_func)) {
if (PyCFunction_GET_FLAGS(exit_func) == METH_O) {
/* C function declared as single-arg */
return false;
}
}
return true;
}
Es ist wichtig zu beachten, dass dies keine allgemeine Introspektionsfunktion ist, sondern eine, die speziell für unseren Anwendungsfall konzipiert wurde. Wir wissen, dass exit_func ein Attribut der Context Manager-Klasse ist (entnommen vom Typ des Objekts, das __enter__ bereitgestellt hat) und typischerweise eine Funktion ist. Darüber hinaus müssen wir für diese Funktion genügend Single-Argument-Formen identifizieren, aber nicht unbedingt alle. Entscheidend für die Abwärtskompatibilität ist, dass wir niemals eine Legacy-exit_func fälschlicherweise als Single-Argument-Funktion identifizieren. Zum Beispiel haben sowohl __exit__(self, *args) als auch __exit__(self, exc_type, *args) die Legacy-Form, obwohl sie mit einem Argument aufgerufen werden *könnten*.
Zusammenfassend wird eine exit_func mit einem einzelnen Argument aufgerufen, wenn
- es sich um eine
PyMethodmitargcount2(umselfzu zählen) und ohne vararg handelt oder - es sich um eine
PyCFunctionmit dem FlagMETH_Ohandelt.
Beachten Sie, dass jeder Leistungskosten der Introspektion durch Spezialisierung gemildert werden können, sodass es kein Problem darstellt, wenn wir sie aus irgendeinem Grund ausgefeilter gestalten müssen als diese.
Abwärtskompatibilität
Alle Context Manager, die zuvor funktioniert haben, werden weiterhin auf die gleiche Weise funktionieren, da der Interpreter sie mit drei Argumenten aufruft, wenn sie drei Argumente akzeptieren können. Es kann Context Manager geben, die zuvor nicht funktioniert haben, weil ihre exit_func ein Argument erwartete, sodass der Aufruf von __exit__ eine TypeError-Ausnahme auslöste, und jetzt würde der Aufruf erfolgreich sein. Dies könnte theoretisch das Verhalten bestehenden Codes ändern, aber es ist unwahrscheinlich, dass es in der Praxis ein Problem darstellt.
Die Kompatibilitätsprobleme treten in einigen Fällen auf, wenn Bibliotheken versuchen, ihre Context Manager von der Mehrfachargument- auf die Single-Argument-Signatur zu migrieren. Wenn __exit__ oder __aexit__ von einem anderen Code als der Eval-Schleife des Interpreters aufgerufen wird, erfolgt die Introspektion nicht automatisch. Dies geschieht beispielsweise, wenn ein Context Manager abgeleitet wird und seine __exit__-Methode direkt aus der abgeleiteten __exit__ aufgerufen wird. Solche Context Manager müssen mit ihren Benutzern zur Single-Argument-Version migriert werden und können eine parallele API anbieten, anstatt die bestehende zu brechen. Alternativ kann eine Oberklasse bei der Signatur __exit__(self, *args) bleiben und sowohl ein als auch drei Argumente unterstützen. Da die meisten Context Manager den Wert der Argumente für __exit__ nicht verwenden und die Ausnahme einfach weiterpropagieren lassen, wird dies wahrscheinlich der übliche Ansatz sein.
Sicherheitsimplikationen
Ich bin mir keiner bewusst.
Wie man das lehrt
Das Sprach-Tutorial wird die Single-Argument-Version vorstellen, und die Dokumentation für Context Manager wird einen Abschnitt über die Legacy-Signaturen von __exit__ und __aexit__ enthalten.
Referenzimplementierung
CPython PR #101995 implementiert den Vorschlag dieser PEP.
Abgelehnte Ideen
Unterstützung von __leave__(self, exc)
Es wurde erwogen, eine Methode mit einem neuen Namen, wie z. B. __leave__, mit der neuen Signatur zu unterstützen. Dies führt den Programmierer dazu, explizit zu deklarieren, welche Signatur er verwenden möchte, und vermeidet die Notwendigkeit der Introspektion.
Verschiedene Variationen dieser Idee beinhalten unterschiedliche Mengen an Magie, die helfen können, die Gleichwertigkeit zwischen __leave__ und __exit__ zu automatisieren. Zum Beispiel schlug Mark Shannon vor, dass der Typkonstruktor eine Standardimplementierung für jede von __exit__ und __leave__ hinzufügt, wann immer eine davon in einer Klasse definiert wird. Diese Standardimplementierung fungiert als Trampolin, das die Funktion des Benutzers aufruft. Dies würde die Vererbung nahtlos machen, ebenso wie die Migration von __exit__ zu __leave__ für bestimmte Klassen. Der Interpreter müsste nur __leave__ aufrufen, und dieser würde bei Bedarf __exit__ aufrufen.
Obwohl dieser Vorschlag mehrere Vorteile gegenüber dem aktuellen hat, hat er zwei Nachteile. Der erste ist, dass er einen neuen Dunder-Namen zum Datenmodell hinzufügt, und wir hätten am Ende zwei Dunder-Namen, die dasselbe bedeuten und sich nur geringfügig in ihren Signaturen unterscheiden. Der zweite ist, dass er die Migration jedes __exit__ zu __leave__ erfordern würde, während bei Introspektion die vielen __exit__(*arg)-Methoden, die ihre Argumente nicht verwenden, nicht geändert werden müssten. Obwohl es nicht so einfach ist wie ein grep nach __exit__, ist es möglich, einen AST-Besucher zu schreiben, der __exit__-Methoden erkennt, die mehrere Argumente akzeptieren können und diese auch verwenden.
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-0707.rst
Zuletzt geändert: 2025-02-01 08:55:40 GMT