Iteratoren, Generatoren und Comprehensions

Track Python · PCAP-Fundament · ca. 50 Min.

Worum es geht

Hier liegt viel von dem, was Python-Code kompakt macht. Aus map, filter und function* in JS/TS werden Comprehensions, Iteratoren und Generatoren. Die Quelle zählt das Thema zum Grundwissen. Die Fallen sind fast alle Laufzeitfragen: Ein Generator läuft nur einmal durch, ein Aufruf führt noch nichts aus, und die Schleifenvariable einer Comprehension verschwindet wieder. Nach der Lektion baust du einen eigenen Iterator, sagst das Verhalten von Generatoren voraus und schreibst Comprehensions mit if, else und zwei for-Klauseln.

Ablauf: erst die Übersetzung JS/TS nach Python, dann fünf kleine Schritte mit lauffähigem Code, dann fünf Übungen.

Von JS/TS her gedacht

Aufgabe JS/TS Python
Jedes Element umwandeln a.map(x => x * 2) [x * 2 for x in a]
Filtern a.filter(x => x > 0) [x for x in a if x > 0]
Umwandeln und filtern a.filter(...).map(...) [x * 2 for x in a if x > 0]
Objekt aus Paaren bauen Object.fromEntries(...) {k: v for k, v in pairs}
Einzigartige Werte new Set(a) {x for x in a}
Alle Einträge durchlaufen Object.entries(o) d.items()
Reduzieren a.reduce(...) functools.reduce(f, a) oder sum(a)
Alle oder keiner a.every(...), a.some(...) all(...), any(...)
Generator function* mit yield def mit yield

Der große Unterschied: In JS erzeugt map sofort ein neues Array. In Python gibt es zwei Welten. Die Comprehension in eckigen Klammern baut sofort eine Liste. Die eingebauten Helfer map, filter, zip, enumerate und reversed sowie Generatoren arbeiten lazy (faul): Sie liefern Werte erst, wenn jemand danach fragt.

// TypeScript: Generator mit function*
function* countdown(n: number) {
  while (n > 0) {
    yield n;
    n -= 1;
  }
}
const g = countdown(3);
console.log(g.next().value);   // 3
console.log([...g]);           // [2, 1]
console.log([...g]);           // []  (auch in JS erschöpft)

Ausgabe: 3, dann [2, 1], dann []. Auch hier ist der Generator nach einem Durchlauf leer, genau wie in JS.

Konzept in kleinen Schritten

Schritt 1: Iterable und Iterator (iterable, iterator)

  • Iterable: lässt sich durchlaufen, hat __iter__. Beispiele: Liste, String, Dict, Set, range, Datei.
  • Iterator: hat __next__, liefert Werte einzeln und löst am Ende StopIteration aus.
  • iter(x) macht aus einem Iterable einen Iterator, next(it) holt den nächsten Wert.
  • Eine for-Schleife ruft intern iter() auf und fängt StopIteration selbst ab.

Die for-Schleife ist also nur diese Schleife in Kurzform:

Eine Liste ist ein Iterable, aber kein Iterator: iter(liste) liefert jedes Mal einen neuen. Ein Iterator ist dagegen sein eigener Iterator:

Ein eigener Iterator ist eine Klasse mit __iter__ (gibt self zurück) und __next__. Das baust du gleich in Übung 1.

Schritt 2: Generatorfunktion (generator function)

Eine Funktion mit yield ist eine Generatorfunktion. Der Aufruf führt noch nichts aus, er liefert ein Generatorobjekt. Jedes next() läuft bis zum nächsten yield und merkt sich den Zustand.

Ausgabe in dieser Reihenfolge: erzeugt, start, 1, ende, []. Der Rumpf lief erst beim ersten next(). Beim zweiten Anlauf (list(h)) lief er bis zum Ende, ohne weiteren Wert.

In JS kennst du das als function* und .next(). Neu ist nur, dass Python for, list(), sum() und andere direkt auf Generatoren arbeiten lässt.

Schritt 3: Generatorausdruck, Erschöpfung, yield from

Ein Generatorausdruck (generator expression) sieht aus wie eine Listen-Comprehension, nur mit runden Klammern. Er spart Speicher, weil die Werte erst bei Bedarf entstehen.

Ein Generator lässt sich nur einmal durchlaufen (Erschöpfung, exhaustion). Und len(g) oder g[0] gehen nicht.

Ausgabe: zwei TypeError-Meldungen, dann 5 0. Das zweite sum(q) findet nichts mehr.

yield from reicht alle Werte eines anderen Iterables durch. Ein return im Generator beendet ihn, der Wert steckt in StopIteration (Attribut value) und ist bei yield from das Ergebnis des Ausdrucks.

Ausgabe: [1, 2, 'fertig'] und 'fertig'.

Schritt 4: Comprehensions im Detail

  • Liste: [ausdruck for x in it if bedingung]
  • Mit else: Die bedingte Wahl steht vorn: [x if x > 0 else 0 for x in a]. Ein bloßes if zum Filtern steht hinten.
  • Verschachtelt: [x * y for x in a for y in b] entspricht zwei ineinander liegenden Schleifen. Die erste for-Klausel ist die äußere.
  • Dict und Set: {k: v for ...} und {x for ...}.
  • Eigener Scope (own scope): Die Schleifenvariable ist außerhalb der Comprehension nicht sichtbar (anders als bei der normalen for-Schleife).

Die Ausgaben: [3, 0, 2, 0], [6, 4], [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')], {'a': 1, 'b': 2} {0, 1, 2}, dann vorher und 2. Die Comprehension hat i nicht überschrieben, die normale Schleife hat j behalten.

Schritt 5: Sortieren und Verknüpfen

  • sorted(it, key=f, reverse=True) sortiert stabil (stable): Gleiche Werte behalten ihre ursprüngliche Reihenfolge, auch mit reverse=True. key bestimmt den Vergleichswert, oft als lambda (zum Beispiel key=lambda p: p[1] für das zweite Element eines Paares).
  • zip(a, b) bricht beim kürzesten Iterable ab.
  • enumerate(it, start=1) liefert Paare aus Zähler und Wert. Besteht der Wert selbst aus einem Paar, entpackst du verschachtelt: for i, (a, b) in enumerate(paare, start=1).
  • any(it) und all(it) prüfen Wahrheitswerte: all([]) ist True, any([]) ist False.
  • map, zip und Co. sind lazy: erst list(...) wertet aus, und auch sie sind danach erschöpft.

Ausgabe: ['bb', 'cc', 'a', 'd'] (bei Gleichstand bleibt bb vor cc), [(1, 'a'), (2, 'b')], [(1, 'a'), (2, 'b')], True False, dann ['1', '2'] und [].

Falle: die Prüfungs- und Praxisklassiker

  1. Zweiter Durchlauf eines Generators ist leer. Das gilt auch für map, filter, zip, enumerate und jeden Iterator. Eine Liste dagegen lässt sich beliebig oft durchlaufen.
  2. Der Aufruf einer Generatorfunktion führt nichts aus. Fehler und Ausgaben im Rumpf erscheinen erst beim ersten next().
  3. len(g) und g[0] gehen nicht (TypeError).
  4. if vorn oder hinten? Mit else steht die Bedingung vorn, ohne else (Filtern) hinten. Sonst SyntaxError.
  5. Reihenfolge der for-Klauseln: die erste ist die äußere Schleife.
  6. Comprehension hat eigenen Scope, die normale for-Schleife nicht.
  7. __next__ muss am Ende StopIteration auslösen. Wer stattdessen None zurückgibt, baut eine Endlosschleife.
  8. zip schneidet still ab. Bei ungleich langen Eingaben verschwinden Elemente ohne Fehlermeldung.

Übungen

Übung 1: Eigenen Iterator bauen

Schreibe die Klasse Aufsteigend(ende). Ein Objekt liefert nacheinander 1, 2, ..., ende und ist danach erschöpft. Bei ende = 0 liefert es nichts. Die Klasse soll ein echter Iterator sein, also auch iter(objekt) unterstützen und dabei sich selbst zurückgeben.

__iter__ gehört zum Iterable-Protokoll, __next__ zum Iterator-Protokoll. Überlege, was ein Objekt zurückgeben muss, das beides gleichzeitig ist. Und woran erkennt die for-Schleife, dass keine Werte mehr kommen?

class Aufsteigend:
    def __init__(self, ende):
        self.ende = ende
        self.aktuell = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.aktuell >= self.ende:
            raise StopIteration
        self.aktuell += 1
        return self.aktuell

Aufsteigend

Übung 2: Generator vorhersagen

Der Code steht fest, du sagst das Ergebnis voraus:

log = []

def zahlen():
    log.append("start")
    yield 1
    log.append("mitte")
    yield 2
    log.append("ende")

z = zahlen()
l0 = list(log)     # Kopie des Logs direkt nach dem Aufruf
next(z)
l1 = list(log)
erg = list(z)
spaeter = list(z)
l2 = list(log)

Trage das Tupel (l0, l1, erg, spaeter, l2) ein.

Frage dich bei jeder Zeile: Läuft hier schon Code aus dem Rumpf von zahlen, oder wird nur ein Objekt erzeugt? Und wie weit läuft ein list(z), wenn schon ein Wert entnommen wurde? Was bleibt nach dem Ende übrig?

antwort = ([], ["start"], [2], [], ["start", "mitte", "ende"])
antwort

Übung 3: Comprehension und Scope (Multiple Choice)

i = "vorher"
a = [i for i in range(3)]
for j in range(3):
    pass

Welche Aussage über i und j nach diesem Code stimmt? Gib den Buchstaben als String zurück.

  • A: i ist "vorher", aber j löst einen NameError aus.
  • B: i ist 2 und j ist 2, beide Schleifen überschreiben.
  • C: i ist "vorher" und j ist 2, nur die Comprehension hat Scope.
  • D: i löst einen NameError aus und j ist 2, beide sind lokal.

Vergleiche zwei Fragen: Wo lebt die Schleifenvariable bei einer normalen for-Schleife? Und bei einer Comprehension? Beachte auch, dass i vorher schon existiert.

antwort = "C"
antwort

Übung 4: Drei Comprehensions schreiben

Schreibe auswerten(a, b). a und b sind Listen von ganzen Zahlen. Die Funktion liefert ein Tupel aus drei Listen:

  1. verdoppelt: jedes positive Element von a, verdoppelt, in der Reihenfolge von a
  2. ohne_minus: alle Elemente von a, wobei negative Zahlen durch 0 ersetzt werden (die Länge bleibt gleich)
  3. paare: alle Paare (x, y) mit x aus a und y aus b, bei denen x != y gilt. Die Reihenfolge ist die einer verschachtelten Schleife, bei der a außen und b innen läuft (also zuerst alle Paare mit dem ersten x).

Nutze Comprehensions.

Bei der ersten Liste fällt etwas weg, bei der zweiten bleibt alles erhalten. Das entscheidet, ob die Bedingung vorn oder hinten steht. Bei der dritten Liste: Welche for-Klausel muss zuerst kommen, damit a die äußere Schleife ist?

def auswerten(a, b):
    verdoppelt = [x * 2 for x in a if x > 0]
    ohne_minus = [x if x >= 0 else 0 for x in a]
    paare = [(x, y) for x in a for y in b if x != y]
    return verdoppelt, ohne_minus, paare

auswerten

Übung 5: Rangliste mit sorted, zip und enumerate

Schreibe rangliste(namen, punkte). Beide Parameter sind Listen. Die Funktion liefert eine Liste von Strings der Form "1. Ayse (9)":

  • Die Zuordnung ist Position für Position (namen[0] gehört zu punkte[0]). Ist eine Liste länger, werden die überzähligen Einträge ignoriert.
  • Sortiert wird nach Punkten, die meisten zuerst. Bei gleichen Punkten bleibt die ursprüngliche Reihenfolge.
  • Die Platzierung zählt ab 1.
  • Die Eingabelisten werden nicht verändert.

Drei eingebaute Helfer passen hier jeweils zu einem Teil der Aufgabe: einer für das Paaren, einer für das Sortieren (schau auf den Parameter für die Richtung), einer für das Zählen mit Startwert. Prüfe an einem Beispiel mit Gleichstand, ob die ursprüngliche Reihenfolge der Gleichen erhalten bleibt, und welche Einstellung von sorted dafür sorgt.

def rangliste(namen, punkte):
    paare = zip(namen, punkte)
    sortiert = sorted(paare, key=lambda p: p[1], reverse=True)
    return [f"{platz}. {name} ({p})" for platz, (name, p) in enumerate(sortiert, start=1)]

rangliste

Merksatz und Prüfstein

Merksatz: Eine Comprehension baut sofort eine Liste, ein Generator liefert Werte erst bei next() und nur ein einziges Mal, und map, zip und enumerate sind genauso faul.

Prüfstein (offene Frage): Du rufst g = zahlen() auf und siehst keine Ausgabe, obwohl im Rumpf ein print steht. Erkläre, warum das so ist und wann die Ausgabe erscheint. Warum liefert sum(g) beim zweiten Mal 0, und wie behebst du das, wenn du die Werte zweimal brauchst?

Quelle: quellen/python-glossar-pcap-pcpp1.md, Abschnitt “Iteratoren, Generatoren und Comprehensions” mit “Übersetzungstabelle”, “Begriffe”, “Comprehensions im Detail” und “Sortieren und Verknüpfen”. Hinweis: Die Stabilität von sorted bei reverse=True und das Verhalten von iter(liste) is liste stehen nicht in der Quelle, wurden aber mit Python 3 ausgeführt und geprüft (bitte prüfen, ob sie in der PCAP geprüft werden).