Zum Inhalt springen

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:

  1. Der aktuelle Wert wird an den Aufrufer geliefert.
  2. 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 finally oder Context Manager schreiben und GeneratorExit normalerweise 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: zaehle ist 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 for bereits 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 wert für einen weiteren Yield-Wert halten: return beendet den Generator. Sein Wert landet in StopIteration.value und kann mit yield from übernommen werden.

  • Selbst StopIteration aus dem Generator werfen: Zum normalen Beenden verwendest Du return oder lässt die Funktion auslaufen.

  • yield from nur als kürzere for-Schleife verstehen: Bei Subgeneratoren delegiert es zusätzlich send(), throw(), close() und übernimmt den return-Wert.

  • Direkt send(5) an einen neuen Generator schicken: Vor dem ersten normalen Wert muss der Generator mit next(...) oder send(None) gestartet werden.

  • send() für normalen Generator-Code erzwingen: Viele Generatoren brauchen ausschließlich yield, for und gelegentlich next().

  • 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() weiter yielden: Beim Schließen soll der Generator enden. Ein neuer Yield-Wert führt zu einem RuntimeError.

  • 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 reicht Iterator[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
Verwendung:
print(
    list(
        zaehle_bis(5)
    )
)
Ausgabe:
[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
Nach dem ersten `yield` bleibt:
zahl == 10
erhalten. Beim zweiten `next(...)` läuft der Generator hinter dem ersten `yield` weiter, erhöht `zahl` und liefert anschließend `15`.

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,
        )
    )
)
Ausgabe:
[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
Test:
zeilen = [
    "INFO Start",
    "ERROR Kaputt",
    "INFO Ende",
]

print(
    list(
        nur_fehler(
            zeilen
        )
    )
)
Ausgabe:
['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
Verwendung:
gruppen = [
    [
        1,
        2,
    ],
    [
        3,
        4,
    ],
    [
        5,
    ],
]

print(
    list(
        alle_werte(
            gruppen
        )
    )
)
Ausgabe:
[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()
    )
)
Ausgabe:
fertig
[1, 2]
`"fertig"` ist nicht Teil der Liste. Es ist der `return`-Wert des Subgenerators.

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)
)
Ausgabe:
0
42
Der erste `next(...)` startet den Generator und bringt ihn bis zum ersten `yield`. `send(42)` wird anschließend zum Wert dieser `yield`-Expression.

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
`close()` beendet den pausierten Generator und sorgt dafür, dass der `finally`-Block ausgeführt wird.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!