# Funktionale Muster in Python: Comprehensions, itertools und functools

URL: https://www.mikebild.dev/de/blog/funktionale-muster-in-python/

```python
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](https://www.mikebild.dev/de/blog/python-advanced-decorators-generatoren/) 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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
# 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, mit `lambda` weniger 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:

```python
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:

```python
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.

```mermaid
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:

```python
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.

```python
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:

```python
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:

```python
# 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:

```python
# 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.

```mermaid
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)](https://docs.python.org/3.8/library/functools.html)
- [itertools – Functions creating iterators (Python 3.8)](https://docs.python.org/3.8/library/itertools.html)
- [List Comprehensions – Python Tutorial (3.8)](https://docs.python.org/3.8/tutorial/datastructures.html#list-comprehensions)
- [map und filter – Built-in Functions (Python 3.8)](https://docs.python.org/3.8/library/functions.html#map)
