cat posts/generatoren-und-yield.md
Generatoren und yield wirklich verstehen
Werte auf Abruf statt alle auf einmal: wie yield funktioniert, warum Generatoren Speicher sparen und wozu Generator Expressions, yield from und Pipelines gut sind.
In Teil 11 der Python-Serie
sind Generatoren kurz aufgetaucht. Sie verdienen einen eigenen Artikel, denn
hinter dem unscheinbaren Schlüsselwort yield steckt ein wichtiges
Python-Konzept.
Generatoren lösen ein alltägliches Problem:
Ich möchte eine Folge von Werten verarbeiten, ohne zuerst alle Werte erzeugen und gleichzeitig im Speicher halten zu müssen.
Statt eine komplette Liste zurückzugeben, liefert ein Generator seine Werte nach und nach – immer dann, wenn der nächste angefordert wird.
Das ist nützlich bei großen Dateien, Datenströmen, Pipelines, potenziell unendlichen Folgen oder Berechnungen, bei denen möglicherweise nur die ersten paar Ergebnisse gebraucht werden.
Der entscheidende Punkt ist dabei nicht, dass Generatoren einfach „Listen mit weniger Speicher“ wären. Sie besitzen einen eigenen laufenden State und folgen dem Iterator-Protokoll von Python.
Eine normale Funktion liefert ihr Ergebnis auf einmal
Beginnen wir ohne Generator.
def liste_bis(bis):
zahlen = []
n = 1
while n <= bis:
zahlen.append(n)
n += 1
return zahlen
Aufruf:
werte = liste_bis(3)
print(werte)
Ausgabe:
[1, 2, 3]
Bevor liste_bis(...) etwas zurückgibt, passiert alles:
Liste anlegen
1 anhängen
2 anhängen
3 anhängen
Liste zurückgeben
Bei drei Elementen ist das kein Problem.
Wenn wir dagegen zehn Millionen Werte erzeugen, müssen diese zehn Millionen Werte zunächst in der Liste liegen, bevor der Aufrufer überhaupt das erste Element sieht.
Vielleicht brauchen wir aber gar nicht alle.
yield macht daraus eine Generatorfunktion
Ersetzen wir return nicht einfach blind durch yield, sondern bauen dieselbe
Idee als Generator:
def zaehle(bis):
n = 1
while n <= bis:
yield n
n += 1
Eine Funktion, deren Body eine yield-Expression enthält, ist eine
Generatorfunktion.
Der Aufruf:
generator = zaehle(3)
führt ihren Body noch nicht aus.
Stattdessen bekommen wir ein Generator-Objekt:
print(generator)
ungefähr:
<generator object zaehle at 0x...>
Erst wenn der nächste Wert angefordert wird, beginnt die Ausführung:
print(next(generator))
Ausgabe:
1
Jetzt ist etwas Ungewöhnliches passiert.
Die Funktion ist nicht fertig. Sie wurde beim:
yield n
angehalten.
Ihr State bleibt erhalten.
Beim nächsten:
print(next(generator))
läuft sie genau hinter diesem yield weiter.
Ausgabe:
2
Und noch einmal:
print(next(generator))
ergibt:
3
Was yield genau macht
Bei:
yield n
passieren vereinfacht zwei Dinge:
- Der aktuelle Wert wird an den Aufrufer geliefert.
- Die Ausführung der Generatorfunktion wird an dieser Stelle pausiert.
Dabei bleiben unter anderem erhalten:
lokale Variablen
aktuelle Position im Code
interner Auswertungszustand
aktive try-Blöcke
Beim nächsten Fortsetzen muss die Funktion also nicht wieder oben anfangen.
Das unterscheidet einen Generator grundlegend von einer normalen Funktion, die bei jedem Aufruf einen neuen Funktionslauf beginnt.
Den Ablauf sichtbar machen
Mit ein paar Ausgaben lässt sich das gut beobachten:
def zaehle_laut(bis):
print("Generator startet")
n = 1
while n <= bis:
print(f"vor yield: {n}")
yield n
print(f"nach yield: {n}")
n += 1
print("Generator ist fertig")
Jetzt:
generator = zaehle_laut(2)
print("Generator erzeugt")
Ausgabe:
Generator erzeugt
Noch nichts aus zaehle_laut(...).
Erst:
print(next(generator))
ergibt:
Generator startet
vor yield: 1
1
Der Generator steht nun genau hier:
yield n
Beim nächsten:
print(next(generator))
geht es hinter dieser Stelle weiter:
nach yield: 1
vor yield: 2
2
Der Code:
n += 1
wurde also nicht nach dem ersten yield ausgeführt, sondern erst beim zweiten
Fortsetzen.
Genau dieses Modell solltest Du bei Generatoren im Kopf haben.
Generatorfunktion und Generator-Objekt sind nicht dasselbe
Die Funktion:
zaehle
ist die Generatorfunktion.
Der Aufruf:
zaehle(3)
erzeugt einen neuen Generator-Iterator.
Das ist ein wichtiger Unterschied.
a = zaehle(3)
b = zaehle(3)
a und b besitzen voneinander unabhängigen State.
Wenn wir:
print(next(a))
print(next(a))
print(next(b))
ausführen, bekommen wir:
1
2
1
b beginnt seinen eigenen Durchlauf.
Wenn ein Generator fertig ist
Nach dem letzten Wert läuft die Funktion irgendwann bis zum Ende:
generator = zaehle(2)
print(next(generator))
print(next(generator))
print(next(generator))
Die ersten beiden Aufrufe liefern:
1
2
Der dritte endet mit:
StopIteration
StopIteration ist Teil des Iterator-Protokolls und bedeutet:
Dieser Iterator hat keinen weiteren Wert.
Nach der Erschöpfung bleibt er erschöpft.
Ein weiterer:
next(generator)
liefert nicht plötzlich wieder 1, sondern erneut StopIteration.
next() mit Default
Wenn Du StopIteration bei einem manuellen Abruf vermeiden möchtest, akzeptiert
next() einen Default:
generator = zaehle(1)
print(
next(
generator,
"fertig",
)
)
print(
next(
generator,
"fertig",
)
)
Ausgabe:
1
fertig
Das ist praktisch, wenn Du bewusst nur einzelne Werte aus einem Iterator abholen möchtest.
Beim normalen Durchlaufen brauchst Du es allerdings selten.
Meist verwendest Du for
Manuelles next(...) ist vor allem gut, um Generatoren zu verstehen.
Im normalen Code:
for zahl in zaehle(3):
print(zahl)
Ausgabe:
1
2
3
Die for-Schleife übernimmt das Iterator-Protokoll für uns.
Vereinfacht passiert intern etwas in dieser Art:
iterator = iter(
zaehle(3)
)
while True:
try:
zahl = next(iterator)
except StopIteration:
break
print(zahl)
Du musst diesen Code nicht selbst schreiben.
Er erklärt aber, warum Generatoren überall dort funktionieren, wo Python ein Iterable erwartet.
Generator-Objekte sind Iteratoren
Prüfen wir:
generator = zaehle(3)
print(
iter(generator)
is generator
)
Ausgabe:
True
Ein Generator-Objekt ist also selbst sein Iterator.
Eine Liste verhält sich anders:
werte = [
1,
2,
3,
]
iterator = iter(werte)
print(
iterator is werte
)
Ausgabe:
False
Die Liste ist ein Iterable.
Sie kann jederzeit einen neuen Iterator erzeugen:
a = iter(werte)
b = iter(werte)
Ein Generator-Objekt besitzt dagegen genau seinen einen fortlaufenden Iterations-State.
Generator-Objekte werden verbraucht
Das führt zu einer der häufigsten Generator-Fallen:
generator = zaehle(3)
print(
list(generator)
)
print(
list(generator)
)
Ausgabe:
[1, 2, 3]
[]
Beim ersten list(...) wurde der Generator bis zum Ende konsumiert.
Danach ist er erschöpft.
Wenn Du dieselbe Folge erneut erzeugen möchtest:
print(
list(
zaehle(3)
)
)
print(
list(
zaehle(3)
)
)
Ausgabe:
[1, 2, 3]
[1, 2, 3]
Die Generatorfunktion kannst Du beliebig oft aufrufen. Das einzelne von ihr erzeugte Generator-Objekt ist dagegen ein One-Shot-Iterator.
Warum Generatoren Speicher sparen können
Vergleichen wir eine List Comprehension:
quadrate = [
x * x
for x in range(10_000_000)
]
Python erzeugt hier zehn Millionen Integer und hält sie anschließend in einer Liste.
Wenn wir die Quadrate nur addieren möchten, brauchen wir diese Liste gar nicht:
summe = sum(
x * x
for x in range(10_000_000)
)
sum(...) fordert einen Wert nach dem anderen an.
Das jeweils nächste Quadrat kann berechnet, zur Summe addiert und anschließend wieder verworfen werden.
Eine zusätzliche Liste mit zehn Millionen Quadraten ist nicht nötig.
Das heißt allerdings nicht:
Generatoren brauchen immer konstanten Gesamtspeicher.
Die Quelle selbst kann natürlich bereits groß sein, und eine Pipeline kann Objekte oder State festhalten.
Der Vorteil ist konkreter:
Ein Generator muss seine erzeugten Ergebnisse nicht automatisch alle gleichzeitig materialisieren.
Generatoren sind nicht automatisch schneller
Weniger Speicher bedeutet nicht zwangsläufig weniger Rechenzeit.
Eine Liste:
werte = [
x * x
for x in range(1_000)
]
kann bei einer kleinen Datenmenge sehr praktisch sein.
Danach kannst Du:
len(werte)
werte[10]
for wert in werte:
...
for wert in werte:
...
beliebig damit arbeiten.
Bei einem Generator:
werte = (
x * x
for x in range(1_000)
)
gibt es keinen normalen Indexzugriff:
werte[10]
und nach einem vollständigen Durchlauf ist er erschöpft.
Ob Liste oder Generator besser ist, hängt deshalb vom benötigten Verhalten ab.
Generator Expressions
Neben Generatorfunktionen gibt es Generator Expressions.
Eine List Comprehension:
quadrate = [
zahl * zahl
for zahl in range(1, 6)
]
erzeugt:
[
1,
4,
9,
16,
25,
]
Mit runden Klammern:
quadrate = (
zahl * zahl
for zahl in range(1, 6)
)
entsteht stattdessen ein Generator-Iterator.
Verwendung:
for quadrat in quadrate:
print(quadrat)
Ausgabe:
1
4
9
16
25
Generator Expressions passen besonders gut zu Funktionen, die ohnehin ein Iterable konsumieren:
sum(
zahl * zahl
for zahl in range(1, 101)
)
Oder:
any(
raum.gegenstaende
for raum in raeume.values()
)
Oder:
all(
spieler.hp > 0
for spieler in gruppe
)
Die zusätzliche Klammer kann oft weg
Als einziges Positionsargument eines Funktionsaufrufs braucht eine Generator Expression keine eigenen zusätzlichen Klammern.
Üblich:
sum(
x * x
for x in range(10)
)
Das funktioniert ebenfalls:
sum(
(
x * x
for x in range(10)
)
)
ist aber unnötig geklammert.
Sobald noch ein weiteres Argument folgt, braucht die Generator Expression ihre eigenen Klammern:
sum(
(
x * x
for x in range(10)
),
start=100,
)
Generator Expressions sind nicht vollständig lazy
Die vereinfachte Erklärung:
Eine Generator Expression macht erst etwas, wenn über sie iteriert wird.
ist fast richtig, aber nicht ganz.
Der Ausdruck im linkesten for wird bereits beim Erzeugen des Generators
ausgewertet.
Beispiel:
def zahlen():
print("Quelle wird erzeugt")
return [
1,
2,
3,
]
generator = (
zahl * 10
for zahl in zahlen()
)
print("Generator existiert")
Ausgabe:
Quelle wird erzeugt
Generator existiert
Der Aufruf:
zahlen()
passiert also sofort.
Die Berechnung:
zahl * 10
dagegen noch nicht.
Erst:
print(
next(generator)
)
liefert:
10
Auch das Erzeugen des Iterators aus der linken Quelle passiert bereits beim Erstellen der Generator Expression.
Das erklärt, warum manche Fehler sofort auftauchen:
generator = (
x * 2
for x in None
)
Hier scheitert bereits die Erzeugung.
Andere Fehler stecken im lazy Teil:
generator = (
10 / x
for x in [
2,
0,
]
)
Der Generator lässt sich problemlos erzeugen.
Der Fehler kommt erst beim passenden Abruf.
Generator Expression oder Generatorfunktion?
Für kurze Transformationen ist eine Generator Expression kompakt:
namen = (
gegenstand.name
for gegenstand in inventar
)
Auch ein kleiner Filter bleibt gut lesbar:
fehlerzeilen = (
zeile
for zeile in zeilen
if "ERROR" in zeile
)
Sobald mehrere Schritte nötig werden, ist eine Funktion meistens verständlicher:
def nur_fehler(zeilen):
for zeile in zeilen:
if "ERROR" in zeile:
yield zeile
In einer Generatorfunktion hast Du Platz für:
Zwischenvariablen
mehrere Bedingungen
Fehlerbehandlung
Logging
Kommentare
mehrere yield-Stellen
Kürzer ist nicht automatisch besser.
Frühes Beenden kann Arbeit sparen
Lazy Evaluation ist besonders interessant, wenn gar nicht alle Werte gebraucht werden.
def quadrate():
zahl = 1
while True:
print(
f"berechne {zahl}"
)
yield zahl * zahl
zahl += 1
Nun:
for quadrat in quadrate():
if quadrat > 20:
break
print(quadrat)
Python berechnet nicht vorsorglich eine Million weitere Quadrate.
Sobald der Verbraucher aufhört, werden keine neuen Werte mehr angefordert.
Das kann bei teuren Berechnungen wichtiger sein als die reine Speicherersparnis.
Unendliche Folgen
Ein Generator muss kein natürliches Ende besitzen:
def zaehler(
start=1,
):
zahl = start
while True:
yield zahl
zahl += 1
Jetzt:
zahlen = zaehler(10)
print(next(zahlen))
print(next(zahlen))
print(next(zahlen))
Ausgabe:
10
11
12
Eine unendliche Folge wäre als fertige Liste unmöglich.
Als Generator ist sie völlig normal, solange der Verbraucher entscheidet, wann Schluss ist.
Gefährlich wäre:
list(
zaehler()
)
list(...) versucht den Generator vollständig zu konsumieren.
Bei einem unendlichen Generator passiert das nie.
itertools.islice() begrenzt einen Iterator
Das Standardmodul itertools enthält viele Werkzeuge für lazy Iteration.
Mit islice(...) nehmen wir beispielsweise nur fünf Werte:
from itertools import islice
erste_fuenf = list(
islice(
zaehler(),
5,
)
)
print(erste_fuenf)
Ausgabe:
[1, 2, 3, 4, 5]
Mit anderem Start:
for zahl in islice(
zaehler(start=100),
3,
):
print(zahl)
ergibt:
100
101
102
Gerade bei unendlichen oder sehr großen Iterables ist itertools deshalb ein
passender Begleiter zu Generatoren.
Ein endloser Ereignisstrom
Für den Dungeon können wir zufällige Ereignisse als Generator modellieren:
import random
def ereignis_strom(
ereignisse,
):
while True:
yield random.choice(
ereignisse
)
Verwendung:
ereignisse = [
"Eine Fledermaus flattert durch den Raum.",
"Irgendwo tropft Wasser.",
"Du hörst ein fernes Knurren.",
]
strom = ereignis_strom(
ereignisse
)
print(next(strom))
print(next(strom))
print(next(strom))
Der Generator muss keine zukünftigen Ereignisse vorberechnen.
Bei jedem Abruf entsteht genau das nächste.
Für Tests ist es besser, die Zufallsquelle explizit übergeben zu können:
def ereignis_strom(
ereignisse,
zufall,
):
while True:
yield zufall.choice(
ereignisse
)
Dann:
zufall = random.Random(42)
strom = ereignis_strom(
ereignisse,
zufall,
)
Der Test kann denselben pseudozufälligen Ablauf reproduzieren.
Das ist weniger ein Generator-Trick als gutes API-Design: Versteckte Dependencies lassen sich schlechter kontrollieren als explizite.
Generator-Pipelines
Generatoren werden besonders angenehm, wenn mehrere Verarbeitungsschritte aufeinander folgen.
Angenommen, logbuch.txt enthält:
INFO Spiel gestartet
ERROR Spielstand fehlt
INFO Neuer Spieler
ERROR Ungültiger Raum
Eine Generatorfunktion liest Zeilen:
from pathlib import Path
def zeilen(
pfad,
):
with Path(pfad).open(
encoding="utf-8",
) as datei:
for zeile in datei:
yield zeile.rstrip(
"\n"
)
Eine zweite filtert:
def nur_fehler(
zeilen,
):
for zeile in zeilen:
if zeile.startswith(
"ERROR "
):
yield zeile
Dann:
for fehler in nur_fehler(
zeilen(
"logbuch.txt"
)
):
print(fehler)
Ausgabe:
ERROR Spielstand fehlt
ERROR Ungültiger Raum
Es muss keine Zwischenliste mit allen Zeilen entstehen.
Auch der Filter baut keine zweite Ergebnisliste.
Der Datenfluss ist:
Datei
↓
zeilen(...)
↓
nur_fehler(...)
↓
for
Jede Stufe fordert von der vorherigen nur den nächsten benötigten Wert an.
Eine Pipeline erweitern
Eine dritte Stufe entfernt das Level:
def entferne_level(
zeilen,
):
for zeile in zeilen:
_, _, text = (
zeile.partition(
" "
)
)
yield text
Dann:
pipeline = entferne_level(
nur_fehler(
zeilen(
"logbuch.txt"
)
)
)
for meldung in pipeline:
print(meldung)
Ausgabe:
Spielstand fehlt
Ungültiger Raum
Der Code liest, filtert und transformiert jeweils nur den Wert, der gerade gebraucht wird.
Lazy Pipelines verschieben Fehler
Dass eine Pipeline erzeugt werden kann, bedeutet nicht, dass sie funktioniert.
def kaputt():
yield 1
int("viel")
yield 2
Das hier ist problemlos:
generator = kaputt()
print(
"Generator erzeugt"
)
Ausgabe:
Generator erzeugt
Nun:
print(
next(generator)
)
liefert:
1
Erst beim nächsten:
next(generator)
wird:
int("viel")
ausgeführt und der ValueError sichtbar.
Beim Debugging von Generator-Code ist deshalb die Frage wichtig:
Wurde nur der Generator erzeugt oder wurde er tatsächlich bis zur problematischen Stelle konsumiert?
Generatoren und Dateien
Unser:
def zeilen(pfad):
with Path(pfad).open(
encoding="utf-8",
) as datei:
for zeile in datei:
yield zeile.rstrip("
")
hat noch eine wichtige Eigenschaft.
Die Datei wird nicht schon bei:
generator = zeilen(
"logbuch.txt"
)
geöffnet.
Der Generator-Body läuft noch nicht.
Erst der erste Abruf erreicht:
Path(pfad).open(...)
Danach bleibt die Datei offen, solange der Generator innerhalb des
with-Blocks pausiert.
Bei vollständigem Konsum wird der Block normal verlassen und die Datei geschlossen.
Vorzeitiges Ende und Ressourcen
Was passiert, wenn der Verbraucher vorher aufhört?
Ein Generator besitzt:
generator.close()
Beim Schließen wird an der pausierten Stelle intern GeneratorExit ausgelöst.
Dadurch können finally-Blöcke und Context Manager aufräumen.
Beispiel:
def werte():
print(
"Ressource geöffnet"
)
try:
yield 1
yield 2
finally:
print(
"Ressource geschlossen"
)
Jetzt:
generator = werte()
print(
next(generator)
)
generator.close()
Ausgabe:
Ressource geöffnet
1
Ressource geschlossen
Das funktioniert auch mit einem with-Block, weil dessen Cleanup letztlich
ebenfalls über einen finally-artigen Mechanismus sichergestellt wird.
Nicht auf Garbage Collection als Ressourcenstrategie verlassen
Generatoren werden bei ihrer Finalisierung ebenfalls geschlossen, sodass ausstehender Cleanup ausgeführt werden kann.
Trotzdem solltest Du wichtigen Ressourcen-Cleanup nicht auf die Hoffnung aufbauen:
Irgendwann räumt der Garbage Collector das bestimmt auf.
Wann ein unreferenziertes Objekt finalisiert wird, ist kein guter Teil Deiner Anwendungslogik.
Wenn Du einen ressourcenhaltenden Generator manuell nur teilweise konsumierst, kannst Du ihn ausdrücklich schließen:
generator = zeilen(
"logbuch.txt"
)
try:
print(
next(generator)
)
finally:
generator.close()
Für APIs mit klarer Ressourcennutzung kann auch ein Context Manager das bessere Design sein.
Generatoren eignen sich hervorragend zum Streamen von Ressourcen. Man sollte nur wissen, dass ihre Lebensdauer dann gleichzeitig die Lebensdauer dieser Ressource beeinflussen kann.
yield from: Werte delegieren
Angenommen, jeder Raum enthält Gegenstände:
def alle_gegenstaende(
raeume,
):
for raum in raeume:
for gegenstand in raum.gegenstaende:
yield gegenstand
Das innere:
for ...
yield ...
kann mit yield from ausgedrückt werden:
def alle_gegenstaende(
raeume,
):
for raum in raeume:
yield from (
raum.gegenstaende
)
Ein einfacheres Beispiel:
def erst_a_dann_b():
yield from [
"a1",
"a2",
]
yield from [
"b1",
"b2",
]
Dann:
print(
list(
erst_a_dann_b()
)
)
Ausgabe:
['a1', 'a2', 'b1', 'b2']
Für diesen Anwendungsfall bedeutet:
yield from iterable
sinngemäß:
Reiche alle Werte dieses Iterables weiter.
yield from kann mehr als eine Schleife abkürzen
Bei einem normalen Iterable ist:
yield from werte
leicht als Kurzform für:
for wert in werte:
yield wert
zu verstehen.
Wenn das delegierte Objekt selbst ein Generator ist, geht yield from aber
weiter.
Es delegiert auch Teile des Generator-Protokolls wie:
send(...)
throw(...)
close()
an den Subgenerator.
Außerdem kann es dessen return-Wert empfangen.
Das wird wichtig, sobald Generatoren nicht nur einfache Werteströme, sondern miteinander kommunizierende Generatoren sind.
Einen return-Wert mit yield from übernehmen
Ein Subgenerator:
def teil():
yield 1
yield 2
return "fertig"
Ein äußerer Generator:
def gesamt():
ergebnis = yield from teil()
print(
f"Subgenerator: {ergebnis}"
)
yield 3
Nun:
print(
list(
gesamt()
)
)
Ausgabe:
Subgenerator: fertig
[1, 2, 3]
Der String:
fertig
war kein normaler Yield-Wert.
Er wurde zum Wert der Expression yield from teil().
Damit kommen wir zum Unterschied zwischen yield und return in Generatoren.
return beendet einen Generator
Dieser Generator:
def werte():
yield 1
return
yield 2
liefert:
print(
list(
werte()
)
)
nur:
[1]
return beendet die Generatorfunktion.
Ein Wert hinter return:
def werte():
yield 1
return "fertig"
wird nicht als weiteres Element geliefert:
for wert in werte():
print(wert)
Ausgabe:
1
Intern landet der Rückgabewert in der StopIteration, mit der der Generator
endet.
StopIteration.value
Manuell können wir den Rückgabewert sehen:
generator = werte()
print(
next(generator)
)
try:
next(generator)
except StopIteration as fehler:
print(
fehler.value
)
Ausgabe:
1
fertig
Im normalen Anwendungsalltag greifst Du selten direkt darauf zu.
yield from macht diese Generator-Rückgabewerte deutlich praktischer, weil es
sie automatisch übernimmt.
StopIteration nicht selbst aus einer Generatorfunktion werfen
Dieser Code ist falsch:
def generator():
yield 1
raise StopIteration
Seit Python 3.7 wird eine StopIteration, die unbehandelt aus dem Body einer
Generatorfunktion herausläuft, in einen RuntimeError umgewandelt.
Zum Beenden verwendest Du:
return
oder lässt die Funktion einfach auslaufen:
def generator():
yield 1
Das Iterator-Protokoll erzeugt die notwendige StopIteration selbst.
Rekursive Generatoren
yield from passt gut zu rekursiven Strukturen.
Eine verschachtelte Liste:
werte = [
1,
[
2,
3,
],
[
4,
[
5,
6,
],
],
]
können wir rekursiv abflachen:
def flach(werte):
for wert in werte:
if isinstance(
wert,
list,
):
yield from flach(
wert
)
else:
yield wert
Dann:
print(
list(
flach(werte)
)
)
Ausgabe:
[1, 2, 3, 4, 5, 6]
Ohne yield from müsste der rekursive Teil explizit wieder durchlaufen werden:
for innerer_wert in flach(
wert
):
yield innerer_wert
Beides funktioniert.
yield from drückt die Delegation direkter aus.
yield ist eine Expression
Bisher sah:
yield wert
fast wie eine besondere Form von return aus.
Technisch ist yield aber eine Expression und besitzt selbst einen Wert, wenn
der Generator später fortgesetzt wird.
Bei einem normalen:
next(generator)
ist dieser empfangene Wert:
None
Mit:
generator.send(...)
kann der Aufrufer stattdessen einen eigenen Wert hineinreichen.
send(): mit einem Generator kommunizieren
Ein kleines Beispiel:
def sammler():
gesamt = 0
while True:
wert = yield gesamt
if wert is not None:
gesamt += wert
Zuerst muss der Generator bis zum ersten yield laufen:
generator = sammler()
print(
next(generator)
)
Ausgabe:
0
Jetzt pausiert er bei:
wert = yield gesamt
Mit:
print(
generator.send(5)
)
bekommt die yield-Expression den Wert 5.
Der Code läuft weiter:
wert = 5
gesamt += 5
und erreicht das nächste yield.
Ausgabe:
5
Danach:
print(
generator.send(10)
)
ergibt:
15
Ein neuer Generator muss zuerst gestartet werden
Das hier funktioniert nicht:
generator = sammler()
generator.send(5)
Ein gerade erzeugter Generator steht noch vor seinem ersten yield.
Deshalb kannst Du ihm noch keinen normalen Wert schicken.
Zum Start verwendest Du:
next(generator)
oder äquivalent:
generator.send(None)
Erst wenn der Generator an einem yield pausiert, kann ein anderer Wert mit
send(...) hineingeschickt werden.
Brauche ich send() im Alltag?
Oft nicht.
Die meisten Generatoren sehen eher so aus:
def zahlen():
yield 1
yield 2
yield 3
und werden ausschließlich über:
for ...
oder:
next(...)
konsumiert.
send() ist trotzdem wichtig zum Verständnis von yield.
Es zeigt, dass yield keine Einbahnstraße ist.
Generatoren können Werte liefern und beim Fortsetzen Informationen zurückbekommen.
Dieses erweiterte Generator-Protokoll war auch ein wichtiger Schritt auf dem Weg zu späteren Coroutine-Konzepten in Python.
Für moderne asynchrone Anwendungen benutzt Du allerdings normalerweise
async/await und nicht selbst gebaute send()-Coroutine-Systeme.
throw(): eine Exception in den Generator schicken
Neben:
send(...)
besitzt ein Generator:
throw(...)
Damit wird eine Exception an der Stelle ausgelöst, an der der Generator gerade pausiert.
Beispiel:
def generator():
try:
while True:
yield "läuft"
except ValueError:
yield "ValueError behandelt"
Verwendung:
g = generator()
print(
next(g)
)
print(
g.throw(
ValueError(
"Test"
)
)
)
Ausgabe:
läuft
ValueError behandelt
Für normale Datenpipelines brauchst Du throw() selten.
Es gehört aber zusammen mit:
send()
throw()
close()
zum erweiterten Generator-Protokoll.
Wenn Du throw() direkt verwendest, ist die moderne Form mit einer
Exception-Instanz die passende Wahl. Die historische Mehrargument-Variante ist seit
Python 3.12 deprecated.
close() und GeneratorExit
Wie weiter oben gesehen:
generator.close()
beendet einen Generator kontrolliert.
Intern wird dabei an der pausierten Stelle:
GeneratorExit
ausgelöst.
Cleanup gehört normalerweise in:
try:
...
finally:
...
und nicht in einen komplizierten except GeneratorExit.
Ein Generator darf während des Schließens insbesondere nicht einfach einen
weiteren Wert yielden. Das würde einen RuntimeError auslösen.
Für normalen Generator-Code ist die praktische Regel simpel:
Cleanup in
finallyoder Context Manager schreiben undGeneratorExitnormalerweise nicht selbst behandeln.
Type Hints für einfache Generatoren
Für einen Generator, der nur Werte liefert, ist Iterator[T] meistens eine gute
Annotation:
from collections.abc import Iterator
def zaehle(
bis: int,
) -> Iterator[int]:
n = 1
while n <= bis:
yield n
n += 1
Die Signatur sagt:
Diese Funktion liefert einen Iterator über Integer.
Du könntest auch allgemeiner:
from collections.abc import Iterable
def zaehle(
bis: int,
) -> Iterable[int]:
...
schreiben, wenn für den Aufrufer nur wichtig sein soll, dass das Ergebnis iterierbar ist.
Da die konkrete Funktion tatsächlich einen Iterator zurückgibt, ist:
Iterator[int]
häufig die passendere Beschreibung.
Generator für send() und return
Wenn das vollständige Generator-Protokoll Teil des Typs sein soll:
from collections.abc import Generator
Der Typ besitzt drei Parameter:
Generator[
YieldType,
SendType,
ReturnType,
]
Zum Beispiel für unseren Sammler:
from collections.abc import Generator
def sammler() -> Generator[
int,
int | None,
None,
]:
gesamt = 0
while True:
wert = yield gesamt
if wert is not None:
gesamt += wert
Das bedeutet:
yieldet int
akzeptiert über send int oder None
returnt keinen besonderen Wert
Bei einem Generator mit Rückgabewert:
def teil() -> Generator[
int,
None,
str,
]:
yield 1
yield 2
return "fertig"
ist der dritte Typ:
str
Seit Python 3.13 sind die letzten Generator-Typen optional
Für einen einfachen Generator ohne send()-Werte und ohne besonderen
return-Wert kann in aktuellen Python-Versionen sogar:
from collections.abc import Generator
def zaehle(
bis: int,
) -> Generator[int]:
...
verwendet werden.
SendType und ReturnType haben seit Python 3.13 den Default None.
Trotzdem würde ich für einen normalen Wertelieferanten meist:
Iterator[int]
bevorzugen.
Generator[...] ist besonders interessant, wenn send() oder der
Generator-Rückgabewert tatsächlich Teil des Vertrages sind.
Mehr zu Type Hints findest Du in Type Hints in der Praxis.
Generatoren testen
Ein endlicher Generator lässt sich häufig sehr einfach materialisieren:
def test_zaehle():
assert list(
zaehle(3)
) == [
1,
2,
3,
]
Damit prüfst Du die gesamte gelieferte Sequenz.
Bei einer unendlichen Quelle:
from itertools import islice
def test_zaehler():
assert list(
islice(
zaehler(10),
3,
)
) == [
10,
11,
12,
]
Eine Pipeline:
def test_nur_fehler():
eingabe = [
"INFO Start",
"ERROR Kaputt",
"INFO Ende",
]
assert list(
nur_fehler(
eingabe
)
) == [
"ERROR Kaputt",
]
Generatorfunktionen sind häufig angenehm zu testen, wenn sie ihre Quelle als
Iterable entgegennehmen und ihre Ergebnisse lediglich yielden.
Dann brauchst Du im Test weder echte Dateien noch globale Datenquellen.
Lazy Verhalten gezielt testen
Manchmal ist gerade wichtig, dass noch nicht alles verarbeitet wird.
def quelle():
yield 1
raise RuntimeError(
"zweiter Wert kaputt"
)
Wenn unser Verbraucher nur den ersten Wert braucht:
generator = quelle()
assert next(generator) == 1
muss der Fehler der späteren Verarbeitung noch gar nicht auftreten.
Das kann bei Streams oder Suchfunktionen gewünschtes Verhalten sein.
Tests sollten deshalb nicht automatisch jeden Generator mit:
list(...)
vollständig konsumieren, wenn das eigentliche API gerade Early Exit ermöglichen soll.
Listen und Generatoren haben unterschiedliche Semantik
Eine Funktion:
def finde_gegenstaende(
raum,
):
return [
gegenstand
for gegenstand in raum.gegenstaende
if gegenstand.sichtbar
]
verspricht dem Aufrufer eine fertige Liste.
Der Code kann:
len(ergebnis)
ergebnis[0]
for wert in ergebnis:
...
for wert in ergebnis:
...
verwenden.
Ändern wir die Implementierung einfach zu:
def finde_gegenstaende(
raum,
):
return (
gegenstand
for gegenstand in raum.gegenstaende
if gegenstand.sichtbar
)
hat sich mehr verändert als nur die Speicherstrategie.
Nun gilt:
kein normaler Indexzugriff
kein len()
nur ein vollständiger Durchlauf
Berechnung passiert später
Fehler können später auftreten
State der Quelle kann später anders sein
Ob ein API Liste oder Iterator zurückgibt, kann deshalb Teil seines öffentlichen Vertrages sein.
Lazy bedeutet auch: Werte können später anders aussehen
Nehmen wir:
werte = [
1,
2,
3,
]
generator = (
wert * 10
for wert in werte
)
Jetzt verändern wir die Liste vor dem Konsum:
werte.append(4)
Dann:
print(
list(generator)
)
liefert:
[10, 20, 30, 40]
Der Generator enthält keine vorberechnete Kopie der ursprünglichen Werte.
Er iteriert später über seine Quelle.
Das ist häufig genau gewünscht. Es kann aber überraschend sein, wenn man einen Generator gedanklich wie eine bereits fertige Collection behandelt.
Listen sind besser, wenn Du eine Momentaufnahme willst
Gerade deshalb kann eine Liste absichtlich die bessere Wahl sein:
sichtbare_gegenstaende = [
gegenstand
for gegenstand in raum.gegenstaende
if gegenstand.sichtbar
]
Diese Liste ist jetzt eine konkrete Momentaufnahme.
Ein Generator:
sichtbare_gegenstaende = (
gegenstand
for gegenstand in raum.gegenstaende
if gegenstand.sichtbar
)
wertet die Bedingung erst später beim Konsum aus.
Lazy Evaluation ist kein pauschaler Vorteil. Sie verändert den Zeitpunkt, zu dem Arbeit und Beobachtung stattfinden.
Wann Generatoren gut passen
Generatoren passen besonders gut, wenn Werte:
nacheinander verarbeitet werden
nur einmal gebraucht werden
teuer zu berechnen sind
aus einer großen Quelle kommen
als Pipeline weitergereicht werden
potenziell unendlich sind
möglicherweise nur teilweise benötigt werden
Typische Beispiele:
def lese_zeilen(
pfad,
):
...
def filtere_fehler(
zeilen,
):
...
def erzeuge_ereignisse():
...
def alle_gegenstaende(
raeume,
):
...
Wann eine Liste besser passt
Eine Liste ist oft angenehmer, wenn Du:
mehrfach darüber iterieren möchtest
Indexzugriff brauchst
len() benötigst
Werte sortieren oder mutieren möchtest
eine feste Momentaufnahme willst
Fehler bewusst sofort auslösen möchtest
nur eine kleine Datenmenge hast
das Ergebnis dauerhaft speichern willst
Dann ist:
werte = list(
zaehle(10)
)
völlig in Ordnung.
Generatoren sind kein Ersatz für Listen.
Sie lösen ein anderes Problem.
Generatoren sind auch nicht dasselbe wie Async Generators
Ein normaler Generator:
def zahlen():
yield 1
wird mit:
for zahl in zahlen():
...
konsumiert.
Ein asynchroner Generator entsteht dagegen aus async def und yield:
async def nachrichten():
yield "Hallo"
Er wird mit:
async for nachricht in nachrichten():
...
verwendet.
Async Generators sind für asynchrone Datenquellen gedacht und besitzen ein eigenes Protokoll mit Methoden wie:
__anext__()
asend()
athrow()
aclose()
Das Grundprinzip – Werte schrittweise liefern – ist verwandt. Die normale Generator-Iteration und die asynchrone Iteration solltest Du trotzdem als zwei verschiedene Protokolle betrachten.
Häufige Stolperfallen
-
Generatorfunktion und Generator-Objekt verwechseln:
zaehleist die Funktion,zaehle(3)erzeugt einen neuen Generator mit eigenem State. -
Erwarten, dass eine Generatorfunktion beim Aufruf schon läuft: Der Body startet erst, wenn das Generator-Objekt fortgesetzt wird.
-
Generator-Objekte mehrfach verwenden wollen: Ein vollständig konsumierter Generator bleibt erschöpft. Für einen neuen Durchlauf erzeugst Du einen neuen.
-
Lazy mit „gar nichts passiert beim Erzeugen“ gleichsetzen: Bei Generator Expressions wird die Quelle des linkesten
forbereits beim Erzeugen ausgewertet und daraus ein Iterator erstellt. -
Generatoren pauschal für schneller halten: Sie können Speicher und unnötige Berechnungen sparen, haben aber ebenfalls Laufzeit-Overhead.
-
Eine Liste durch einen Generator ersetzen und das für ein internes Detail halten: Dadurch ändern sich unter anderem Wiederverwendbarkeit, Indexzugriff und der Zeitpunkt der Auswertung.
-
list(...)auf einen unendlichen Generator anwenden: Ein unendlicher Iterator kann nicht vollständig materialisiert werden. -
Beim Debugging nur die Erzeugung testen: Fehler innerhalb eines Generators treten häufig erst beim Konsumieren auf.
-
return wertfür einen weiteren Yield-Wert halten:returnbeendet den Generator. Sein Wert landet inStopIteration.valueund kann mityield fromübernommen werden. -
Selbst
StopIterationaus dem Generator werfen: Zum normalen Beenden verwendest Dureturnoder lässt die Funktion auslaufen. -
yield fromnur als kürzerefor-Schleife verstehen: Bei Subgeneratoren delegiert es zusätzlichsend(),throw(),close()und übernimmt denreturn-Wert. -
Direkt
send(5)an einen neuen Generator schicken: Vor dem ersten normalen Wert muss der Generator mitnext(...)odersend(None)gestartet werden. -
send()für normalen Generator-Code erzwingen: Viele Generatoren brauchen ausschließlichyield,forund gelegentlichnext(). -
Ressourcenhaltende Generatoren unbegrenzt liegen lassen: Solange ein pausierter Generator eine Datei oder andere Ressource hält, kann diese offen bleiben.
-
Auf Garbage Collection als Cleanup-Mechanismus vertrauen: Wenn die Lebensdauer einer Ressource wichtig ist, sollte der Cleanup bewusst strukturiert sein.
-
Während
close()weiteryielden: Beim Schließen soll der Generator enden. Ein neuer Yield-Wert führt zu einemRuntimeError. -
Generator Expressions zu kompliziert machen: Mehrere Bedingungen, Seiteneffekte oder Fehlerbehandlung sind meistens in einer Generatorfunktion verständlicher.
-
Lazy State unterschätzen: Wenn sich eine zugrunde liegende Collection zwischen Erzeugung und Konsum verändert, kann der Generator den späteren Zustand sehen.
-
Immer
Generator[...]als Return Type verwenden: Für einfache Wertelieferanten reichtIterator[T]meistens aus und beschreibt das benötigte Interface klarer.
Übungen
1. Einen einfachen Generator schreiben
Schreibe einen Generator zaehle_bis(bis), der die Zahlen von 1 bis bis
liefert.
Lösung
def zaehle_bis(
bis,
):
zahl = 1
while zahl <= bis:
yield zahl
zahl += 1
print(
list(
zaehle_bis(5)
)
)
[1, 2, 3, 4, 5]
2. Den State zwischen zwei yield beobachten
Was gibt dieser Code aus?
def generator():
zahl = 10
yield zahl
zahl += 5
yield zahl
g = generator()
print(next(g))
print(next(g))
Lösung
10
15
zahl == 10
3. Einen endlosen Generator begrenzen
Schreibe einen Generator zaehler(start), der endlos hochzählt. Nimm mit
itertools.islice(...) nur die ersten fünf Werte.
Lösung
from itertools import islice
def zaehler(
start,
):
zahl = start
while True:
yield zahl
zahl += 1
print(
list(
islice(
zaehler(10),
5,
)
)
)
[10, 11, 12, 13, 14]
4. Eine Generator Expression verwenden
Berechne die Summe der Quadrate von 1 bis 100, ohne vorher eine Liste mit
den Quadraten anzulegen.
Lösung
summe = sum(
zahl * zahl
for zahl in range(
1,
101,
)
)
print(summe)
5. Fehlerzeilen filtern
Schreibe eine Generatorfunktion nur_fehler(zeilen), die nur Zeilen liefert,
die mit ERROR beginnen.
Lösung
def nur_fehler(
zeilen,
):
for zeile in zeilen:
if zeile.startswith(
"ERROR "
):
yield zeile
zeilen = [
"INFO Start",
"ERROR Kaputt",
"INFO Ende",
]
print(
list(
nur_fehler(
zeilen
)
)
)
['ERROR Kaputt']
6. yield from verwenden
Schreibe eine Funktion alle_werte(gruppen), die die Werte aus mehreren Listen
nacheinander liefert.
Lösung
def alle_werte(
gruppen,
):
for gruppe in gruppen:
yield from gruppe
gruppen = [
[
1,
2,
],
[
3,
4,
],
[
5,
],
]
print(
list(
alle_werte(
gruppen
)
)
)
[1, 2, 3, 4, 5]
7. Den Rückgabewert eines Subgenerators übernehmen
Schreibe einen Subgenerator, der zwei Werte yieldet und anschließend
"fertig" zurückgibt. Der äußere Generator soll den Rückgabewert mit
yield from übernehmen.
Lösung
def innen():
yield 1
yield 2
return "fertig"
def aussen():
ergebnis = yield from innen()
print(
ergebnis
)
print(
list(
aussen()
)
)
fertig
[1, 2]
8. send() ausprobieren
Schreibe einen Generator, der zunächst 0 liefert und anschließend einen mit
send(...) empfangenen Wert wieder ausgibt.
Lösung
def empfaenger():
wert = yield 0
yield wert
g = empfaenger()
print(
next(g)
)
print(
g.send(42)
)
0
42
9. Cleanup testen
Was passiert bei diesem Code?
def generator():
try:
yield 1
yield 2
finally:
print("Cleanup")
g = generator()
print(next(g))
g.close()
Lösung
1
Cleanup
Weiterlesen
- Python lernen, Teil 6: Pythonic schreiben
- Python lernen, Teil 11: Werkzeugkasten und Ausblick
- pytest von Null
- Type Hints in der Praxis
- Das Datenmodell: Dunder-Methoden verstehen
- Python-Dokumentation: Generatoren
- Python-Dokumentation: Generator Expressions
- Python-Dokumentation: Yield Expressions
- Python-Dokumentation: Generator-Iterator Methods
- Python-Dokumentation: Iterator Types
- Python-Dokumentation:
itertools - Python-Dokumentation: Generatoren typisieren
- PEP 255: Simple Generators
- PEP 342: Coroutines via Enhanced Generators
- PEP 380: Syntax for Delegating to a Subgenerator
- PEP 479: Change StopIteration handling inside generators
- PEP 525: Asynchronous Generators
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.