Funktionale Muster in Python: Comprehensions, itertools und functools
Comprehensions, functools und itertools im Zusammenspiel: Wie ich in Projekten zeige, wann eine Comprehension die bessere Wahl ist als map/filter, wo lru_cache und groupby glänzen und welche Fallstricke lauern.
from itertools import groupby
from operator import itemgetter
records = [
{"team": "a", "pts": 3},
{"team": "b", "pts": 5},
{"team": "a", "pts": 2},
]
totals = {
team: sum(r["pts"] for r in group)
for team, group in groupby(records, key=itemgetter("team"))
}
# {"a": 2, "b": 5} – erwartet war {"a": 5, "b": 5}
Kein Traceback, keine Warnung, nur ein falsches Ergebnis. groupby fasst ausschließlich aufeinanderfolgende gleiche Schlüssel zusammen. Ohne Vorsortierung zerfällt Team a in zwei Gruppen, und die zweite überschreibt im Dictionary die erste. Wer GROUP BY aus SQL kennt, erwartet an dieser Stelle das Gegenteil.
Genau so verhalten sich die funktionalen Werkzeuge in Python insgesamt: Sie tragen vertraute Namen aus Java, C# oder SQL, folgen aber eigenen Regeln. Wer funktional in Python arbeiten will, greift seltener zu map und filter und häufiger zu Comprehensions, functools und itertools. Diese drei decken zusammen fast alles ab, was aus der funktionalen Programmierung vertraut ist – in einer Schreibweise, die für Python typisch ist.
Ich gehe 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. Idiomatischer Python-Code bevorzugt meist eine Comprehension gegenüber map oder filter mit lambda; PEP 8 schreibt diese konkrete Wahl allerdings nicht vor. 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 geht dabei leicht unter: 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))
Das lambda taugt damit als Signal: Steht eines in map oder filter, wäre eine Comprehension die bessere Wahl. 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 – im Code-Review stolpere ich regelmäßig darüber.
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 materialisiert keine vollständige Ergebnisliste. Sie erzeugt ihre Werte nach Bedarf; vorgelagerte Datenquellen oder einzelne Operationen können natürlich trotzdem Speicher belegen. Wenn ich einen Ausdruck ohnehin nur einmal durchlaufe – etwa als Argument für sum, any oder max –, ist die Generator-Expression meist 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}
Damit sind wir zurück beim Beispiel vom Anfang: 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 keine Kür, sondern Pflicht – es ist die eine Zeile, die den Unterschied zwischen {"a": 5, "b": 5} und {"a": 2, "b": 5} ausmacht.
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 nur dann, wenn auch ihre Elemente hashbar sind. Zweitens: lru_cache passt, wenn das Ergebnis ausschließlich von diesen Argumenten abhängt und das zurückgegebene Objekt gefahrlos wiederverwendet werden kann. Bei Seiteneffekten oder veränderlichem externem Zustand liefert der Cache leicht 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, sofern ihre Elemente hashbar sind, und lassen sich dann 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. Gerade in größeren Systemen macht diese Trennung Übergänge nachvollziehbar.
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
Entscheidend ist weniger, jedes Werkzeug zu kennen, als im richtigen Moment das passende zu greifen. 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 – lazy 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 diese drei Werkzeuggruppen 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
- Foliensatz zu diesem Thema: https://supabase.mikebild.dev/storage/v1/object/public/slides/introduction-python.html
- 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)
Wie fandest du diesen Beitrag?
Kommentare