6 Minuten Lesezeit
(1127 Worte)
Ein alter Hut kann auch modern sein – Python Generator-Funktionen und -Expressions
Funktionen wie filter(), map() und zip() geben seit Python 3 keine Liste, sondern einen Iterator zurück. Dadurch muss nicht die gesamte Liste im Speicher gehalten werden, sondern immer nur das aktuelle Objekt. Dies ist wesentlich effizienter und eine gute Vorlage für das Design von eigenem Code. Schon seit Python 2.3 bzw. 2.4 können Generator-Funktionen und -Expressions genutzt werden, um auf einfache und effiziente Weise Iteratoren zu generieren. Trotzdem werden sie viel zu selten eingesetzt. Damit in Zukunft häufiger Generatoren verwendet werden, sollen sie in diesem Beitrag an-schaulich erläutert werden.
Wie was – Iterable und Iterator
Eine Klasse, die gleichzeitig ein Iterable und ein Iteratorist, ist beispielhaft in Abbildung 1 dargestellt. Ein Iterable ist ein Objekt, dass die Methode __iter__()implementiert. Dies sind alle Sequence-Typen (Listen, Strings usw.) und z. B. Dictionaries oder Dateiobjekte. Die Methode wird aufgerufen, wenn ein Iterator-Objekt des Containers benötigt wird. Dies wird von der Methode zurückgegeben. Das Iterator-Objekt wiederum implementiert die Methode __next__(), die jeweils das nächste Element in dem Container zurück gibt bzw. die Exception StopIteration generiert, wenn keine weiteren Elemente im Container vorhanden sind. Damit der Iterator weiß, welches das nächste Element ist, speichert er seinen jeweiligen aktuellen Zustand.
Eine Klasse, die gleichzeitig ein Iterable und ein Iterator ist, kann wie folgt aussehen. Dies entspricht der Implementierung von Generatoren und auch den Objekten, die z. B. map() zurückgibt. Die in dem Beispiel gezeigte Klasse ist ein einfacher Nachbau der Funktion range(). Beim Aufruf von __iter__() gibt sie sich selbst zurück und beim Aufruf von __next__() wird self.start so lange inkrementiert und zurückgegeben, bis self.stop erreicht ist.
Iterables können z. B. in for-Schleifen verwendet werden. Diese ruft implizit die Funktion iter(), die den Iterator zu einem Iterable zurückgibt, mit dem angegebenen IterableObjekt als Argument auf und weist den Iterator einer anonymen Variablen zu. Diese Variable ist temporär und existiert, bis die Schleife beendet ist. In jedem Durchlauf der Schleife wird implizit die Funktion next() mit dem Iteratorals Argument aufgerufen, bis die StopIteration Exception auftritt. Eine for-Schleife lässt sich somit mit einer while-Schleife simulieren (siehe Abbildung 2).
Abb. 1: Iterable- und Iterator-Klasse
Abb. 2: StopIteration Exception
Ressourcen? Habe ich doch ausreichend ..
Ein Iterator erzeugt ein Ergebnis nur, wenn es angefordert wird, anstatt alle Ergebnisse sofort bereitzustellen. Dieses „faule" Verhalten kann bei der Verarbeitung großer Datenmengen den benötigten Speicher massiv reduzieren und auch die Performance steigern, wenn komplexe Berechnungen zum Produzieren der Werte notwendig sind. Man kann argumentieren, dass Ressourcen in heutiger Zeit in vielen Fällen kein wesentlicher Faktor sind, aber wenn man ihre Nutzung ohne höheren Aufwand auf ein Minimum reduzieren kann, sollte jeder hellhörig werden. In dem folgenden Beispiel wird im ersten Schritt mittels List-Comprehension eine Liste generiert, die Zweierpotenzen der Zahlen von 0 bis 99999999 enthält, und im zweiten Schritt die Summe dieser Zahlen gebildet:
>>> sum([n**2 for n in range(100000000)]) 333333328333333350000000
Für diese Berechnung wird ca. 4 GB Speicher benötigt. Wird anstelle der Liste ein Iterator verwendet, kann der Speicherbedarf, bei vergleichbarer Performance auf ca. 8 MB reduziert werden. Die Implementierung ist im folgenden Beispiel mit einer Generator-Expression gelöst, die in den nächsten Punkten detailliert erläutert wird.
>>> sum(n**2 for n in range(100000000)) 333333328333333350000000
Generatoren
Die Erzeugung eines IteratorObjektes wie im ersten Beispiel gezeigt ist relativ komplex und der Aufwand wird von vielen Entwicklern gescheut. Mit Generatoren lassen sich Iteratoren auf einfache Weise erstellen und sie machen damit die Einsparung von Ressourcen einfach zugänglich. Ein Generator lässt sich auf zwei verschiedene Arten erzeugen, zum einen mit Generator-Expressions und zum anderen mit Generator-Funktionen.
Generator-Expression
Einfache Generatoren lassen sich mit Generator-Expressions implementieren. Sie haben, wie im zweiten Beispiel ersichtlich, eine an List-Comprehensions angelehnte Syntax. Ähnlich wie lambda anonyme Funktionen erstellt, erstellen Generator-Expressions anonyme Generator-Funktionen. In der Regel werden die Expressions in runden Klammern geschrieben. Diese können, falls die Expression das einzige Argument einer Funktion ist, weggelassen werden. Im Gegensatz zu einer Liste wird ein Generator „verbraucht", man kann z. B. nur einmal über ihn iterieren (siehe Abbildung 3). Ebenso kann auf einen Generator nicht über Sequence-Operationen zugegriffen werden. Wird also eine Liste, die mittels List-Comprehension generiert wurde, nicht häufiger benötigt bzw. es müssen keine speziellen Methoden oder Sequence-Operationen auf sie ausgeführt werden, sollte sie durch eine Generator-Expression ersetzt werden.
Abb. 3: Verbrauch von Generatoren
Generator-Funktionen
Es lassen sich mit Generator-Expressions jedoch nicht beliebig komplizierte Generatoren erstellen. An dieser Stelle kommen Generator-Funktionen ins Spiel. Die in den oberen Beispielen dargestellte Expression könnte als Funktion wie folgt definiert werden.
>>> def potenz_2(anz): ... for n in range(anz): ... yield n**2 ...
Generator-Funktionen unterscheiden sich somit auf den ersten Blick nicht von „normalen" Funktionen. Der Unterschied liegt in dem Statement yield. Es macht eine Funktion zu einem Generator. Wird eine Generator-Funktion aufgerufen, wird sie initialisiert, aber noch kein Code ausgeführt. Dies geschieht erst, wenn der erstellte Iterator genutzt wird (er wird next() übergeben). Dann wird die Funktion von oben nach unten abgearbeitet, bis das erste yield-Statement erreicht wird und der Wert der Expression zurückgegeben. Im Gegensatz zu einer normalen Funktion wird diese nun nicht beendet, sondern nur unterbrochen (alle aktuellen Werte bleiben erhalten). Beim nächsten Mal, wenn der Generator next() übergeben wird, beginnt die Verarbeitung direkt nach dem yield-Statement, bei dem der Generator das letzte Mal unterbrochen wurde.
Pipelining mit Generatoren
Mit den bisher genannten Beispielen lässt sich zwar das Verhalten von Generatoren gut erklären, aber wirkliche Begeisterung lässt sich damit schwer hervorrufen. Leider hören die Erläuterungen in vielen Python-Büchern an dieser Stelle auf. Dies erklärt eventuell den relativ seltenen Einsatz von Generatoren in Python-Programmen. Folgendes Beispiel zeigt, wie mithilfe von Generator-Expressions und -Funktionen alle Apache-Access-Logfiles in einem Verzeichnis verarbeitet werden können, um eine Liste aller IPs, über die auf den Webserver zugegriffen wurde, zu erstellen (siehe Abbildung 4).
Abb. 4: Parsen von Apache-Logfiles
Natürlich fehlt in dem Beispiel das gesamte Exception-Handling, aber es ist auf den ersten Blick ersichtlich, dass der Code durch die Nutzung von Generatoren deutlich eleganter wird. Folgendermaßen läuft die Verarbeitung ab:
- Der Generator-Funktion open_logfiles() wird ein Pfad und ein RegEx-Muster übergeben. Er öffnet die entsprechenden Dateien, gibt File-Objekte zurück und nutzt, falls es sich um eine gepackte Datei handelt, die open()Funktion des entsprechenden Moduls.
- gen_lines() iteriert über die File-Objekte und gibt die einzelnen Zeilen der Dateien zurück.
- Diese werden in der Generator-Expression verarbeitet, die jeweils die IP-Adresse zurückgibt.
Die genutzte Verknüpfung von Generatoren hat David M. Beazley schon 2008 auf der PyCon als Pipelining beschrieben. Seine Talks zu dem Thema sind die de-facto-Referenz und können als weiterführende Lektüre dienen. Dort erläutert er z. B. auch, wie Co-Routinen auf Basis von Generatoren implementiert werden.
Principal Consultant bei ORDIX
Comment for this post has been locked by admin.