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

Python Enhancement Proposals

PEP 533 – Deterministische Bereinigung für Iteratoren

Autor:
Nathaniel J. Smith
BDFL-Delegate:
Yury Selivanov <yury at edgedb.com>
Status:
Verschoben
Typ:
Standards Track
Erstellt:
18-Okt-2016
Post-History:
18-Okt-2016

Inhaltsverzeichnis

Zusammenfassung

Wir schlagen vor, das Iterator-Protokoll um einen neuen __(a)iterclose__-Slot zu erweitern, der automatisch beim Verlassen von (async) for-Schleifen aufgerufen wird, unabhängig davon, wie diese verlassen werden. Dies ermöglicht eine bequeme, deterministische Bereinigung von Ressourcen, die von Iteratoren gehalten werden, ohne auf den Garbage Collector angewiesen zu sein. Dies ist besonders wertvoll für asynchrone Generatoren.

Hinweis zur Zeitplanung

Praktisch gesehen ist der hier vorgeschlagene Vorschlag in zwei separate Teile unterteilt: die Behandlung von asynchronen Iteratoren, die idealerweise so schnell wie möglich implementiert werden sollte, und die Behandlung von regulären Iteratoren, was ein größeres, aber entspannteres Projekt ist, das frühestens ab 3.7 beginnen kann. Da die Änderungen jedoch eng miteinander verbunden sind und wir wahrscheinlich nicht wollen, dass asynchrone und reguläre Iteratoren langfristig auseinanderdriften, scheint es sinnvoll, sie gemeinsam zu betrachten.

Hintergrund und Motivation

Python-Iterables halten oft Ressourcen, die bereinigt werden müssen. Zum Beispiel müssen file-Objekte geschlossen werden; die WSGI-Spezifikation fügt dem regulären Iterator-Protokoll eine close-Methode hinzu und verlangt, dass Konsumenten diese zur richtigen Zeit aufrufen (obwohl das Vergessen eine häufige Fehlerquelle ist); und PEP 342 (basierend auf PEP 325) erweiterte Generator-Objekte um eine close-Methode, um Generatoren die Möglichkeit zu geben, sich selbst zu bereinigen.

Im Allgemeinen definieren Objekte, die sich selbst bereinigen müssen, auch eine __del__-Methode, um sicherzustellen, dass diese Bereinigung irgendwann stattfindet, wenn das Objekt vom Garbage Collector eingesammelt wird. Die Verlassenheit auf den Garbage Collector für die Bereinigung führt jedoch in mehreren Fällen zu ernsthaften Problemen

  • In Python-Implementierungen, die keine Referenzzählung verwenden (z.B. PyPy, Jython), können Aufrufe von __del__ beliebig verzögert werden – doch viele Situationen erfordern eine *sofortige* Bereinigung von Ressourcen. Verzögerte Bereinigung führt zu Problemen wie Abstürzen aufgrund von Dateideskriptor-Erschöpfung oder WSGI-Zeit-Middleware, die fehlerhafte Zeiten sammelt.
  • Asynchrone Generatoren (PEP 525) können die Bereinigung nur unter der Aufsicht des entsprechenden Coroutine-Runners durchführen. __del__ hat keinen Zugriff auf den Coroutine-Runner; tatsächlich könnte der Coroutine-Runner vor dem Generator-Objekt vom Garbage Collector eingesammelt werden. Sich auf den Garbage Collector zu verlassen, ist daher effektiv unmöglich, ohne eine Art von Sprachausdehnung. (PEP 525 bietet eine solche Ausdehnung, hat aber eine Reihe von Einschränkungen, die dieser Vorschlag behebt; siehe den Abschnitt „Alternativen“ unten zur Diskussion.)

Glücklicherweise bietet Python ein Standardwerkzeug für die strukturiertere Ressourcenbereinigung: with-Blöcke. Dieser Code öffnet beispielsweise eine Datei, verlässt sich aber darauf, dass der Garbage Collector sie schließt

def read_newline_separated_json(path):
    for line in open(path):
        yield json.loads(line)

for document in read_newline_separated_json(path):
    ...

und neuere Versionen von CPython werden dies durch die Ausgabe einer ResourceWarning anmahnen und uns dazu bewegen, dies durch Hinzufügen eines with-Blocks zu beheben

def read_newline_separated_json(path):
    with open(path) as file_handle:      # <-- with block
        for line in file_handle:
            yield json.loads(line)

for document in read_newline_separated_json(path):  # <-- outer for loop
    ...

Aber hier gibt es eine Subtilität, die durch die Interaktion von with-Blöcken und Generatoren verursacht wird. with-Blöcke sind Pythons Hauptwerkzeug zur Verwaltung der Bereinigung, und sie sind ein mächtiges Werkzeug, da sie die Lebensdauer einer Ressource an die Lebensdauer eines Stack-Frames binden. Dies setzt jedoch voraus, dass sich jemand um die Bereinigung des Stack-Frames kümmert... und für Generatoren erfordert dies, dass jemand sie schließt.

In diesem Fall reicht das Hinzufügen des with-Blocks *aus*, um die ResourceWarning zu unterdrücken, aber das ist irreführend – die Bereinigung des Dateiobjekts hängt hier immer noch vom Garbage Collector ab. Der with-Block wird nur dann zurückgerollt, wenn der read_newline_separated_json-Generator geschlossen wird. Wenn die äußere for-Schleife vollständig durchläuft, erfolgt die Bereinigung sofort; wenn diese Schleife jedoch frühzeitig durch ein break oder eine Ausnahme beendet wird, wird der with-Block erst ausgelöst, wenn das Generator-Objekt vom Garbage Collector eingesammelt wird.

Die korrekte Lösung erfordert, dass alle *Benutzer* dieser API jede for-Schleife in einen eigenen with-Block einschließen

with closing(read_newline_separated_json(path)) as genobj:
    for document in genobj:
        ...

Dies wird noch schlimmer, wenn man die Idiomatik betrachtet, eine komplexe Pipeline in mehrere verschachtelte Generatoren zu zerlegen

def read_users(path):
    with closing(read_newline_separated_json(path)) as gen:
        for document in gen:
            yield User.from_json(document)

def users_in_group(path, group):
    with closing(read_users(path)) as gen:
        for user in gen:
            if user.group == group:
                yield user

Im Allgemeinen benötigen Sie bei N verschachtelten Generatoren N+1 with-Blöcke, um 1 Datei zu bereinigen. Und gute defensive Programmierung würde vorschlagen, dass wir bei jeder Verwendung eines Generators davon ausgehen sollten, dass es irgendwo in seinem (möglicherweise transitiven) Aufrufstapel mindestens einen with-Block geben könnte, entweder jetzt oder in Zukunft, und ihn daher immer in einen with einschließen sollten. Aber in der Praxis tut das praktisch niemand, weil Programmierer eher fehlerhaften Code als ermüdenden repetitiven Code schreiben. In einfachen Fällen wie diesem gibt es einige Workarounds, die gute Python-Entwickler kennen (z.B. in diesem einfachen Fall wäre es idiomatisch, ein Dateihandle statt eines Pfads zu übergeben und die Ressourcenverwaltung auf die oberste Ebene zu verschieben), aber im Allgemeinen können wir die Verwendung von with/finally innerhalb von Generatoren nicht vermeiden, und müssen uns daher auf die eine oder andere Weise mit diesem Problem auseinandersetzen. Wenn Schönheit und Korrektheit kämpfen, gewinnt Schönheit tendenziell, daher ist es wichtig, korrekten Code schön zu machen.

Lohnt sich das immer noch zu beheben? Bis asynchrone Generatoren aufkamen, hätte ich ja gesagt, aber mit niedriger Priorität, da anscheinend alle okay zurechtkommen – aber asynchrone Generatoren machen es viel dringlicher. Asynchrone Generatoren können die Bereinigung *gar nicht* ohne einen Mechanismus für deterministische Bereinigung, den die Leute tatsächlich verwenden werden, durchführen, und asynchrone Generatoren halten wahrscheinlich besonders wahrscheinlich Ressourcen wie Dateideskriptoren. (Wenn sie keine E/A durchführen würden, wären sie Generatoren, keine asynchronen Generatoren.) Wir müssen also etwas tun, und es könnte genauso gut eine umfassende Behebung des zugrundeliegenden Problems sein. Und es ist viel einfacher, dies jetzt zu beheben, wenn asynchrone Generatoren gerade erst eingeführt werden, als es später zu beheben.

Der Vorschlag selbst ist konzeptionell einfach: Füge eine __(a)iterclose__-Methode zum Iterator-Protokoll hinzu, und lass (async) for-Schleifen diese beim Verlassen der Schleife aufrufen, auch wenn dies über break oder Ausnahmefall-Abwicklung geschieht. Effektiv nehmen wir die aktuelle umständliche Idiomatik (with-Block + for-Schleife) und verschmelzen sie zu einer schickeren for-Schleife. Dies mag nicht orthogonal erscheinen, macht aber Sinn, wenn man bedenkt, dass die Existenz von Generatoren bedeutet, dass with-Blöcke tatsächlich von der Iterator-Bereinigung abhängen, um zuverlässig zu funktionieren, plus Erfahrungen, die zeigen, dass Iterator-Bereinigung oft an sich schon ein wünschenswertes Feature ist.

Alternativen

PEP 525 asyncgen-Hooks

PEP 525 schlägt eine Reihe globaler Thread-lokaler Hooks vor, die von neuen sys.{get/set}_asyncgen_hooks()-Funktionen verwaltet werden, welche es Event-Loops ermöglichen, sich mit dem Garbage Collector zu integrieren, um die Bereinigung für asynchrone Generatoren durchzuführen. Prinzipiell ergänzen sich dieser Vorschlag und PEP 525, so wie with-Blöcke und __del__ sich ergänzen: Dieser Vorschlag sorgt in den meisten Fällen für deterministische Bereinigung, während die GC-Hooks von PEP 525 alles bereinigen, was übersehen wurde. Aber __aiterclose__ bietet eine Reihe von Vorteilen gegenüber reinen GC-Hooks

  • Die Semantik der GC-Hooks ist kein Teil des abstrakten asynchronen Iterator-Protokolls, sondern ist spezifisch auf den konkreten Typ des asynchronen Generators beschränkt. Wenn Sie einen asynchronen Iterator haben, der mit einer Klasse implementiert ist, wie z.B.
    class MyAsyncIterator:
        async def __anext__():
            ...
    

    dann können Sie dies nicht in einen asynchronen Generator refaktorisieren, ohne seine Semantik zu ändern, und umgekehrt. Das scheint sehr unpythonisch zu sein. (Es lässt auch die Frage offen, was genau klassenbasierte asynchrone Iteratoren tun sollen, da sie genau die gleichen Bereinigungsprobleme wie asynchrone Generatoren haben.) __aiterclose__ hingegen ist auf Protokollebene definiert, ist also duck-type-freundlich und funktioniert für alle Iteratoren, nicht nur für Generatoren.

  • Code, der auf Nicht-CPython-Implementierungen wie PyPy laufen soll, kann sich im Allgemeinen nicht auf GC für die Bereinigung verlassen. Ohne __aiterclose__ ist es mehr oder weniger garantiert, dass Entwickler, die auf CPython entwickeln und testen, Bibliotheken erstellen, die Ressourcenlecks verursachen, wenn sie auf PyPy verwendet werden. Entwickler, die alternative Implementierungen ansprechen wollen, müssen entweder den defensiven Ansatz wählen, jede for-Schleife in einen with-Block einschließen, oder ihren Code sorgfältig prüfen, um festzustellen, welche Generatoren möglicherweise Bereinigungscode enthalten, und um diese herum with-Blöcke hinzufügen. Mit __aiterclose__ wird das Schreiben von portablem Code einfach und natürlich.
  • Ein wichtiger Teil des Aufbaus robuster Software ist sicherzustellen, dass Ausnahmen immer korrekt weitergegeben werden, ohne verloren zu gehen. Eines der aufregendsten Dinge an async/await im Vergleich zu traditionellen Callback-basierten Systemen ist, dass anstatt manueller Verknüpfung die Laufzeit das Schwergewicht der Fehlerweitergabe übernehmen kann, was es *viel einfacher* macht, robusten Code zu schreiben. Aber dieses schöne neue Bild hat eine große Lücke: Wenn wir uns für die Generator-Bereinigung auf den GC verlassen, gehen während der Bereinigung ausgelöste Ausnahmen verloren. Also müssen Entwickler, denen diese Art von Robustheit wichtig ist, entweder den defensiven Ansatz wählen, jede for-Schleife in einen with-Block einschließen, oder ihren Code sorgfältig prüfen, um festzustellen, welche Generatoren möglicherweise Bereinigungscode enthalten. __aiterclose__ schließt diese Lücke, indem es die Bereinigung im Kontext des Aufrufers durchführt, so dass das Schreiben von robusterem Code zum Weg des geringsten Widerstands wird.
  • Die WSGI-Erfahrung deutet darauf hin, dass es wichtige iteratorbasierte APIs gibt, die eine sofortige Bereinigung benötigen und sich auch in CPython nicht auf den GC verlassen können. Betrachten Sie zum Beispiel eine hypothetische WSGI-ähnliche API, die auf async/await und asynchronen Iteratoren basiert, bei der ein Antwort-Handler ein asynchroner Generator ist, der Anforderungs-Header + einen asynchronen Iterator über den Anforderungs-Body aufnimmt und Antwort-Header + Antwort-Body liefert. (Dies ist tatsächlich der Anwendungsfall, der mich ursprünglich an asynchronen Generatoren interessiert hat, d.h. dies ist nicht hypothetisch.) Wenn wir WSGI folgen und verlangen, dass untergeordnete Iteratoren ordnungsgemäß geschlossen werden müssen, dann sieht ohne __aiterclose__ die absolut minimalistischste Middleware in unserem System ungefähr so aus:
    async def noop_middleware(handler, request_header, request_body):
        async with aclosing(handler(request_body, request_body)) as aiter:
            async for response_item in aiter:
                yield response_item
    

    Man könnte argumentieren, dass man in regulärem Code damit davonkommt, den with-Block um for-Schleifen herum zu überspringen, je nachdem, wie zuversichtlich man ist, die interne Implementierung des Generators zu verstehen. Aber hier müssen wir mit beliebigen Antwort-Handlern umgehen, so dass ohne __aiterclose__ diese with-Konstruktion ein zwingender Bestandteil jeder Middleware ist.

    __aiterclose__ ermöglicht es uns, den zwingenden Boilerplate-Code und eine zusätzliche Einrückungsebene aus jeder Middleware zu entfernen

    async def noop_middleware(handler, request_header, request_body):
        async for response_item in handler(request_header, request_body):
            yield response_item
    

Der __aiterclose__-Ansatz bietet somit erhebliche Vorteile gegenüber GC-Hooks.

Dies lässt die Frage offen, ob wir eine Kombination aus GC-Hooks + __aiterclose__ wollen, oder nur __aiterclose__ allein. Da die überwiegende Mehrheit der Generatoren über eine for-Schleife oder Ähnliches iteriert wird, behandelt __aiterclose__ die meisten Situationen, bevor der GC überhaupt zum Einsatz kommt. Der Fall, in dem GC-Hooks zusätzlichen Wert bieten, ist in Code, der manuelle Iteration durchführt, z.B.

agen = fetch_newline_separated_json_from_url(...)
while True:
    document = await type(agen).__anext__(agen)
    if document["id"] == needle:
        break
# doesn't do 'await agen.aclose()'

Wenn wir uns für den Ansatz GC-Hooks + __aiterclose__ entscheiden, wird dieser Generator schließlich vom GC bereinigt, der die __del__-Methode des Generators aufruft, welche dann die Hooks verwendet, um zurück in die Event-Schleife zu rufen, um den Bereinigungscode auszuführen.

Wenn wir uns für den Ansatz ohne GC-Hooks entscheiden, wird dieser Generator schließlich vom Garbage Collector eingesammelt, mit folgenden Auswirkungen:

  • seine __del__-Methode gibt eine Warnung aus, dass der Generator nicht geschlossen wurde (ähnlich der vorhandenen Warnung „coroutine never awaited“).
  • Die zugrundeliegenden Ressourcen werden trotzdem bereinigt, da der Generator-Frame immer noch vom Garbage Collector eingesammelt wird, was dazu führt, dass Referenzen auf gehaltene Dateihandles oder Sockets gelöscht werden, und dann werden die __del__-Methoden dieser Objekte die tatsächlichen Betriebssystemressourcen freigeben.
  • Aber jeder Bereinigungscode innerhalb des Generators selbst (z.B. Logging, Puffer-Flush) bekommt keine Chance zu laufen.

Die Lösung hier – wie die Warnung anzeigen würde – ist, den Code zu korrigieren, damit er __aiterclose__ aufruft, z.B. durch Verwendung eines with-Blocks

async with aclosing(fetch_newline_separated_json_from_url(...)) as agen:
    while True:
        document = await type(agen).__anext__(agen)
        if document["id"] == needle:
            break

Im Grunde wäre in diesem Ansatz die Regel: Wenn Sie das Iterator-Protokoll manuell implementieren wollen, dann ist es Ihre Verantwortung, es vollständig zu implementieren, und das schließt jetzt __(a)iterclose__ ein.

GC-Hooks fügen eine nicht-triviale Komplexität hinzu in Form von (a) neuem globalen Interpreter-Zustand, (b) einer etwas komplizierten Kontrollflusslogik (z.B. asynchrone Generator-GC beinhaltet immer Wiederbelebung, daher sind die Details von PEP 442 wichtig) und (c) einer neuen öffentlichen API in asyncio (await loop.shutdown_asyncgens()), an die sich Benutzer zur richtigen Zeit erinnern müssen. (Insbesondere letzterer Punkt untergräbt teilweise das Argument, dass GC-Hooks eine sichere Rückfallebene bieten, um die Bereinigung zu garantieren, da, wenn shutdown_asyncgens() nicht korrekt aufgerufen wird, ich *denke*, es ist möglich, dass Generatoren stillschweigend verworfen werden, ohne dass ihr Bereinigungscode aufgerufen wird; vergleichen Sie dies mit dem reinen __aiterclose__-Ansatz, bei dem im schlimmsten Fall zumindest eine Warnung ausgegeben wird. Dies könnte behoben werden.) Alles in allem sind GC-Hooks wohl nicht lohnenswert, da sie nur denjenigen helfen, die __anext__ manuell aufrufen wollen, aber __aiterclose__ nicht manuell aufrufen wollen. Aber Yury ist anderer Meinung als ich :-). Und beide Optionen sind gangbar.

Ressourcen immer injizieren und alle Bereinigungen auf der obersten Ebene durchführen

Mehrere Kommentatoren auf python-dev und python-ideas haben vorgeschlagen, dass ein Muster zur Vermeidung dieser Probleme darin besteht, Ressourcen immer von oben weiterzugeben, z.B. sollte read_newline_separated_json ein Dateiobjekt statt eines Pfades nehmen, wobei die Bereinigung auf der obersten Ebene gehandhabt wird

def read_newline_separated_json(file_handle):
    for line in file_handle:
        yield json.loads(line)

def read_users(file_handle):
    for document in read_newline_separated_json(file_handle):
        yield User.from_json(document)

with open(path) as file_handle:
    for user in read_users(file_handle):
        ...

Dies funktioniert gut in einfachen Fällen; hier können wir das Problem der „N+1 with-Blöcke“ vermeiden. Aber leider bricht es schnell zusammen, wenn die Dinge komplexer werden. Stellen Sie sich vor, unser Generator würde statt aus einer Datei von einer Streaming-HTTP-GET-Anfrage lesen – während er Umleitungen und Authentifizierung über OAUTH behandelt. Dann würden wir wirklich wollen, dass die Sockets in unserer HTTP-Client-Bibliothek verwaltet werden, nicht auf der obersten Ebene. Außerdem gibt es andere Fälle, in denen finally-Blöcke, die in Generatoren eingebettet sind, an sich wichtig sind: Verwaltung von Datenbanktransaktionen, Ausgabe von Protokollinformationen während der Bereinigung (einer der Hauptanwendungsfälle für die close-Methode von WSGI) und so weiter. Dies ist also wirklich ein Workaround für einfache Fälle, keine allgemeine Lösung.

Komplexere Varianten von __(a)iterclose__

Die Semantik von __(a)iterclose__ ist teilweise von with-Blöcken inspiriert, aber Kontextmanager sind mächtiger: __(a)exit__ kann zwischen einem normalen Ausstieg und einem Ausstieg durch Ausnahme unterscheiden, und im Falle einer Ausnahme kann er die Ausnahmedetails untersuchen und optional die Weitergabe unterdrücken. __(a)iterclose__ hat in der hier vorgeschlagenen Form diese Befugnisse nicht, aber man kann sich ein alternatives Design vorstellen, bei dem dies der Fall wäre.

Dies scheint jedoch unnötige Komplexität zu sein: Erfahrungen deuten darauf hin, dass es üblich ist, dass Iterables close-Methoden haben und sogar __exit__-Methoden, die self.close() aufrufen, aber ich bin keiner gängigen Fälle bewusst, die die volle Leistung von __exit__ nutzen. Ich kann mir auch keine Beispiele vorstellen, bei denen dies nützlich wäre. Und es scheint unnötig verwirrend zu sein, Iteratoren die Programmflusskontrolle durch das Verschlucken von Ausnahmen zu ermöglichen – wenn Sie sich in einer Situation befinden, in der Sie das wirklich wollen, dann sollten Sie wahrscheinlich sowieso einen echten with-Block verwenden.

Spezifikation

Dieser Abschnitt beschreibt, wo wir letztendlich landen wollen, obwohl es einige Kompatibilitätsprobleme gibt, die bedeuten, dass wir nicht direkt dorthin springen können. Ein späterer Abschnitt beschreibt den Übergangsplan.

Leitprinzipien

Generell sollten __(a)iterclose__-Implementierungen

  • idempotent sein,
  • jede Bereinigung durchführen, die angemessen ist, unter der Annahme, dass der Iterator nach dem Aufruf von __(a)iterclose__ nicht wieder verwendet wird. Insbesondere nach dem Aufruf von __(a)iterclose__ führt der Aufruf von __(a)next__ zu undefiniertem Verhalten.

Und generell sollte jeder Code, der beginnt, durch ein Iterable zu iterieren und es vollständig erschöpfen will, dafür sorgen, dass __(a)iterclose__ schließlich aufgerufen wird, unabhängig davon, ob der Iterator tatsächlich erschöpft ist.

Änderungen an der Iteration

Der Kernvorschlag ist die Verhaltensänderung von for-Schleifen. Angesichts dieses Python-Codes

for VAR in ITERABLE:
    LOOP-BODY
else:
    ELSE-BODY

desugarisieren wir zu dem Äquivalent von

_iter = iter(ITERABLE)
_iterclose = getattr(type(_iter), "__iterclose__", lambda: None)
try:
    traditional-for VAR in _iter:
        LOOP-BODY
    else:
        ELSE-BODY
finally:
    _iterclose(_iter)

wobei die „traditionelle for-Anweisung“ hier als Kurzform für die klassische Semantik der for-Schleife vor Version 3.5 gemeint ist.

Neben der for-Anweisung auf oberster Ebene gibt es in Python noch mehrere andere Stellen, an denen Iteratoren konsumiert werden. Zur Konsistenz sollten auch diese __iterclose__ aufrufen, mit einer Semantik, die der obigen entspricht. Dazu gehören

  • for-Schleifen innerhalb von Comprehensions
  • *-Unpacking
  • Funktionen, die Iterables entgegennehmen und vollständig konsumieren, wie list(it), tuple(it), itertools.product(it1, it2, ...), und andere.

Zusätzlich sollte ein yield from, das den aufgerufenen Generator erfolgreich erschöpft, als letzten Schritt seine __iterclose__-Methode aufrufen. (Begründung: yield from verknüpft bereits die Lebensdauer des aufrufenden Generators mit dem aufgerufenen Generator; wenn der aufrufende Generator mitten in einem yield from geschlossen wird, schließt dies automatisch den aufgerufenen Generator.)

Änderungen an der asynchronen Iteration

Wir führen auch die analogen Änderungen für asynchrone Iterationskonstrukte durch, mit dem Unterschied, dass der neue Slot __aiterclose__ heißt und eine asynchrone Methode ist, die mit await aufgerufen wird.

Änderungen an grundlegenden Iterator-Typen

Generator-Objekte (einschließlich derjenigen, die von Generator-Comprehensions erstellt wurden)

  • __iterclose__ ruft self.close() auf
  • __del__ ruft self.close() auf (wie jetzt) und gibt zusätzlich eine ResourceWarning aus, wenn der Generator nicht erschöpft wurde. Diese Warnung ist standardmäßig ausgeblendet, kann aber für diejenigen aktiviert werden, die sicherstellen wollen, dass sie nicht versehentlich auf CPython-spezifische GC-Semantik setzen.

Asynchrone Generator-Objekte (einschließlich derjenigen, die von asynchronen Generator-Comprehensions erstellt wurden)

  • __aiterclose__ ruft self.aclose() auf
  • __del__ gibt eine RuntimeWarning aus, wenn aclose nicht aufgerufen wurde, da dies wahrscheinlich auf einen latenten Fehler hinweist, ähnlich der Warnung „coroutine never awaited“.

FRAGE: Sollen Dateiobjekte __iterclose__ implementieren, um die Datei zu schließen? Einerseits würde dies diese Änderung disruptiver machen; andererseits schreiben die Leute sehr gerne for line in open(...): ..., und wenn wir uns daran gewöhnen, dass Iteratoren ihre eigene Bereinigung übernehmen, könnte es sehr seltsam sein, wenn Dateien dies nicht tun.

Neue Komfortfunktionen

Das operator-Modul erhält zwei neue Funktionen mit Semantik, die der folgenden entspricht

def iterclose(it):
    if not isinstance(it, collections.abc.Iterator):
        raise TypeError("not an iterator")
    if hasattr(type(it), "__iterclose__"):
        type(it).__iterclose__(it)

async def aiterclose(ait):
    if not isinstance(it, collections.abc.AsyncIterator):
        raise TypeError("not an iterator")
    if hasattr(type(ait), "__aiterclose__"):
        await type(ait).__aiterclose__(ait)

Das Modul itertools erhält einen neuen Iterator-Wrapper, der verwendet werden kann, um das neue __iterclose__-Verhalten selektiv zu deaktivieren

# QUESTION: I feel like there might be a better name for this one?
class preserve(iterable):
    def __init__(self, iterable):
        self._it = iter(iterable)

    def __iter__(self):
        return self

    def __next__(self):
        return next(self._it)

    def __iterclose__(self):
        # Swallow __iterclose__ without passing it on
        pass

Beispielverwendung (angenommen, Dateiobjekte implementieren __iterclose__)

with open(...) as handle:
    # Iterate through the same file twice:
    for line in itertools.preserve(handle):
        ...
    handle.seek(0)
    for line in itertools.preserve(handle):
        ...
@contextlib.contextmanager
def iterclosing(iterable):
    it = iter(iterable)
    try:
        yield preserve(it)
    finally:
        iterclose(it)

__iterclose__-Implementierungen für Iterator-Wrapper

Python liefert eine Reihe von Iteratortypen aus, die als Wrapper um andere Iteratoren fungieren: map, zip, itertools.accumulate, csv.reader und andere. Diese Iteratoren sollten eine __iterclose__-Methode definieren, die ihrerseits __iterclose__ für ihre zugrunde liegenden Iteratoren aufruft. Zum Beispiel könnte map wie folgt implementiert werden:

# Helper function
map_chaining_exceptions(fn, items, last_exc=None):
    for item in items:
        try:
            fn(item)
        except BaseException as new_exc:
            if new_exc.__context__ is None:
                new_exc.__context__ = last_exc
            last_exc = new_exc
    if last_exc is not None:
        raise last_exc

class map:
    def __init__(self, fn, *iterables):
        self._fn = fn
        self._iters = [iter(iterable) for iterable in iterables]

    def __iter__(self):
        return self

    def __next__(self):
        return self._fn(*[next(it) for it in self._iters])

    def __iterclose__(self):
        map_chaining_exceptions(operator.iterclose, self._iters)

def chain(*iterables):
    try:
        while iterables:
            for element in iterables.pop(0):
                yield element
    except BaseException as e:
        def iterclose_iterable(iterable):
            operations.iterclose(iter(iterable))
        map_chaining_exceptions(iterclose_iterable, iterables, last_exc=e)

In einigen Fällen erfordert dies einige Feinheiten; zum Beispiel sollte itertools.tee __iterclose__ für den zugrunde liegenden Iterator erst aufrufen, wenn es für *alle* Klon-Iteratoren aufgerufen wurde.

Beispiel / Begründung

Der Lohn für all dies ist, dass wir jetzt einfachen Code schreiben können wie

def read_newline_separated_json(path):
    for line in open(path):
        yield json.loads(line)

und sicher sein können, dass die Datei eine deterministische Bereinigung erhält, *ohne dass der Endbenutzer besondere Anstrengungen unternehmen muss*, selbst in komplexen Fällen. Betrachten Sie zum Beispiel diese alberne Pipeline:

list(map(lambda key: key.upper(),
         doc["key"] for doc in read_newline_separated_json(path)))

Wenn unsere Datei ein Dokument enthält, bei dem doc["key"] eine ganze Zahl ergibt, dann wird die folgende Sequenz von Ereignissen auftreten:

  1. key.upper() löst einen AttributeError aus, der aus dem map ausbricht und den impliziten finally-Block innerhalb von list auslöst.
  2. Der finally-Block in list ruft __iterclose__() auf dem Map-Objekt auf.
  3. map.__iterclose__() ruft __iterclose__() auf dem Generator-Comprehension-Objekt auf.
  4. Dies injiziert eine GeneratorExit-Ausnahme in den Körper der Generator-Comprehension, die gerade innerhalb des for-Schleifen-Körpers der Comprehension suspendiert ist.
  5. Die Ausnahme bricht aus der for-Schleife aus und löst den impliziten finally-Block der for-Schleife aus, der __iterclose__ für das Generator-Objekt aufruft, das den Aufruf von read_newline_separated_json repräsentiert.
  6. Dies injiziert eine innere GeneratorExit-Ausnahme in den Körper von read_newline_separated_json, der gerade am yield suspendiert ist.
  7. Die innere GeneratorExit-Ausnahme bricht aus der for-Schleife aus und löst den impliziten finally-Block der for-Schleife aus, der __iterclose__() für das Dateiobjekt aufruft.
  8. Das Dateiobjekt wird geschlossen.
  9. Die innere GeneratorExit-Ausnahme setzt die Weitergabe fort, erreicht die Grenze der Generatorfunktion und bewirkt, dass die __iterclose__()-Methode von read_newline_separated_json erfolgreich zurückkehrt.
  10. Die Kontrolle kehrt zum Körper der Generator-Kompilierung zurück, und das äußere GeneratorExit breitet sich weiter aus und ermöglicht es, dass __iterclose__() der Kompilierung erfolgreich zurückkehrt.
  11. Der Rest der __iterclose__()-Aufrufe wickelt sich ohne Zwischenfälle zurück in den Körper von list ab.
  12. Der ursprüngliche AttributeError breitet sich weiter aus.

(Die obigen Details setzen voraus, dass wir file.__iterclose__ implementieren; wenn nicht, fügen Sie einen with-Block zu read_newline_separated_json hinzu und im Wesentlichen die gleiche Logik wird durchlaufen.)

Natürlich kann dies aus Sicht des Benutzers einfach reduziert werden auf

1. int.upper() löst einen AttributeError aus 1. Das Dateiobjekt wird geschlossen. 2. Der AttributeError breitet sich aus list aus

Wir haben also unser Ziel erreicht, dies "einfach funktionieren" zu lassen, ohne dass der Benutzer darüber nachdenken muss.

Übergangsplan

Während die Mehrheit bestehender for-Schleifen weiterhin identische Ergebnisse liefert, werden die vorgeschlagenen Änderungen in einigen Fällen zu einem abwärtsinkompatiblen Verhalten führen. Beispiel

def read_csv_with_header(lines_iterable):
    lines_iterator = iter(lines_iterable)
    for line in lines_iterator:
        column_names = line.strip().split("\t")
        break
    for line in lines_iterator:
        values = line.strip().split("\t")
        record = dict(zip(column_names, values))
        yield record

Dieser Code war früher korrekt, aber nach Implementierung dieses Vorschlags wird er einen itertools.preserve-Aufruf erfordern, der zur ersten for-Schleife hinzugefügt wurde.

[FRAGE: Derzeit löst das Schließen eines Generators und der anschließende Versuch, ihn zu durchlaufen, nur Stop(Async)Iteration aus. Code, der dasselbe Generatorobjekt an mehrere for-Schleifen übergibt, aber vergisst, itertools.preserve zu verwenden, wird keinen offensichtlichen Fehler sehen – die zweite for-Schleife wird einfach sofort beendet. Vielleicht wäre es besser, wenn das Iterieren eines geschlossenen Generators einen RuntimeError auslöst? Beachten Sie, dass Dateien dieses Problem nicht haben – der Versuch, ein geschlossenes Dateiobjekt zu iterieren, löst bereits ValueError aus.]

Insbesondere tritt die Inkompatibilität auf, wenn alle folgenden Faktoren zusammenkommen

  • Das automatische Aufrufen von __(a)iterclose__ ist aktiviert
  • Das iterierbare Objekt hat zuvor keine __(a)iterclose__ definiert
  • Das iterierbare Objekt definiert nun __(a)iterclose__
  • Das iterierbare Objekt wird nach dem Beenden der for-Schleife wiederverwendet

Das Problem besteht also darin, diesen Übergang zu steuern, und das sind die Hebel, die uns zur Verfügung stehen.

Betrachten wir zunächst, dass die einzigen asynchronen iterierbaren Objekte, bei denen wir __aiterclose__ hinzufügen wollen, asynchrone Generatoren sind und derzeit kein bestehender Code asynchrone Generatoren verwendet (obwohl sich dies bald ändern wird), daher führen die asynchronen Änderungen nicht zu Abwärtsinkompatibilitäten. (Es gibt bestehenden Code, der asynchrone Iteratoren verwendet, aber die Verwendung der neuen asynchronen for-Schleife mit einem alten asynchronen Iterator ist harmlos, da alte asynchrone Iteratoren keine __aiterclose__ haben.) Darüber hinaus wurde PEP 525 provisorisch angenommen und asynchrone Generatoren sind mit Abstand die größten Nutznießer der vorgeschlagenen Änderungen dieser PEP. Daher sollten wir dringend erwägen, __aiterclose__ für async for-Schleifen und asynchrone Generatoren so schnell wie möglich zu aktivieren, idealerweise für 3.6.0 oder 3.6.1.

Für die nicht-asynchrone Welt ist die Sache schwieriger, aber hier ist ein möglicher Übergangspfad

In 3.7

Unser Ziel ist, dass bestehender unsicherer Code Warnungen ausgibt, während diejenigen, die sich für die Zukunft entscheiden wollen, dies sofort tun können

  • Wir fügen sofort alle oben beschriebenen __iterclose__-Methoden hinzu.
  • Wenn from __future__ import iterclose aktiv ist, dann rufen for-Schleifen und *-Entpackung __iterclose__ wie oben angegeben auf.
  • Wenn die Zukunft *nicht* aktiviert ist, dann rufen for-Schleifen und *-Entpackung __iterclose__ *nicht* auf. Aber sie rufen stattdessen eine andere Methode auf, z. B. __iterclose_warning__.
  • Ähnlich verwenden Funktionen wie list Stapel-Introspektion (!!) um zu prüfen, ob ihr direkter Aufrufer __future__.iterclose aktiviert hat, und verwenden dies, um zu entscheiden, ob __iterclose__ oder __iterclose_warning__ aufgerufen werden soll.
  • Für alle Wrapper-Iteratoren fügen wir auch __iterclose_warning__-Methoden hinzu, die an die __iterclose_warning__-Methode des zugrunde liegenden Iterators oder Iteratoren weiterleiten.
  • Für Generatoren (und Dateien, wenn wir uns dafür entscheiden), ist __iterclose_warning__ definiert, um ein internes Flag zu setzen, und andere Methoden des Objekts werden modifiziert, um dieses Flag zu überprüfen. Wenn sie das Flag gesetzt finden, geben sie eine PendingDeprecationWarning aus, um den Benutzer darüber zu informieren, dass diese Sequenz in Zukunft zu einer Use-After-Close-Situation geführt hätte und der Benutzer preserve() verwenden sollte.

In 3.8

  • Wechsel von PendingDeprecationWarning zu DeprecationWarning

In 3.9

  • Aktivierung von __future__ bedingungslos und Entfernung des gesamten __iterclose_warning__-Krams.

Ich glaube, dass dies die normalen Anforderungen für diese Art von Übergang erfüllt – zunächst Opt-in, mit Warnungen, die genau auf die betroffenen Fälle abzielen, und einem langen Deprecationszyklus.

Wahrscheinlich ist der umstrittenste / riskanteste Teil davon die Verwendung von Stapel-Introspektion, um die iterierenden Funktionen empfindlich für eine __future__-Einstellung zu machen, obwohl ich noch keine Situation gefunden habe, in der dies tatsächlich schief gehen würde...

Danksagungen

Vielen Dank an Yury Selivanov, Armin Rigo und Carl Friedrich Bolz für ihre hilfreichen Diskussionen zu früheren Versionen dieser Idee.


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

Zuletzt geändert: 2025-02-01 08:59:27 GMT