Funktionale Muster in Python: Comprehensions, itertools und functools
Comprehensions, functools und itertools im Zusammenspiel: Wie ich in Schulungen zeige, wann eine Comprehension die bessere Wahl ist als map/filter, wo lru_cache und groupby glänzen und welche Fallstricke lauern.
In fast jeder Python-Schulung kommt irgendwann der Moment, in dem jemand aus der Java- oder C#-Welt fragt: „Und wo ist hier map und filter? Das kenne ich doch." Meine Antwort ist immer dieselbe – es gibt beides, aber Python bevorzugt an dieser Stelle eine andere Ausdrucksform. Wer funktional in Python arbeiten will, greift seltener zu map und filter und häufiger zu Comprehensions, functools und itertools. Diese drei Werkzeuge decken zusammen fast alles ab, was man aus der funktionalen Programmierung gewohnt ist – nur mit einer Lesbarkeit, die für Python typisch ist.
In diesem Artikel gehe ich der Reihe nach durch: erst Comprehensions als idiomatischer Ersatz für map/filter, dann die lazy Kombinatoren aus itertools, danach die Helfer aus functools – allen voran lru_cache – und zum Schluss die Neigung zur Unveränderlichkeit. An mehreren Stellen zeige ich nicht nur, wie es geht, sondern wo ich in der Praxis regelmäßig Fehler sehe. Wer sich vorher mit Fortgeschrittenem Python beschäftigt hat, kennt Generatoren und Dekoratoren schon – hier bauen wir darauf auf.
Comprehensions statt map und filter
Fangen wir mit dem konkreten Beispiel an. Ich will die Quadrate aller geraden Zahlen von 0 bis 9. In der map/filter-Variante liest sich das so:
nums = range(10)
# map/filter with lambdas – works, but reads inside out
squares_even = list(map(lambda n: n * n, filter(lambda n: n % 2 == 0, nums)))
Das funktioniert, aber man liest es von innen nach außen: erst filter, dann map, und die beiden lambda-Ausdrücke stehen zwischen den Argumenten. Die Comprehension dreht das um und stellt die Transformation nach vorn, die Bedingung nach hinten:
nums = range(10)
# comprehension – transform first, condition last, one line
squares_even = [n * n for n in nums if n % 2 == 0]
Das ist die Form, die in Python-Code überwiegt, und der Grund ist schlicht Lesbarkeit. Die Konvention aus PEP 8 und der gängigen Praxis lautet: Comprehension vor map/filter mit lambda. Comprehensions gibt es in drei Ausprägungen – für Listen, Dictionaries und Sets:
words = ["alpha", "beta", "gamma"]
# list, dict and set comprehensions
lengths = [len(w) for w in words]
by_length = {w: len(w) for w in words}
initials = {w[0] for w in words}
Ein Detail, das ich in Schulungen gern hervorhebe: Seit Python 3 hat die Comprehension einen eigenen Namensraum. Die Laufvariable w bleibt innerhalb der Comprehension und leckt nicht mehr in den umgebenden Code – ein alter Stolperstein aus Python-2-Zeiten, den man heute nicht mehr fürchten muss.
Bedeutet das, dass map und filter überflüssig sind? Nein. Sie sind weiterhin idiomatisch, wenn bereits eine benannte Funktion existiert. map(str.upper, words) ist klarer als [w.upper() for w in words] – hier steht kein lambda, sondern eine fertige Funktion, und der Ausdruck bleibt knapp:
words = ["alpha", "beta", "gamma"]
# map shines with an existing named function – no lambda noise
shouted = list(map(str.upper, words))
Die Faustregel, die ich mitgebe: lambda in map/filter ist ein Signal, dass eine Comprehension die bessere Wahl wäre. Eine benannte Funktion in map dagegen ist völlig in Ordnung.
Ein verwandtes Detail sorgt regelmäßig für Verwirrung: der Unterschied zwischen dem Filter am Ende und einem bedingten Ausdruck vorn. Beide benutzen das Schlüsselwort if, tun aber Gegensätzliches:
nums = range(10)
# if at the end filters elements out – only evens survive
evens = [n for n in nums if n % 2 == 0]
# if ... else at the front transforms – all elements stay
masked = [n if n % 2 == 0 else 0 for n in nums]
Das if am Ende wählt aus und lässt die ungeraden Zahlen weg; das if ... else vorn behält jedes Element und ersetzt nur die ungeraden durch 0. Zwei Konstrukte, die sich leicht verwechseln lassen und völlig unterschiedliche Ergebnisse liefern – ich sehe den Fehler in Schulungen fast in jeder Gruppe einmal.
Lazy rechnen mit map, filter und Generator-Expressions
Ein Punkt wird oft übersehen: In Python 3 geben map und filter keine Listen mehr zurück, sondern Iteratoren. Sie rechnen bedarfsgesteuert – erst wenn man Werte anfordert, werden sie erzeugt. Genau deshalb steht in den Beispielen oben ein list(...) drumherum, sonst bekäme man nur das Iterator-Objekt.
Die Comprehension dagegen materialisiert sofort: Sie baut die komplette Liste im Speicher auf. Für kleine Datenmengen ist das egal, für große kann es teuer werden. Hier kommt die Generator-Expression ins Spiel – syntaktisch fast identisch zur List-Comprehension, nur mit runden statt eckigen Klammern:
# list comprehension: builds the whole list in memory
total_eager = sum([n * n for n in range(1_000_000)])
# generator expression: yields one value at a time, constant memory
total_lazy = sum(n * n for n in range(1_000_000))
Beide liefern dasselbe Ergebnis, aber die zweite Variante hält nie mehr als einen Wert gleichzeitig im Speicher. Wenn ich einen Ausdruck ohnehin nur einmal durchlaufe – etwa als Argument für sum, any oder max –, ist die Generator-Expression fast immer die passende Wahl.
Ein Wort der Vorsicht: Ein Generator ist nach einem Durchlauf erschöpft. Wer denselben Generator zweimal durchgehen will, bekommt beim zweiten Mal ein leeres Ergebnis. Für mehrfachen Zugriff braucht man eine materialisierte Liste – oder man denkt die Pipeline so, dass ein Durchlauf reicht.
Damit haben wir drei Wege, eine Sequenz zu verarbeiten, und jeder hat seinen Platz:
- Comprehension – lesbar, materialisiert sofort, eigener Namensraum, Filter und Transformation in einem Ausdruck.
map/filter– lazy, ideal in Kombination mit einer vorhandenen benannten Funktion, mitlambdaweniger lesbar.itertools– lazy Kombinatoren für lange oder unendliche Ströme, konstanter Speicher, gut komponierbar.
Den dritten Punkt schauen wir uns jetzt genauer an.
itertools: lazy Kombinatoren für Ströme
itertools ist eine Sammlung von Bausteinen, die alle lazy arbeiten. Sie erzeugen Iteratoren, keine Listen, und lassen sich zu Pipelines zusammenstecken, ohne dass Zwischenergebnisse im Speicher auflaufen. Ein paar, die ich fast täglich verwende:
from itertools import chain, islice, count
# count: infinite counter, here 1, 3, 5, 7, 9
first_five_odds = list(islice(count(1, 2), 5))
# chain.from_iterable: flatten a list of lists lazily
flat = list(chain.from_iterable([[1, 2], [3, 4], [5, 6]]))
Neben chain, islice und count greife ich oft zu takewhile und dropwhile. Beide nehmen ein Prädikat und einen Strom: takewhile liefert Werte, solange die Bedingung hält, und bricht beim ersten False endgültig ab; dropwhile überspringt den führenden Block, für den das Prädikat wahr ist, und gibt ab da alles zurück. Auf einem sortierten Strom lässt sich damit ein Bereich herausschneiden, ohne den ganzen Rest noch zu prüfen.
count(1, 2) ist ein unendlicher Zähler – ohne islice würde er nie aufhören. islice schneidet die ersten fünf Werte heraus und stoppt die Auswertung genau dort. Das ist das Muster hinter lazy Pipelines: Man beschreibt eine potenziell unendliche Quelle und begrenzt sie erst am Ende. chain.from_iterable wiederum flacht verschachtelte Sequenzen ab, ohne die Teillisten vorher zusammenzukopieren.
Der Baustein, der die meisten Aha-Momente auslöst, ist groupby. Damit lassen sich Datensätze nach einem Schlüssel gruppieren und aggregieren:
from itertools import groupby
from operator import itemgetter
records = [
{"team": "a", "pts": 3},
{"team": "a", "pts": 2},
{"team": "b", "pts": 5},
]
# IMPORTANT: sort by the same key before grouping
records.sort(key=itemgetter("team"))
totals = {
team: sum(r["pts"] for r in group)
for team, group in groupby(records, key=itemgetter("team"))
}
# {"a": 5, "b": 5}
Und hier lauert der Fallstrick, an dem ich schon viele habe scheitern sehen: groupby gruppiert nur aufeinanderfolgende gleiche Schlüssel. Ist die Eingabe nicht nach demselben Schlüssel vorsortiert, entstehen mehrere Gruppen für denselben Schlüssel, und das Ergebnis ist stillschweigend falsch. Das records.sort(...) vor dem groupby ist also keine Kür, sondern Pflicht. Wer von SQL-GROUP BY kommt, erwartet oft, dass die Sortierung automatisch passiert – hier passiert sie nicht.
Ein zweiter Punkt: Die Gruppen, die groupby liefert, sind flüchtige Iteratoren. Sobald man in der äußeren Schleife weiterrückt, wird die vorige Gruppe ungültig. Wer eine Gruppe über die Iteration hinaus behalten will, muss sie mit list(group) materialisieren, bevor er weitergeht. Im Beispiel oben ist das kein Problem, weil sum(...) die Gruppe direkt im selben Schritt auswertet.
Bevor wir zu functools kommen, ein Blick auf das Gesamtbild. Die folgende Skizze zeigt, wie sich Rohdaten durch eine solche Pipeline bewegen – von der eager materialisierenden Comprehension über lazy Generator-Expressions und itertools-Kombinatoren bis zur finalen Faltung auf einen Wert.
graph LR
A[Raw data] --> B[Comprehension<br/>eager, materialised]
B --> C[Generator expr<br/>lazy, constant memory]
C --> D[itertools<br/>chain / islice / groupby]
D --> E[reduce / sum / prod<br/>fold to a value]
E --> F[Result]
functools: partial, reduce und lru_cache
functools bündelt Werkzeuge, die auf Funktionen selbst wirken. Drei davon sind für funktionale Muster zentral.
partial bindet Argumente einer Funktion vorab und gibt eine neue, aufrufbare Funktion zurück. Das ist praktisch, um allgemeine Funktionen zu spezialisieren:
from functools import partial
def power(base, exponent):
return base ** exponent
# fix the exponent, get a specialised function
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(5)) # 125
reduce faltet eine Sequenz auf einen einzigen Wert – der Klassiker der funktionalen Programmierung. In Python 3 ist reduce bewusst kein Builtin mehr, sondern in functools verschoben. Das ist ein deutliches Signal: sparsam einsetzen.
from functools import reduce
flat = [1, 2, 3, 4]
# reduce works, but a built-in is almost always clearer
total = reduce(lambda acc, x: acc + x, flat, 0) # 10
better = sum(flat) # same result, obvious intent
Ein Detail an reduce ist leicht zu übersehen: das dritte Argument. Es ist der Startwert der Faltung und entscheidet zugleich, was bei einer leeren Sequenz passiert. Mit Startwert 0 liefert reduce bei leerer Eingabe genau diese 0; ohne Startwert wirft reduce bei leerer Sequenz stattdessen einen TypeError. Wer den Startwert weglässt, handelt sich also einen Sonderfall ein, den man in Tests leicht vergisst – und der dann ausgerechnet im Randfall zuschlägt.
Hier zeigt sich der häufigste functools-Fehler, den ich sehe: reduce wird überstrapaziert. Verschachtelte reduce-Lambdas sind schwer zu lesen, und für die meisten Fälle gibt es klarere Alternativen – sum für Summen, math.prod für Produkte (seit Python 3.8), eine Comprehension oder schlicht eine Schleife. reduce ist gerechtfertigt, wenn die Faltung wirklich nicht-trivial ist und keine spezialisierte Funktion existiert. In allen anderen Fällen gilt: Der ausdrücklichere Weg gewinnt.
Der Star von functools ist für mich lru_cache. Der Dekorator memoisiert die Ergebnisse einer Funktion – gleiche Argumente liefern das gespeicherte Ergebnis, statt neu zu rechnen. Das klassische Lehrbeispiel ist die naive rekursive Fibonacci-Funktion, deren Laufzeit ohne Memoisierung exponentiell explodiert:
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n: int) -> int:
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(50)) # 12586269025 – instant instead of minutes
print(fib.cache_info()) # CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)
Mit lru_cache wird jeder Fibonacci-Wert genau einmal berechnet – die Komplexität sinkt von exponentiell auf linear. cache_info() gibt Einblick in Treffer und Fehlschläge, cache_clear() leert den Speicher. Ein Hinweis zur Schreibweise: Ich schreibe konsequent @lru_cache(maxsize=None) mit Klammern. Das ist über alle Versionen hinweg eindeutig und erspart Diskussionen darüber, ab welcher Python-Version der Aufruf ohne Klammern erlaubt ist.
Zwei Bedingungen muss man kennen. Erstens: Die Argumente müssen hashbar sein – Listen oder Dictionaries als Parameter funktionieren nicht, Tupel dagegen schon. Zweitens: lru_cache passt nur zu reinen Funktionen. Wenn die Funktion Seiteneffekte hat oder von veränderlichem Zustand abhängt, liefert der Cache falsche Ergebnisse. Und maxsize=None bedeutet einen unbeschränkten Cache – bei sehr vielen verschiedenen Argumenten kann der Speicherverbrauch ungebremst wachsen. Für solche Fälle setzt man ein konkretes maxsize.
functools hält noch mehr bereit, als in einen Artikel passt – etwa singledispatch, das eine Funktion je nach Typ ihres ersten Arguments unterschiedlich auflösen lässt, oder cmp_to_key, das eine alte Vergleichsfunktion in einen key für sorted übersetzt. Für funktionale Muster im engeren Sinn sind partial, reduce und lru_cache aber die drei, zu denen ich in der Praxis am häufigsten greife.
Der Hang zur Unveränderlichkeit
Funktionales Arbeiten und Unveränderlichkeit gehören zusammen. Python bietet zwei unveränderliche Container, die dabei helfen: tuple für geordnete Sequenzen und frozenset für Mengen. Beide sind hashbar und lassen sich damit als Dictionary-Schlüssel oder als Elemente eines Sets verwenden – etwas, das mit list oder set nicht geht:
# immutable, hashable – usable as dict keys or set members
point = (3, 4)
allowed = frozenset({"read", "write"})
permissions = {
frozenset({"read"}): "viewer",
frozenset({"read", "write"}): "editor",
}
print(permissions[allowed]) # editor
Der Vorteil liegt nicht nur in der Hashbarkeit. Unveränderliche Daten können sich nicht unbemerkt ändern, während man sie durch eine Pipeline schickt. Genau das ist die Grundlage seiteneffektfreien Arbeitens: Eine Funktion bekommt Daten herein, gibt neue Daten heraus und lässt die Eingabe unangetastet. Wer tiefer verstehen will, warum diese Trennung gerade in größeren Systemen trägt, findet mehr dazu unter Python für Entwickler, die Systeme verstehen wollen.
Das führt direkt zum letzten Fallstrick, den ich häufig sehe: Seiteneffekte in Comprehensions. Eine Comprehension soll Werte erzeugen, nicht print aufrufen, an eine Liste anhängen oder Zustand verändern:
# don't do this: a comprehension used only for side effects
[print(w) for w in words] # builds a useless list of None values
# do this instead
for w in words:
print(w)
Die erste Variante baut nebenbei eine Liste aus lauter None-Werten, die niemand braucht. Sie missbraucht die Comprehension für etwas, wofür die normale Schleife da ist. Comprehensions sind für Transformationen gedacht – wenn kein Wert entsteht, gehört der Code in eine Schleife.
Die passende Wahl treffen
Am Ende geht es weniger darum, jedes Werkzeug zu kennen, als darum, im richtigen Moment das passende zu wählen. Der folgende Entscheidungsbaum fasst zusammen, wie ich in der Praxis abwäge.
graph TD
A[Sequence to process] --> B{Build a new container?}
B -->|Yes| C[Comprehension<br/>list / dict / set]
B -->|No| D{Transform with a<br/>named function?}
D -->|Yes| E[map]
D -->|No| F{Large or infinite,<br/>save memory?}
F -->|Yes| G[Generator expr<br/>or itertools]
F -->|No| H{Fold to one value?}
H -->|Yes| I[sum / prod / reduce]
Die Linie, die sich durch all das zieht: Python stellt die funktionalen Werkzeuge bereit, verlangt aber, dass man sie in seiner eigenen Ausdrucksform nutzt. Comprehensions kommen vor map/filter mit lambda, weil sie sich besser lesen. Generator-Expressions und itertools treten an, wenn Datenmengen groß werden oder Ströme unendlich sind – faul und speicherschonend. functools steuert partial für Spezialisierung, reduce für die seltenen echten Faltungen und lru_cache für Memoisierung reiner Funktionen bei. Und die unveränderlichen Container tuple und frozenset halten das Ganze seiteneffektfrei zusammen.
Die Fallstricke, die ich gezeigt habe, sind alle vom selben Typ: reduce, wo sum gereicht hätte; groupby ohne Vorsortierung; Seiteneffekte in einer Comprehension. Sie entstehen nicht aus fehlendem Wissen, sondern aus der Gewohnheit, ein Werkzeug über seinen Zweck hinaus einzusetzen. Wer die drei Bibliotheken kennt und diszipliniert einsetzt, schreibt Python, das kompakt bleibt und trotzdem gut lesbar ist – und genau das ist der Anspruch, den die Sprache an funktionalen Code stellt.
Weiterführende Quellen
- Repository
introduction-python– die Beispiele aus diesem Artikel zum Ausprobieren. - functools – Higher-order functions (Python 3.8)
- itertools – Functions creating iterators (Python 3.8)
- List Comprehensions – Python Tutorial (3.8)
- map und filter – Built-in Functions (Python 3.8)
Kommentare
Kommentar schreiben