Python Iterables und Iteratoren
Warum kann man mit derselben for-Schleife durch Listen, Dicts, Strings und Dateien laufen? Und wie spart ein Iterator Gigabyte an Speicher? Ein Deep-Dive ins Iterator-Protokoll.
Wer Python schreibt, benutzt jeden Tag den gleichen Schleifen-Kopf:
for frucht in ["Apfel", "Banane", "Kiwi"]:
print(frucht)
for taste, wert in {"Apfel": 8, "Banane": 1}.items():
print(taste, wert)
for zeile in open("fruechte.txt"):
print(zeile.strip())
Drei völlig unterschiedliche Objekte: eine Liste im Speicher, ein Dictionary mit Hash-Tabelle, eine Datei, die noch gar nicht eingelesen ist. Und trotzdem funktioniert dieselbe for-Schleife für alles. Bei einem int dagegen kracht es:
for x in 22:
print(x)
# TypeError: 'int' object is not iterable
Der Grund ist nicht, dass eine Liste „mehr Elemente hat” und eine Zahl nicht. Der Grund ist eine Schnittstelle, die Python erwartet, das Iterator-Protokoll. In diesem Post schauen wir uns an, wie es funktioniert, warum Python sauber zwischen Iterable und Iterator trennt, und wie genau dieses Protokoll es möglich macht, dass du ein 50-GB-Logfile auf einem Rechner mit 8 GB RAM verarbeitest.
1. Was eine for-Schleife wirklich macht
Eine for-Schleife sieht aus wie ein eingebautes Sprachkonstrukt. In Wirklichkeit ist sie syntaktischer Zucker für zwei explizite Aufrufe: erst iter(...), dann wiederholt next(...).
Das Folgende ist äquivalent:
for frucht in ["Apfel", "Banane", "Kiwi"]:
print(frucht)
it = iter(["Apfel", "Banane", "Kiwi"])
while True:
try:
frucht = next(it)
except StopIteration:
break
print(frucht)
Damit ist die Frage „warum funktioniert die Schleife mit so vielen Typen?” beantwortet: Sie funktioniert mit allem, das auf iter(...) einen Iterator zurückgibt, der seinerseits auf next(...) Werte liefert und am Ende StopIteration wirft. Genau das ist das Protokoll.
2. Iterable vs. Iterator
Zwei Wörter, die fast gleich aussehen, aber zwei verschiedene Dinge meinen. Diese Trennung sauber zu verstehen ist der ganze Punkt dieses Posts.
- Iterable — ein Objekt, durch dessen Elemente man iterieren kann. Listen, Strings, Dicts, Dateien, eigene Klassen mit
__iter__. Hält die Daten. - Iterator — ein Objekt, das den aktuellen Stand der Iteration kennt und auf Anfrage das nächste Element liefert. Hält die Position.
Aus einem Iterable bekommst du einen Iterator, indem du iter(...) aufrufst. Aus einem Iterator bekommst du Werte, indem du next(...) aufrufst. Schematisch:
Iterable --iter()--> Iterator --next()--> Element
Iterator --next()--> Element
Iterator --next()--> StopIteration
Das Iterable kann beliebig oft neue Iteratoren ausgeben. Jeder Iterator ist einweg — einmal durchgelaufen, ist er leer.
liste = ["Apfel", "Banane", "Kiwi"]
it1 = iter(liste)
it2 = iter(liste)
print(next(it1)) # Apfel
print(next(it1)) # Banane
print(next(it2)) # Apfel — eigener, unabhängiger Zustand
Zwei Iteratoren, dieselbe Liste, kein gemeinsamer Stand. Genau deshalb gibt es die Trennung, dazu in §5 mehr.
3. Das Protokoll im Detail
Damit ein Objekt mitspielen kann, muss es die richtigen Dunder-Methoden implementieren. Die Python-Dokumentation drückt es nüchtern aus: Ein Iterable hat __iter__, ein Iterator hat __next__.
| Methode | Wer implementiert | Was sie tut |
|---|---|---|
__iter__(self) | Iterable | Gibt einen frischen Iterator zurück |
__next__(self) | Iterator | Gibt das nächste Element zurück, sonst raise StopIteration |
Die globalen Funktionen iter(x) und next(x) rufen intern genau diese Methoden auf. iter(x) ist nichts weiter als ein etwas eleganteres x.__iter__(), next(x) entsprechend x.__next__().
Wenn du das einmal direkt sehen willst:
liste = ["Apfel", "Banane", "Kiwi"]
it = iter(liste)
print(type(it)) # <class 'list_iterator'>
print(next(it)) # Apfel
print(next(it)) # Banane
print(next(it)) # Kiwi
print(next(it)) # StopIteration
Die list ist das Iterable, das list_iterator ist der dazugehörige Iterator. Zwei verschiedene Klassen mit klar getrennten Aufgaben.
4. Selbst gebaut — ein Iterable mit eigenem Iterator
Theorie reicht. Wir bauen das von Hand nach, zuerst ein Iterable, dann den Iterator dazu.
Das Iterable
class FrüchteIterable:
def __init__(self):
self.daten = ["Apfel", "Banane", "Kiwi"]
def __iter__(self):
return FrüchteIterator(self.daten)
Mehr braucht ein Iterable nicht: eine Datenquelle und eine __iter__-Methode, die einen frischen Iterator zurückgibt. Wichtig ist, dass der Iterator nur eine Referenz auf die Daten bekommt, keine Kopie. Mehrere Iteratoren teilen sich also dieselbe Liste, aber nicht denselben Fortschritt.
Der Iterator
class FrüchteIterator:
def __init__(self, daten_referenz):
self.daten = daten_referenz
self.aktuell = 0
def __next__(self):
if self.aktuell >= len(self.daten):
raise StopIteration
wert = self.daten[self.aktuell]
self.aktuell += 1
return wert
Drei Dinge passieren hier:
- Endprüfung zuerst. Wenn der Index außerhalb der Liste liegt,
raise StopIteration. Die for-Schleife fängt das ab und bricht ab. - Aktuellen Wert holen.
- Position fortzählen und Wert zurückgeben. Die Reihenfolge ist wichtig: erst den Wert merken, dann den Index erhöhen, dann den gemerkten Wert zurückgeben. Sonst überspringt die Schleife das erste Element.
Probieren wir es aus:
for frucht in FrüchteIterable():
print(frucht)
# Apfel
# Banane
# Kiwi
Funktioniert wie eine eingebaute Liste. Die for-Schleife sieht keinen Unterschied — sie ruft __iter__ auf, kriegt einen FrüchteIterator und ruft __next__ bis zur StopIteration.
5. Warum diese Trennung?
An dieser Stelle drängt sich eine Frage auf: Warum zwei Klassen? Warum kann das Iterable nicht selbst die Position halten?
Die Antwort ist parallele Iteration. Stell dir vor, das Iterable hält den Stand selbst. Dann würde Folgendes nicht mehr funktionieren:
früchte = FrüchteIterable()
for a in früchte:
for b in früchte:
print(a, b)
Mit dem aktuellen Design erzeugen die beiden Schleifen jeweils ihren eigenen Iterator, der seinen eigenen Index führt. Würde der Stand am Iterable kleben, wären beide Schleifen verkoppelt und das Verhalten kaputt.
Dieselbe Trennung erlaubt es zwei Funktionen, gleichzeitig durch dieselbe Datenquelle zu laufen, ohne dass sie sich gegenseitig stören. „Separation of Concerns” als Sprachfeature: das Iterable hält die Daten, der Iterator hält den Fortschritt.
6. Der Selbstbezug: Iterator ist auch ein Iterable
Eine kleine, aber wichtige Spitzfindigkeit. Die for-Schleife erwartet ein Iterable. Was passiert, wenn du ihr stattdessen direkt einen Iterator gibst?
it = iter([1, 2, 3])
for x in it:
print(x)
Funktioniert. Aber laut Protokoll dürfte die for-Schleife nur Iterables annehmen, und unser Iterator hat doch nur __next__?
Die Lösung ist eine Konvention: Jeder Iterator ist gleichzeitig ein Iterable, der sich selbst zurückgibt. Mit einer einzigen Zeile:
class FrüchteIterator:
def __init__(self, daten_referenz):
self.daten = daten_referenz
self.aktuell = 0
def __iter__(self):
return self
def __next__(self):
if self.aktuell >= len(self.daten):
raise StopIteration
wert = self.daten[self.aktuell]
self.aktuell += 1
return wert
Damit kann die for-Schleife dem Iterator dasselbe iter(...) zumuten wie einem Iterable und kriegt einfach den Iterator selbst zurück. Das macht es möglich, einen Iterator pausierend zu konsumieren, ihn weiterzureichen, ihn nochmal in eine Schleife zu stecken, weil er sein eigenes Iterable ist.
Was die Beziehung nicht umkehrt: Nicht jedes Iterable ist ein Iterator. Eine Liste ist ein Iterable, aber sie hat kein __next__. Wer das verwechselt, baut sich gerne ein Iterable, das nach dem ersten Durchlauf leer ist, weil es nebenbei zum Iterator gemacht wurde.
Faustregel: Iteratoren sind immer auch Iterables. Iterables sind nicht automatisch Iteratoren. Asymmetrisch.
7. Lazy vs. Eager — der eigentliche Hebel
Bisher war das Iterator-Protokoll eine elegante Abstraktion. Der praktische Gewinn kommt erst, wenn das Iterable die Werte nicht im Voraus berechnet, sondern erst dann, wenn __next__ aufgerufen wird. Das nennt man Lazy Evaluation.
Vergleich: Eine Million Zahlen, einmal eager als Liste, einmal lazy als Iterator.
import sys
eager = [i for i in range(1_000_000)]
lazy = iter(range(1_000_000))
print(sys.getsizeof(eager)) # ~8 MB
print(sys.getsizeof(lazy)) # ~200 Byte
Die Liste materialisiert alle eine Million int-Objekte im Speicher. Der Iterator hält nur einen Zähler und die Anweisung, wie der nächste Wert berechnet wird, wenn er gefragt wird. Vierzigtausendfacher Unterschied im Speicher, ohne dass sich am Schleifen-Code etwas ändert.
Das ist der Mechanismus, der es erlaubt, ein 50-GB-Logfile auf einem 8-GB-RAM-Rechner zu verarbeiten:
with open("riesig.log") as f:
for zeile in f:
if "ERROR" in zeile:
print(zeile.strip())
Das Dateiobjekt ist ein Iterator. Es liest immer nur die nächste Zeile in den Speicher, gibt sie zurück, und vergisst sie. Egal wie groß die Datei wird, der Speicherverbrauch bleibt konstant.
Dasselbe Prinzip steckt hinter Generators (yield), range(...), map(...), filter(...), enumerate(...), zip(...). Alle geben Iteratoren zurück, alle produzieren ihre Werte erst auf Anfrage.
8. Wo das im Alltag auftaucht
Du musst selten von Hand einen Iterator-Klassen-Codeblock schreiben. Aber das Protokoll wirkt überall:
- Generators:
def f(): yield 1; yield 2ist ein Iterator, der Python für dich baut. Dasyieldist quasi einreturn, das die Funktion an der Stelle einfriert, statt sie zu beenden. - Comprehensions: Listen-Comprehensions sind eager, Generator-Comprehensions lazy. Klammern entscheiden:
[x*x for x in range(N)]vs.(x*x for x in range(N)). - Standard-Library:
itertoolsist ein ganzer Werkzeugkasten an fertigen Iteratoren —chain,groupby,accumulate,cycle. Lazy, kombinierbar, billig. - Dateien und Sockets: alles, wo Daten erst ankommen, wenn du danach fragst.
- Netzwerk-Pagination: ein Iterator, der nach
next(...)die nächste Seite vom Server holt, ist die saubere Art, eine API mit Cursor-basiertem Paging zu konsumieren.
Sobald du das Protokoll im Kopf hast, fällt dir auf, wie viel Python-Idiomatik darauf aufbaut.
9. Komplett-Beispiel: ein lazy LineReader für große Dateien
Zum Abschluss etwas, das die ganze Mechanik in einem realistischen Stück Code zeigt: ein Iterator, der eine Datei liest, leere Zeilen überspringt und führende/abschließende Whitespaces wegmacht, alles lazy. So etwas baut man tatsächlich, wenn man große Logs oder CSVs streamt.
class CleanLineReader:
"""Liest eine Datei Zeile für Zeile, überspringt leere Zeilen,
strippt Whitespace. Kein Wort der Datei landet je gemeinsam
im Speicher."""
def __init__(self, pfad):
self.pfad = pfad
self._datei = None
def __iter__(self):
# Jeder iter()-Aufruf öffnet die Datei frisch.
# Damit lässt sich das Iterable mehrfach durchlaufen.
self._datei = open(self.pfad, "r", encoding="utf-8")
return self
def __next__(self):
if self._datei is None:
raise StopIteration
while True:
zeile = self._datei.readline()
if zeile == "":
# Dateiende erreicht.
self._datei.close()
self._datei = None
raise StopIteration
zeile = zeile.strip()
if zeile:
return zeile
for zeile in CleanLineReader("riesig.log"):
if "ERROR" in zeile:
print(zeile)
Drei Beobachtungen lohnen sich:
- Iterator und Iterable in einem.
__iter__gibtselfzurück,__next__liefert das nächste Element. Klassisches Pattern für „eigentlich ein Iterator, aber ich will ihn auch direkt in eine for-Schleife stecken können”. - Frische Iteration durch frisches Öffnen. Weil
__iter__die Datei jedes Mal neu öffnet, kannst du dasselbeCleanLineReader-Objekt mehrfach durchlaufen. Würden wir die Datei in__init__öffnen, wäre nach dem ersten Durchlauf Schluss. - Speicherverbrauch ist konstant. Egal ob die Datei 50 KB oder 50 GB groß ist, im Speicher liegt immer nur die aktuelle Zeile. Genau das, was du für Streaming-Workloads brauchst.
In der Praxis schreibt man so etwas natürlich als Generator-Funktion, weniger Boilerplate, dasselbe Verhalten:
def clean_lines(pfad):
with open(pfad, "r", encoding="utf-8") as f:
for zeile in f:
zeile = zeile.strip()
if zeile:
yield zeile
Aber der Klassen-Weg zeigt das Protokoll vollständig. Generators sind nur die abgekürzte Form davon.
Zusammenfassung
- Iterable vs. Iterator: das Iterable hält die Daten, der Iterator hält den Fortschritt.
iter(iterable)erzeugt den Iterator,next(iterator)liefert das nächste Element. - Das Protokoll: ein Iterable hat
__iter__, ein Iterator hat__next__und wirft am EndeStopIteration. Die for-Schleife ruft beides im Hintergrund auf. - Trennung erlaubt parallele Iteration: weil jeder
iter(...)-Aufruf einen frischen Iterator liefert, können mehrere Schleifen unabhängig durch dieselbe Datenquelle laufen. - Iterator als Iterable: jeder Iterator hat in der Regel auch
__iter__, dasselfzurückgibt. Iterables hingegen sind nicht automatisch Iteratoren. - Lazy Evaluation: weil
__next__Werte erst bei Bedarf produziert, kann ein Iterator über Millionen oder Milliarden Elemente laufen, ohne sie gleichzeitig im Speicher zu halten. - In der Praxis: Generators, Comprehensions,
itertools, Dateien,range,zip— überall steckt das Protokoll drin, oft ohne dass das Wort Iterator je fällt.
Wenn du das nächste Mal eine for-Schleife schreibst, weißt du, was unter der Haube passiert. Und du weißt, warum dein 50-GB-Logfile durchläuft, obwohl dein RAM das nie aushalten würde.
Ähnliche Posts
Alle anzeigen
Python Closures: Funktionen mit Gedächtnis
Closures sind das Geheimnis hinter Decorators, Factories und elegantem State-Handling in Python. Ein praxisnaher Deep-Dive: wie sie funktionieren und wann du sie brauchst.
Python-Dekoratoren einfach erklärt
Dekoratoren gehören zu den beliebtesten Sprachfeatures der Programmiersprache Python. Lerne, eigene Dekoratorenzu erstellen, um Boilerplate-Code zu reduzieren.
Werde Teil der Python-Community
Tausch dich mit anderen Python-Lernenden aus, stell deine Fragen im Forum und schau dir die besten Lernvideos rund um Python an — kostenlos auf Skool.