Python Generator-Funktionen und -Expressions: Ein alter Hut kann auch modern sein
Wie was Iterable und Iterator?
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. 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. von map() zurückgegeben werden. Die in dem Beispiel gezeigte Klasse ist ein einfacher Nachbau der range() Funktion. Beim Aufruf von __iter__() gibt sie sich selbst zurück und beim Aufruf von __next__() wird self.start inkrementiert und zurückgegeben bis self.stop erreicht ist.
>>> class my_range: ... def __init__(self, start, stop): ... self.start = start ... self.stop = stop ... def __iter__(self): ... return self ... def __next__(self): ... if self.start == self.stop: ... raise StopIteration ... self.start += 1 ... return self.start - 1
>>> i = iter(my_range(1, 5)) >>> while True: ... try: ... print(next(i)) ... except StopIteration: ... break 1 2 3 4
Ressourcen? Habe ich doch ausreichend...
>>> 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 Iterator-Objektes wie im 1. 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-Expressions
>>> g = (n**2 for n in range(5)) >>> for i in g: ... print(i) ... 0 1 4 9 16 >>> for i in g: ... print(i) ... >>>
Generator-Funktionen
Mit Generator-Expressions lassen sich 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 ...
Pipelining mit Generatoren
#!/usr/bin/env python3 import bz2 import gzip import lzma import os import re from pprint import pprint def open_logfiles(path, pattern): re_pattern = re.compile(pattern) for root, dirs, files in os.walk(path): for f in (f for f in files if re_pattern.search(f)): if f.endswith('.gz'): yield gzip.open(os.path.join(root, f), 'r') elif f.endswith('.bz2'): yield bz2.open(os.path.join(root, f), 'r') elif f.endswith('.xz'): yield lzma.open(os.path.join(root, f), 'r') else: yield open(os.path.join(root, f), 'r') def gen_lines(fhs): for fh in fhs: for l in fh: yield l.rstrip() def main(): apache_logs = open_logfiles('/var/log/apache2', r'^access_log') ips = set(l.split()[0] for l in gen_lines(apache_logs)) pprint(ips) if __name__ == '__main__': main()
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:
1. Die 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.
2. gen_lines() iteriert über die File-Objekte und gibt die einzelnen Zeilen der Dateien zurück.
3. 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.
Quellen
Principal Consultant bei ORDIX
Bei Updates im Blog, informieren wir per E-Mail.
Kommentare