Iteratoren und Generatoren in Python
Eine Folge ist in der Mathematik eine Auflistung von endlich oder unendlich vielen fortlaufend nummerierten Objekten (beispielsweise Zahlen). Nur, wie gehen wir praktisch in Python mit dieser Unendlichkeit um? Und könnte uns das Prinzip auch anderweitig weiterhelfen?
Die Fibonacci-Folge
Wir möchten wiederverwendbaren Code schreiben, der die Fibonacci-Folge generiert. Zur Erinnerung: die Fibonacci-Folge beginnt mit den Werten 1 und 1 (oder 0 und 1), der nächste Wert ergibt sich immer aus der Summe der beiden vorherigen.
Aufgabe 1: Fibonacci-Liste
Unser erster Versuch: Schreibe eine Funktion, die die ersten n
Elemente der Fibonacci-Folge generiert und in einer Liste zurückgibt. Untenstehender Code funktioniert nur für $n\le10$ – ändere den Code so, dass er für beliebig grosse $n$ das richtige Resultat zurückgibt!
Das Problem ist nur, dass die Liste viel Speicher benötigt, wenn wir eine grosse Anzahl Elemente benötigen. Beispiel: wir möchten die Summe der ersten n
Fibonacci-Zahlen berechnen, ohne den Speicherplatz für die Liste aufzuwenden. Oder wir wissen gar nicht im Voraus, wieviele Elemente nötig sind, wenn wir zum Beispiel die erste Fibonacci-Zahl grösser als 1000 suchen.
Natürlich könnten wir für beide Probleme eine eigene, effiziente Funktion schreiben, die ohne Liste auskommt - aber als faule Informatiker:innen möchten wir es vermeiden, den gleichen Code zweimal zu schreiben…
Gesucht ist also ein Stück Code, das einen Strom von Fibonacci-Zahlen liefert, die wir kontinuierlich konsumieren können, bis wir genug haben.
Iteratoren
Solche Strom-Objekte heissten Iteratoren. Ein Iterator hat nur eine einzige Funktion, __next__
, die jeweils das nächste Element zurückgibt. Der Iterator muss sich irgendwie den Zustand merken, also wo in der Fibonacci-Folge er gerade steckt. Die Funktion wirft eine StopIteration
Exception wenn es keine weiteren Elemente gibt.
Die built-in function next kann verwendet werden, um aus einem Iterator das nächste Element zu holen.
Aufgabe 2: Fibonacci-Iterator
Analysiere den untenstehenden Code und führe ihn aus. Ändere den Code, so dass die erste Fibonacci Zahl ausgegeben wird, die grösser als 1000 ist!
Iterables
Möchten wir unseren Iterator in einer for
-Schleife einsetzen, müssen wir zusätzlich das Iterable-Protokoll beachten: Iterables müssen eine __iter__
Funktion haben, die einen neuen, von vorne beginnenden Iterator zurückgibt. Wieso benötigen wir sowohl Iterable als auch Iterator? Stell dir vor, eine Liste möchte das Iterable-Protokoll implementieren - es ist entscheidend, dass jede neue Schleife wieder von vorne beginnt:
alphabet = 'abcdef' for letter1 in alphabet: # Erster Iterator über die Sequence alphabet for letter2 in alphabet: # Zweiter, unabhängiger Iterator über die gleiche Sequence print(letter1 + letters)
Spickzettel:
__iter__
wird einmal aufgerufen, wenn diefor
-Schleife startet, und gibt einen neuen Iterator zurück.__next__
wird vor jedem Schleifendurchgang aufgerufen und das Resultat der Schleifenvariable zugewiesen.
Die Funktionen werden hier beschrieben.
Untenstehender Code fügt dem bestehenden Iterator ein Iterable hinzu, so dass wir eine for-Schleife nützen können:
Die Funktionen __iter__
und __next__
dürfen auch von derselben Klasse implementiert werden.
Yield
Das ganze leuchtet ein, aber ist ziemlich kompliziert. Zu Glück hat Python eine Abkürzung für uns bereit: Sobald unsere Funktion yield
statt return
verwendet, erzeugt Python automatisch einen Generator, d.h. ein Objekt, das sowohl Iterator als auch Iterable ist und sich den ganzen Zustand der Funktion merkt, wenn ein Element mit yield
zurückgegeben wird.
Die erzeugte __next__
Funktion setzt die Ausführung beim wiederholten Aufruf dort fort, wo das letzte yield
erfolgt ist.
Ein Generator ist ein Iterable und kann damit auch als Ausdruck nach in
in einer for ... in <expression>
stehen.
Iteration beenden
Sind keine Elemente mehr übrig, wird einfach nichts zurückgegeben bzw. ein normales return
.
Aufgabe 3: Limitieren
Verändere den obigen Code um ein optionales Limit einzubauen, wieviele Elemente erzeugt werden sollen.
Der folgende Code soll also 143
ausgeben:
Yield From
Manchmal möchten wir eine andere Iteration (bzw. einen anderen Generator) in die gerade laufende Iteration einbinden. Dazu dienen die Keywords yield from
.
Beispiel: ein einfacher Binärbaum besteht aus Knoten (en: Nodes), die jeweils bis zu zwei Kinder haben können.
Um den Baum zu traversieren (d.h. alle Knoten zu besuchen), können wir zum Beispiel die In-Order Traversierung wählen: Zuerst wird der linke Teilbaum durchlaufen, anschliessend der Knoten selbst, zum Schluss der rechte Teilbaum.
Eine rekursive Umsetzung könnte wie folgt aussehen - beachte, wie yield from
eingesetzt wird, um die rekursive Traversierung zu erreichen.
Aufgabe 4: Pre-Order Traversierung
Füge eine Methode pre_order_traversal
hinzu, die zuerst jeweils den Knoten selbst, dann den linken und rechten Teilbaum besucht. Die Reihenfolge des Besuchs sollte also 5 1 7
ausgeben.
Weiter?
Zurück zum Graph Walk in den Graphenalgorithmen - implementiere einen Depth-First Graph Walk mit deinem Wissen über Generators und yield
bzw. yield from
.