Wie Pure Functions, Immutability und Komposition deinen Code vorhersagbar, testbar und komponierbar machen.
1. Pure Functions: Berechenbar statt überraschend
Eine Pure Function hat zwei Eigenschaften:
- Gleicher Input → gleicher Output – immer, ohne Ausnahme
- Keine Seiteneffekte – sie verändert nichts außerhalb ihrer selbst
"Purity means that functions have no side effects. The output of a function is predictably the same as long as the input is the same." — Functional Anthology, S. 34
❌ Impure: Globaler State
_discount_rate = 0.1 # globaler, mutierbarer State
def calculate_discount_impure(price: float) -> float:
return price * (1 - _discount_rate)
Ändert jemand _discount_rate an einer anderen Stelle, liefert calculate_discount_impure(100) plötzlich nicht mehr 90.0, sondern 80.0. Die Funktion selbst sieht korrekt aus – der Fehler ist unsichtbar.
✅ Pure: Alles explizit
def calculate_discount(price: float, rate: float) -> float:
return price * (1 - rate)
calculate_discount(100, 0.1) liefert immer 90.0. Kein versteckter State, keine Überraschungen.
Referentielle Transparenz
Weil eine Pure Function für denselben Input immer denselben Output liefert, lässt sich jeder Funktionsaufruf durch seinen Rückgabewert ersetzen – das Programm verhält sich identisch. Das nennt man referentielle Transparenz.
def total_after_discount(prices: list[float], rate: float) -> float:
return sum(calculate_discount(p, rate) for p in prices)
total_after_discount([100, 200, 50], 0.1) # → 315.0
▶ Probier es selbst
def calculate_discount(price: float, rate: float) -> float:
return price * (1 - rate)
def total_after_discount(prices: list[float], rate: float) -> float:
return sum(calculate_discount(p, rate) for p in prices)
print(calculate_discount(100, 0.1))
print(total_after_discount([100, 200, 50], 0.1))
calculate_discount(100, 0.1) lässt sich im Kopf durch 90.0 ersetzen – das Ergebnis bleibt gleich. Code wird nicht nur testbar, sondern auch denkbar.
"Pure functions promote referential transparency. Pure functions can be easily rearranged and reordered for execution on multiple threads." — Functional Anthology, S. 34
Testbarkeit ist der sofortige Gewinn: Pure Functions brauchen kein setUp(), kein tearDown(), keine Mocks.
def test_calculate_discount():
assert calculate_discount(100, 0.1) == 90.0
assert calculate_discount(100, 0.0) == 100.0
assert calculate_discount(0, 0.5) == 0.0
→ Vollständiger Code: src/pure_functions.py
2. Immutability: Einmal erstellt, nie verändert
Das zweite Fundament: Daten werden nicht verändert, sondern transformiert. Statt ein Objekt zu mutieren, entsteht ein neues mit den gewünschten Änderungen.
"Two of the main characteristics that make your code functional are immutability and the use of pure functions." — Functional Programming in Kotlin by Tutorials, S. 158
Das Aliasing-Problem
Python-Listen sind mutierbar – ein klassischer Bug:
cart_a = [{"item": "Buch", "price": 29.99}]
cart_b = cart_a # kein Kopieren, nur ein Alias!
cart_b.append({"item": "Stift", "price": 1.99})
len(cart_a) # → 2, nicht 1!
cart_a und cart_b zeigen auf dieselbe Liste. Eine Änderung an der einen betrifft die andere.
Pythons Werkzeuge für Immutability
| Werkzeug | Ersetzt | Unveränderlich? |
|---|---|---|
tuple |
list |
✅ |
frozenset |
set |
✅ |
@dataclass(frozen=True) |
Normale Klassen | ✅ |
NamedTuple |
Dicts / Klassen | ✅ |
Praxis: Ein unveränderlicher Warenkorb
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class CartItem:
name: str
price: float
quantity: int = 1
@dataclass(frozen=True)
class Cart:
items: tuple[CartItem, ...] = ()
@property
def total(self) -> float:
return sum(item.price * item.quantity for item in self.items)
Statt den Warenkorb zu verändern, entsteht ein neuer:
def add_item(cart: Cart, item: CartItem) -> Cart:
return replace(cart, items=cart.items + (item,))
def apply_discount(cart: Cart, rate: float) -> Cart:
discounted_items = tuple(
replace(item, price=round(item.price * (1 - rate), 2))
for item in cart.items
)
return replace(cart, items=discounted_items)
cart = Cart()
cart2 = add_item(cart, CartItem("Buch", 29.99))
cart.total # → 0.0 (Original unverändert!)
cart2.total # → 29.99
cart bleibt immer derselbe. Mutation eines frozen-Objekts schlägt sofort fehl:
item = CartItem("Buch", 29.99)
item.price = 19.99 # → AttributeError: frozen dataclass
→ Vollständiger Code: src/immutability.py
3. First-Class Functions & Higher-Order Functions
In Python sind Funktionen First-Class Citizens: Sie lassen sich Variablen zuweisen, als Parameter übergeben und aus anderen Funktionen zurückgeben. Eine Higher-Order Function (HOF) nimmt eine Funktion als Argument oder gibt eine zurück.
"Higher-order functions accept a lambda expression as input [or] return a lambda expression as an output value." — Functional Programming in Kotlin by Tutorials, S. 115
Funktionen als Parameter
def shout(text: str) -> str:
return text.upper() + "!"
def whisper(text: str) -> str:
return text.lower() + "..."
def greet(name: str, style):
return style(name)
greet("Python", shout) # → 'PYTHON!'
greet("Python", whisper) # → 'python...'
greet ist eine HOF: Sie bekommt eine Strategie (Funktion) übergeben, statt das Verhalten hart zu kodieren.
Die klassischen HOFs: map, filter, reduce
from functools import reduce
prices = [120, 80, 45, 200, 30]
discounted = map(lambda p: p * 0.9, prices)
above_50 = filter(lambda p: p >= 50, discounted)
total = reduce(lambda acc, p: acc + p, above_50, 0.0)
# → 360.0
Keine Schleife, kein mutierbarer Akkumulator. Jeder Schritt beschreibt was passieren soll, nicht wie.
Eigene HOFs: Der retry-Dekorator
def retry(max_attempts=3):
def decorator(func):
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_error = e
raise last_error
return wrapper
return decorator
@retry(max_attempts=3)
def fetch_data():
# ... könnte fehlschlagen ...
pass
Lambda: Wann ja, wann nein?
# ✅ Gut: kurz, klar, einmalig
sorted(products, key=lambda p: p[1])
# ❌ Schlecht: zu komplex für Lambda
sorted(products, key=lambda p: p[1] * (1 - p[2]) if p[2] > 0 else p[1])
Faustregel: Wenn ein Lambda einen Namen bräuchte, sollte es eine benannte Funktion sein.
→ Vollständiger Code: src/higher_order.py
4. Closures: Funktionen mit Gedächtnis
Eine Closure ist eine Funktion, die sich an Variablen aus ihrem Entstehungskontext erinnert – auch nachdem dieser Kontext verlassen wurde.
"Closures are a bit magical, so it's worth examining them in more detail." — Functional Patterns, S. 61
Die Funktions-Factory
def make_multiplier(factor: float):
def multiply(x: float) -> float:
return x * factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
double(5) # → 10.0
triple(5) # → 15.0
double und triple sind verschiedene Funktionen mit unterschiedlichem eingeschlossenen State. Der factor lebt in der Closure weiter, obwohl make_multiplier längst fertig ist.
Closure vs. Klasse
Closures sind eine leichtgewichtige Alternative zu Klassen, wenn nur eine Operation mit Konfiguration benötigt wird:
# Mit Klasse
class Greeter:
def __init__(self, greeting):
self.greeting = greeting
def __call__(self, name):
return f"{self.greeting}, {name}!"
# Als Closure
def make_greeter(greeting):
def greet(name):
return f"{greeting}, {name}!"
return greet
Die Closure ist kompakter und kommuniziert klarer: Es geht um eine konfigurierte Funktion, nicht um ein Objekt mit State.
Konfiguration einschließen
def make_formatter(decimal_places=2, currency="€"):
def format_price(amount: float) -> str:
return f"{amount:.{decimal_places}f} {currency}"
return format_price
format_eur = make_formatter(2, "€")
format_usd = make_formatter(2, "$")
format_eur(1234.5) # → '1234.50 €'
format_usd(1234.5) # → '1234.50 $'
Python erlaubt mit nonlocal das Mutieren von Closure-Variablen. In FP vermeiden wir das – eine Closure sollte ihren State lesen, nicht schreiben.
→ Vollständiger Code: src/closures.py
5. Funktionskomposition: Kleine Teile, großes Ganzes
Das vielleicht wichtigste Prinzip: Statt eine große Funktion zu schreiben, die alles macht, entstehen kleine Funktionen, die zusammengesteckt werden.
"Function composition [means] combining functions by connecting the output of the first to the input of the second. [...] An important aspect of this kind of composition is information hiding." — Domain Modeling Made Functional, S. 164
Mathematische Komposition
In der Mathematik: (f ∘ g)(x) = f(g(x)) – erst g, dann f. In Python:
def compose(*funcs):
def composed(x):
result = x
for f in reversed(funcs):
result = f(result)
return result
return composed
Pipe: Von links nach rechts
Die mathematische Notation liest sich rückwärts. Natürlicher – wie Unix-Pipes – ist die Pipe-Richtung:
"The filter composition operation is an example of function composition [...] like Unix pipes." — Functional Programming in Swift, S. 28
def pipe(*funcs):
def piped(x):
result = x
for f in funcs:
result = f(result)
return result
return piped
Praxis: Text-Pipeline
def strip_whitespace(text): return text.strip()
def lowercase(text): return text.lower()
def remove_punctuation(text):
return "".join(c for c in text if c.isalnum() or c.isspace())
def split_words(text): return text.split()
def unique_sorted(words): return sorted(set(words))
clean_and_tokenize = pipe(
strip_whitespace,
lowercase,
remove_punctuation,
split_words,
unique_sorted,
)
clean_and_tokenize(" Hello, World! Hello Python. ")
# → ['hello', 'python', 'world']
Jede Funktion ist einzeln testbar. Die Pipeline liest sich von oben nach unten. Schritte lassen sich jederzeit hinzufügen oder entfernen, ohne den Rest anzufassen.
→ Vollständiger Code: src/fp_composition.py
6. Comprehensions & Generatoren: Pythons deklarativer Stil
Python hat einen Trumpf, den viele FP-Sprachen so nicht bieten: Comprehensions.
Imperativ vs. Deklarativ
# Imperativ: WIE es gemacht wird
result = []
for item in items:
if item["price"] >= min_price:
result.append(item["name"].upper())
# Deklarativ: WAS gemacht wird
result = [
item["name"].upper()
for item in items
if item["price"] >= min_price
]
Gleiche Logik, gleicher Output – aber die Comprehension kommuniziert die Absicht, nicht den Mechanismus.
Generatoren: Lazy Evaluation
Ein Generator berechnet Werte erst bei Bedarf – ideal für große oder unendliche Datenströme:
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci()
first_8 = [next(fib) for _ in range(8)]
# → [0, 1, 1, 2, 3, 5, 8, 13]
Generator Expressions sind die lazy Variante von List Comprehensions:
# Eager: erstellt die ganze Liste im Speicher
sum([x * x for x in range(1_000_000)])
# Lazy: berechnet on-the-fly, braucht kaum Speicher
sum(x * x for x in range(1_000_000))
→ Vollständiger Code: src/comprehensions.py
7. Alles zusammen: Log-Analyse
Alle Konzepte in einem realistischen Beispiel: ein Log-Analyse-Tool.
Das Problem
Log-Zeilen wie:
2024-01-15 10:30:00 INFO Server gestartet
2024-01-15 10:32:00 ERROR Datenbankverbindung verloren
2024-01-15 10:34:00 WARNING Speicher bei 90%
❌ Imperativ: Mutierbarer State
def analyze_logs_imperative(lines):
error_messages = []
level_counts = {}
for line in lines:
parts = line.strip().split(" ", 3)
if len(parts) < 4:
continue
level = parts[2]
message = parts[3]
if level in level_counts:
level_counts[level] += 1
else:
level_counts[level] = 1
if level == "ERROR":
error_messages.append(message)
return {
"total": sum(level_counts.values()),
"level_counts": level_counts,
"errors": error_messages,
}
Zwei mutierbare Datenstrukturen, verschachtelte Logik, alles in einer Funktion.
✅ Funktional: Pure Functions + Komposition
from dataclasses import dataclass
from collections import Counter
@dataclass(frozen=True)
class LogEntry:
timestamp: str
level: str
message: str
def parse_log_line(line: str) -> LogEntry | None:
parts = line.strip().split(" ", 3)
if len(parts) < 4:
return None
return LogEntry(
timestamp=f"{parts[0]} {parts[1]}",
level=parts[2],
message=parts[3],
)
def is_error(entry: LogEntry) -> bool:
return entry.level == "ERROR"
def count_by_level(entries):
return dict(Counter(e.level for e in entries))
def analyze_logs_functional(lines):
entries = [e for line in lines
if (e := parse_log_line(line)) is not None]
errors = list(filter(is_error, entries))
error_messages = list(map(lambda e: e.message, errors))
return {
"total": len(entries),
"level_counts": count_by_level(entries),
"errors": error_messages,
}
- Jede Funktion hat eine Aufgabe:
parse_log_lineparst,is_errorfiltert,count_by_levelaggregiert - Alles ist testbar: Jede Funktion lässt sich einzeln testen
- Erweiterbar: Ein neuer Filter ist eine neue Funktion, kein neues
if
→ Vollständiger Code: src/log_analyzer.py
🧠 Kurz-Check
Welche Eigenschaft ist *nicht* charakteristisch für eine pure Function?
Zusammenfassung
| Konzept | Kernidee | Python-Werkzeug |
|---|---|---|
| Pure Functions | Gleicher Input → gleicher Output | Explizite Parameter, kein globaler State |
| Immutability | Nicht verändern, sondern transformieren | frozen dataclass, tuple, replace() |
| Higher-Order Functions | Funktionen als Werte | map, filter, reduce, Dekoratoren |
| Closures | Funktionen mit Gedächtnis | Verschachtelte Funktionen, Factories |
| Komposition | Kleine Teile zusammenstecken | compose(), pipe(), Pipelines |
Jedes dieser Konzepte macht Code sofort testbarer, lesbarer und robuster. Aber eine Frage bleibt offen: Was passiert, wenn etwas schiefgeht? Wie behandelt man Fehler funktional? Die Antworten liegen in Algebraischen Datentypen – dem Thema des nächsten Artikels.
Alle Code-Beispiele aus diesem Artikel findest du im src/-Verzeichnis. Sie sind lauffähig und enthalten Tests.
Quellen
- Functional Anthology (div. Autoren) – S. 26, 34, 43
- Functional Programming in Kotlin by Tutorials (raywenderlich) – S. 115, 137, 158
- Functional Patterns (Tomas Petricek) – S. 61
- Domain Modeling Made Functional (Scott Wlaschin) – S. 164–165
- Functional Programming in Swift (Chris Eidhof et al.) – S. 28