PEP 255 – Einfache Generatoren
- Autor:
- Neil Schemenauer <nas at arctrix.com>, Tim Peters <tim.peters at gmail.com>, Magnus Lie Hetland <magnus at hetland.org>
- Status:
- Final
- Typ:
- Standards Track
- Benötigt:
- 234
- Erstellt:
- 18-Mai-2001
- Python-Version:
- 2.2
- Post-History:
- 14-Jun-2001, 23-Jun-2001
Inhaltsverzeichnis
- Zusammenfassung
- Motivation
- Spezifikation: Yield
- Spezifikation: Return
- Spezifikation: Generatoren und Ausnahmebehandlung
- Spezifikation: Try/Except/Finally
- Beispiel
- F & A
- Warum nicht ein neues Schlüsselwort statt der Wiederverwendung von
def? - Warum ein neues Schlüsselwort für
yield? Warum nicht eine eingebaute Funktion? - Warum dann keine andere spezielle Syntax ohne neues Schlüsselwort?
- Warum
returnüberhaupt zulassen? Warum nicht erzwingen, dass die Beendigung alsraise StopIterationgeschrieben wird? - Warum dann nicht auch einen Ausdruck bei
returnzulassen?
- Warum nicht ein neues Schlüsselwort statt der Wiederverwendung von
- BDFL-Bekanntmachungen
- Referenzimplementierung
- Fußnoten und Referenzen
- Urheberrecht
Zusammenfassung
Dieses PEP führt das Konzept der Generatoren in Python ein, sowie eine neue Anweisung, die in Verbindung mit ihnen verwendet wird, die yield-Anweisung.
Motivation
Wenn eine Producer-Funktion eine so schwierige Aufgabe hat, dass sie einen Zustand zwischen den produzierten Werten aufrechterhalten muss, bieten die meisten Programmiersprachen keine angenehme und effiziente Lösung, außer das Hinzufügen einer Callback-Funktion zur Argumentliste des Producers, die mit jedem produzierten Wert aufgerufen wird.
Zum Beispiel verwendet tokenize.py in der Standardbibliothek diesen Ansatz: Der Aufrufer muss eine Token-Esser-Funktion an tokenize() übergeben, die jedes Mal aufgerufen wird, wenn tokenize() das nächste Token findet. Dies ermöglicht es, Tokenizer auf natürliche Weise zu codieren, aber Programme, die Tokenizer aufrufen, sind typischerweise durch die Notwendigkeit, sich zwischen den Rückrufen zu merken, welche Token zuletzt gesehen wurden, verkompliziert. Die Token-Esser-Funktion in tabnanny.py ist ein gutes Beispiel dafür, da sie einen Zustandsautomaten in globalen Variablen aufrechterhält, um sich über Rückrufe hinweg zu merken, was sie bereits gesehen hat und was sie als nächstes erwartet. Dies war schwierig korrekt zum Laufen zu bringen und ist für Menschen immer noch schwer zu verstehen. Leider ist das typisch für diesen Ansatz.
Eine Alternative wäre gewesen, dass der Tokenizer eine vollständige Analyse des Python-Programms auf einmal in einer großen Liste erzeugt. Dann könnten Tokenizer-Clients auf natürliche Weise mit lokalen Variablen und lokaler Kontrollflusslogik (wie Schleifen und verschachtelte If-Anweisungen) geschrieben werden, um ihren Zustand zu verfolgen. Dies ist jedoch nicht praktikabel: Programme können sehr groß sein, sodass keine a-priori-Grenze für den benötigten Speicher zur Materialisierung der gesamten Analyse festgelegt werden kann; und einige Tokenizer-Clients möchten nur sehen, ob etwas Spezifisches früh im Programm vorkommt (z. B. eine zukünftige Anweisung oder, wie in IDLE, nur die erste eingerückte Anweisung), und dann ist das Parsen des gesamten Programms zuerst eine erhebliche Zeitverschwendung.
Eine weitere Alternative wäre, tokenize zu einem Iterator zu machen, der das nächste Token liefert, wenn seine .next()-Methode aufgerufen wird. Dies ist für den Aufrufer auf die gleiche Weise angenehm wie eine große Liste von Ergebnissen, jedoch ohne die Nachteile in Bezug auf Speicher und "Was, wenn ich frühzeitig aussteigen möchte?". Dies verschiebt jedoch die Last auf tokenize, seinen Zustand zwischen .next()-Aufrufen zu speichern, und der Leser muss nur einen Blick auf tokenize.tokenize_loop() werfen, um zu erkennen, welche schreckliche Plackerei das wäre. Oder stellen Sie sich einen rekursiven Algorithmus zur Erzeugung der Knoten einer allgemeinen Baumstruktur vor: um diesen in ein Iterator-Framework zu gießen, müssen Sie die Rekursion manuell entfernen und den Zustand des Traversierens von Hand verwalten.
Eine vierte Option ist, den Produzenten und Konsumenten in separaten Threads auszuführen. Dies ermöglicht es beiden, ihre Zustände auf natürliche Weise zu erhalten, und ist daher für beide angenehm. Tatsächlich stellt Demo/threads/Generator.py in der Python-Quellcodeverteilung eine brauchbare synchronisierte Kommunikationsklasse zur Verfügung, um dies auf allgemeine Weise zu tun. Dies funktioniert jedoch nicht auf Plattformen ohne Threads und ist auf Plattformen mit Threads sehr langsam (im Vergleich zu dem, was ohne Threads erreichbar ist).
Eine letzte Option ist die Verwendung der Stackless-Variantenimplementierung von Python [1] (PEP 219), die leichtgewichtige Coroutinen unterstützt. Dies hat fast die gleichen programmatischen Vorteile wie die Thread-Option, ist aber viel effizienter. Stackless ist jedoch eine kontroverse Neudefinition des Python-Kerns, und es ist möglicherweise nicht möglich, dass Jython die gleichen Semantiken implementiert. Dieses PEP ist nicht der Ort, um dies zu diskutieren, daher genügt es, hier zu sagen, dass Generatoren eine nützliche Teilmenge der Stackless-Funktionalität auf eine Weise bereitstellen, die leicht in die aktuelle CPython-Implementierung passt und für andere Python-Implementierungen als relativ unkompliziert gilt.
Das erschöpft die aktuellen Alternativen. Einige andere Hochsprachen bieten angenehme Lösungen, insbesondere Iteratoren in Sather [2], die von Iteratoren in CLU inspiriert wurden; und Generatoren in Icon [3], einer neuartigen Sprache, in der jeder Ausdruck ein Generator ist. Es gibt Unterschiede zwischen diesen, aber die Grundidee ist dieselbe: eine Art von Funktion bereitstellen, die ein Zwischenergebnis ("den nächsten Wert") an ihren Aufrufer zurückgeben kann, aber den lokalen Zustand der Funktion beibehält, so dass die Funktion genau dort, wo sie aufgehört hat, wieder fortgesetzt werden kann. Ein sehr einfaches Beispiel
def fib():
a, b = 0, 1
while 1:
yield b
a, b = b, a+b
Wenn fib() zum ersten Mal aufgerufen wird, werden a auf 0 und b auf 1 gesetzt, dann wird b an seinen Aufrufer zurückgegeben. Der Aufrufer sieht 1. Wenn fib wieder aufgenommen wird, ist die yield-Anweisung aus seiner Sicht wirklich dasselbe wie z. B. eine print-Anweisung: fib fährt nach dem Yield mit intaktem lokaler Zustand fort. a und b werden dann zu 1 und 1, und fib kehrt zur yield-Anweisung zurück und gibt 1 an seinen Aufrufer weiter. Und so weiter. Aus Sicht von fib liefert es einfach eine Sequenz von Ergebnissen, als ob über einen Rückruf. Aber aus Sicht seines Aufrufers ist die fib-Aufrufung ein iterierbares Objekt, das nach Belieben fortgesetzt werden kann. Wie beim Thread-Ansatz erlaubt dies beiden Seiten, auf die natürlichste Weise codiert zu werden; aber im Gegensatz zum Thread-Ansatz kann dies effizient und auf allen Plattformen geschehen. Tatsächlich sollte das Wiederaufnehmen eines Generators nicht teurer sein als ein Funktionsaufruf.
Die gleiche Art von Ansatz gilt für viele Produzent/Konsument-Funktionen. Zum Beispiel könnte tokenize.py das nächste Token liefern, anstatt eine Callback-Funktion damit als Argument aufzurufen, und Tokenizer-Clients könnten die Tokens auf natürliche Weise durchlaufen: Ein Python-Generator ist eine Art von Python Iterator, aber von einer besonders leistungsfähigen Art.
Spezifikation: Yield
Eine neue Anweisung wird eingeführt
yield_stmt: "yield" expression_list
yield ist ein neues Schlüsselwort, daher ist eine future-Anweisung (PEP 236) erforderlich, um dies einzuführen: In der ersten Veröffentlichung muss ein Modul, das Generatoren verwenden möchte, die Zeile
from __future__ import generators
am Anfang enthalten (siehe PEP 236 für Details). Module, die die Kennung yield ohne eine future-Anweisung verwenden, lösen Warnungen aus. In der folgenden Veröffentlichung wird yield ein Sprachschlüsselwort sein und die future-Anweisung wird nicht mehr benötigt.
Die yield-Anweisung darf nur in Funktionen verwendet werden. Eine Funktion, die eine yield-Anweisung enthält, wird als Generatorfunktion bezeichnet. Eine Generatorfunktion ist in jeder Hinsicht ein gewöhnliches Funktions-Objekt, aber mit dem neuen Flag CO_GENERATOR in der co_flags-Mitglied des Code-Objekts.
Wenn eine Generatorfunktion aufgerufen wird, werden die tatsächlichen Argumente auf übliche Weise an formale Argumentnamen im Funktionslokalen gebunden, aber kein Code im Körper der Funktion wird ausgeführt. Stattdessen wird ein Generator-Iterator-Objekt zurückgegeben; dies erfüllt das Iterator-Protokoll, sodass es insbesondere in for-Schleifen auf natürliche Weise verwendet werden kann. Beachten Sie, dass der nicht qualifizierte Name "Generator" verwendet werden kann, um sich entweder auf eine Generatorfunktion oder einen Generator-Iterator zu beziehen, wenn die Absicht aus dem Kontext klar ist.
Jedes Mal, wenn die .next()-Methode eines Generator-Iterators aufgerufen wird, wird der Code im Körper der Generatorfunktion ausgeführt, bis eine yield- oder return-Anweisung (siehe unten) erreicht wird oder bis das Ende des Körpers erreicht ist.
Wenn eine yield-Anweisung angetroffen wird, wird der Zustand der Funktion eingefroren und der Wert von expression_list wird an den Aufrufer von .next() zurückgegeben. Mit "eingefroren" meinen wir, dass aller lokale Zustand beibehalten wird, einschließlich der aktuellen Bindungen lokaler Variablen, des Befehlszeigers und des internen Auswertungsstapels: Genug Informationen werden gespeichert, damit die Funktion beim nächsten Aufruf von .next() genau so fortfahren kann, als ob die yield-Anweisung nur ein weiterer externer Aufruf wäre.
Einschränkung: Eine yield-Anweisung ist im try-Block eines try/finally-Konstrukts nicht zulässig. Die Schwierigkeit besteht darin, dass keine Garantie besteht, dass der Generator jemals wieder aufgenommen wird, daher keine Garantie, dass der finally-Block jemals ausgeführt wird; das ist eine zu große Verletzung des Zwecks von finally, um erträglich zu sein.
Einschränkung: Ein Generator kann nicht wieder aufgenommen werden, während er aktiv ausgeführt wird
>>> def g():
... i = me.next()
... yield i
>>> me = g()
>>> me.next()
Traceback (most recent call last):
...
File "<string>", line 2, in g
ValueError: generator already executing
Spezifikation: Return
Eine Generatorfunktion kann auch Rückgabeanweisungen der Form enthalten
return
Beachten Sie, dass eine expression_list bei Rückgabeanweisungen im Körper eines Generators nicht zulässig ist (obwohl sie natürlich in den Körpern von Nicht-Generator-Funktionen erscheinen können, die innerhalb des Generators verschachtelt sind).
Wenn eine return-Anweisung angetroffen wird, verfährt die Steuerung wie bei jeder Funktionsrückgabe, indem die entsprechenden finally-Klauseln (falls vorhanden) ausgeführt werden. Dann wird eine StopIteration-Ausnahme ausgelöst, die signalisiert, dass der Iterator erschöpft ist. Eine StopIteration-Ausnahme wird auch ausgelöst, wenn die Steuerung das Ende des Generators erreicht, ohne ein explizites return.
Beachten Sie, dass return "Ich bin fertig und habe nichts Interessantes zurückzugeben" bedeutet, sowohl für Generatorfunktionen als auch für Nicht-Generator-Funktionen.
Beachten Sie, dass return nicht immer äquivalent zum Auslösen von StopIteration ist: Der Unterschied liegt in der Behandlung von umschließenden try/except-Konstrukten. Zum Beispiel,
>>> def f1():
... try:
... return
... except:
... yield 1
>>> print list(f1())
[]
weil, wie in jeder Funktion, return einfach beendet, aber
>>> def f2():
... try:
... raise StopIteration
... except:
... yield 42
>>> print list(f2())
[42]
weil StopIteration von einem leeren except abgefangen wird, wie jede Ausnahme.
Spezifikation: Generatoren und Ausnahmebehandlung
Wenn eine nicht behandelte Ausnahme – einschließlich, aber nicht beschränkt auf StopIteration – von einer Generatorfunktion ausgelöst wird oder durch sie hindurchgeht, wird die Ausnahme wie üblich an den Aufrufer weitergegeben, und nachfolgende Versuche, die Generatorfunktion wiederaufzunehmen, lösen StopIteration aus. Mit anderen Worten, eine nicht behandelte Ausnahme beendet das nützliche Leben eines Generators.
Beispiel (nicht idiomatisch, aber zur Veranschaulichung)
>>> def f():
... return 1/0
>>> def g():
... yield f() # the zero division exception propagates
... yield 42 # and we'll never get here
>>> k = g()
>>> k.next()
Traceback (most recent call last):
File "<stdin>", line 1, in ?
File "<stdin>", line 2, in g
File "<stdin>", line 2, in f
ZeroDivisionError: integer division or modulo by zero
>>> k.next() # and the generator cannot be resumed
Traceback (most recent call last):
File "<stdin>", line 1, in ?
StopIteration
>>>
Spezifikation: Try/Except/Finally
Wie bereits erwähnt, ist yield nicht im try-Block eines try/finally-Konstrukts zulässig. Eine Konsequenz ist, dass Generatoren kritische Ressourcen mit großer Sorgfalt zuweisen sollten. Es gibt keine Einschränkung für yield, ansonsten in finally-Klauseln, except-Klauseln oder im try-Block eines try/except-Konstrukts zu erscheinen
>>> def f():
... try:
... yield 1
... try:
... yield 2
... 1/0
... yield 3 # never get here
... except ZeroDivisionError:
... yield 4
... yield 5
... raise
... except:
... yield 6
... yield 7 # the "raise" above stops this
... except:
... yield 8
... yield 9
... try:
... x = 12
... finally:
... yield 10
... yield 11
>>> print list(f())
[1, 2, 4, 5, 8, 9, 10, 11]
>>>
Beispiel
# A binary tree class.
class Tree:
def __init__(self, label, left=None, right=None):
self.label = label
self.left = left
self.right = right
def __repr__(self, level=0, indent=" "):
s = level*indent + `self.label`
if self.left:
s = s + "\n" + self.left.__repr__(level+1, indent)
if self.right:
s = s + "\n" + self.right.__repr__(level+1, indent)
return s
def __iter__(self):
return inorder(self)
# Create a Tree from a list.
def tree(list):
n = len(list)
if n == 0:
return []
i = n / 2
return Tree(list[i], tree(list[:i]), tree(list[i+1:]))
# A recursive generator that generates Tree labels in in-order.
def inorder(t):
if t:
for x in inorder(t.left):
yield x
yield t.label
for x in inorder(t.right):
yield x
# Show it off: create a tree.
t = tree("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
# Print the nodes of the tree in in-order.
for x in t:
print x,
print
# A non-recursive generator.
def inorder(node):
stack = []
while node:
while node.left:
stack.append(node)
node = node.left
yield node.label
while not node.right:
try:
node = stack.pop()
except IndexError:
return
yield node.label
node = node.right
# Exercise the non-recursive generator.
for x in t:
print x,
print
Beide Ausgabeblöcke zeigen
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
F & A
Warum nicht ein neues Schlüsselwort statt der Wiederverwendung von def?
Siehe Abschnitt BDFL-Erklärungen unten.
Warum ein neues Schlüsselwort für yield? Warum nicht eine eingebaute Funktion?
Die Steuerung wird in Python viel besser durch Schlüsselwörter ausgedrückt, und yield ist ein Kontrollkonstrukt. Es wird auch angenommen, dass eine effiziente Implementierung in Jython erfordert, dass der Compiler potenzielle Aufhängepunkte zur Kompilierzeit erkennen kann, und ein neues Schlüsselwort erleichtert dies. Die CPython-Referenzimplementierung nutzt es ebenfalls stark, um zu erkennen, welche Funktionen Generatorfunktionen sind (obwohl ein neues Schlüsselwort anstelle von def dies für CPython lösen würde – aber Leute, die die Frage "Warum ein neues Schlüsselwort?" stellen, wollen kein neues Schlüsselwort).
Warum dann keine andere spezielle Syntax ohne neues Schlüsselwort?
Zum Beispiel, eine dieser Alternativen anstelle von yield 3
return 3 and continue
return and continue 3
return generating 3
continue return 3
return >> , 3
from generator return 3
return >> 3
return << 3
>> 3
<< 3
* 3
Habe ich eine übersehen <zwinker>? Von Hunderten von Nachrichten zählte ich drei, die eine solche Alternative vorschlugen, und extrahierte das Obige daraus. Es wäre schön, kein neues Schlüsselwort zu brauchen, aber schöner, yield sehr klar zu machen – ich will nicht ableiten müssen, dass ein Yield auftritt, indem ich eine zuvor unsinnige Sequenz von Schlüsselwörtern oder Operatoren interpretiere. Dennoch, wenn dies genügend Interesse weckt, sollten Befürworter einen einzigen Konsensvorschlag festlegen, und Guido wird ihn verkünden.
Warum return überhaupt zulassen? Warum nicht erzwingen, dass die Beendigung als raise StopIteration geschrieben wird?
Die Mechanik von StopIteration sind Low-Level-Details, ähnlich wie die Mechanik von IndexError in Python 2.1: Die Implementierung muss etwas gut definiertes im Hintergrund tun, und Python legt diese Mechanismen für fortgeschrittene Benutzer offen. Das ist jedoch kein Argument dafür, jeden auf dieser Ebene arbeiten zu lassen. return bedeutet "Ich bin fertig" in jeder Art von Funktion, und das ist leicht zu erklären und zu verwenden. Beachten Sie, dass return nicht immer äquivalent zu raise StopIteration in try/except-Konstrukten ist (siehe Abschnitt "Spezifikation: Return").
Warum dann nicht auch einen Ausdruck bei return zulassen?
Vielleicht werden wir das eines Tages tun. In Icon bedeutet return expr sowohl "Ich bin fertig" als auch "Aber ich habe noch einen letzten nützlichen Wert zurückzugeben, und das ist er". Am Anfang und in Abwesenheit zwingender Anwendungsfälle für return expr ist es einfach klarer, ausschließlich yield zur Übergabe von Werten zu verwenden.
BDFL-Bekanntmachungen
Problemstellung
Führen Sie ein weiteres neues Schlüsselwort ein (sagen wir, gen oder generator) anstelle von def, oder ändern Sie die Syntax auf andere Weise, um Generatorfunktionen von Nicht-Generator-Funktionen zu unterscheiden.
Contra
In der Praxis (wie man sie denkt) sind Generatoren tatsächlich Funktionen, aber mit dem Kniff, dass sie wiederaufnehmbar sind. Die Mechanik, wie sie eingerichtet werden, ist ein vergleichsweise geringfügiges technisches Problem, und die Einführung eines neuen Schlüsselworts würde die Mechanik, wie Generatoren gestartet werden (ein wichtiger, aber winziger Teil des Lebens eines Generators), unnötig überbetonen.
Pro
In der Realität (wie man sie denkt) sind Generatorfunktionen eigentlich Factory-Funktionen, die Generator-Iteratoren wie durch Magie erzeugen. In dieser Hinsicht unterscheiden sie sich radikal von Nicht-Generator-Funktionen und ähneln eher einem Konstruktor als einer Funktion, daher ist die Wiederverwendung von def bestenfalls verwirrend. Eine yield-Anweisung, die im Körper vergraben ist, ist keine ausreichende Warnung, dass die Semantik so unterschiedlich ist.
BDFL
def bleibt es. Kein Argument auf einer der Seiten ist überzeugend, daher habe ich meine Intuition als Sprachgestalter konsultiert. Sie sagt mir, dass die im PEP vorgeschlagene Syntax genau richtig ist – nicht zu heiß, nicht zu kalt. Aber wie das Orakel von Delphi in der griechischen Mythologie sagt es mir nicht, warum, also habe ich keine Widerlegung für die Argumente gegen die PEP-Syntax. Das Beste, was mir einfällt (abgesehen davon, den bereits gemachten Gegenargumenten zuzustimmen), ist "FUD". Wenn dies von Anfang an Teil der Sprache gewesen wäre, bezweifle ich sehr, dass es auf Andrew Kuchlings "Python Warts"-Seite gelandet wäre.
Referenzimplementierung
Die aktuelle Implementierung, in einem vorläufigen Zustand (keine Dokumentation, aber gut getestet und solide), ist Teil des CVS-Entwicklungszweigs von Python [5]. Die Verwendung erfordert, dass Sie Python aus dem Quellcode kompilieren.
Dies wurde aus einem früheren Patch von Neil Schemenauer abgeleitet [4].
Fußnoten und Referenzen
Urheberrecht
Dieses Dokument wurde gemeinfrei erklärt.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0255.rst
Zuletzt geändert: 2025-01-31 10:51:19 GMT