Unser Newsletter rund um technische Themen,
das Unternehmen und eine Karriere bei uns.

6 Minuten Lesezeit (1231 Worte)

Python Generator-Funktionen und -Expressions: Ein alter Hut kann auch modern sein

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 anschaulich erläutert werden.

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 
Iterables können z. B. in for-Schleifen verwendet werden. Diese rufen implizit die Funktion iter(), die den Iterator zu einem Iterable zurückgibt, mit dem angegebenen Iterable-Objekt als Argument auf und weist den Iterator einer anonymen Variablen zu. Die Variable ist temporär und existiert bis die Schleife beendet ist. In jedem Durchlauf der Schleife wird implizit die Funktion next() mit dem Iterator als Argument aufgerufen bis die StopIteration Exception auftritt. Eine for-Schleife lässt sich somit folgendermaßen mit einer while-Schleife simulieren:
>>> i = iter(my_range(1, 5))
>>> while True:
...     try:
...         print(next(i))
...     except StopIteration:
...         break
1
2
3
4 

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 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

Einfache Generatoren können mit Generator-Expressions implementiert werden. Sie haben, wie im 2. Beispiel ersichtlich, eine an List-Comprehensions angelehnte Syntax. Ähnlich wie lambda, mit dessen Hilfe sich anonyme Funktionen erstellen lassen, 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 folgendes Beispiel). 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.
>>> 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
... 
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 (er wird next() übergeben) wird. Dann wird die Funktion von oben nach unten abgearbeitet, bis das erste yield Statement erreicht wird, welches den Wert der Expression zurückgibt. 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 mit Hilfe von Generator-Exrepssions 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.
#!/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

 

Kommentare

Derzeit gibt es keine Kommentare. Schreibe den ersten Kommentar!
Mittwoch, 18. Dezember 2024

Sicherheitscode (Captcha)

×
Informiert bleiben!

Bei Updates im Blog, informieren wir per E-Mail.

Weitere Artikel in der Kategorie