Wie drei Abstraktionen aus der Kategorientheorie Code transformieren – ohne Mathe-Studium.
1. Das Problem: Verschachtelte None-Checks und try/except-Kaskaden
Ein typisches Problem:
def get_user(user_id: int) -> dict | None:
users = {1: {"name": "Alice", "email": "alice@example.com", "address_id": 42}}
return users.get(user_id)
def get_address(address_id: int) -> dict | None:
addresses = {42: {"city": "Berlin", "zip": "10115"}}
return addresses.get(address_id)
def get_user_city(user_id: int) -> str | None:
user = get_user(user_id)
if user is None:
return None
address_id = user.get("address_id")
if address_id is None:
return None
address = get_address(address_id)
if address is None:
return None
return address.get("city")
Drei verschachtelte None-Checks für eine simple Abfrage. Das skaliert nicht. Was wäre, wenn es ein Muster gäbe, das diese Verschachtelung automatisch handhabt?
2. Funktor: Eine Funktion in einen Kontext heben
Die Intuition
Ein Funktor ist ein Container, der eine map-Operation unterstützt:
zahlen = [1, 2, 3]
verdoppelt = list(map(lambda x: x * 2, zahlen)) # [2, 4, 6]
map nimmt eine Funktion A → B und wendet sie auf jeden Wert innerhalb des Containers an, ohne den Container selbst zu verändern.
"A functor is a mapping between categories. Given two categories, C and D, a functor F maps objects in C to objects in D — it's a function on objects." — Category Theory for Programmers, S. 93
Funktor in Python: Maybe
Ein Maybe-Typ, der entweder einen Wert enthält (Just) oder leer ist (Nothing):
from __future__ import annotations
from dataclasses import dataclass
from typing import TypeVar, Callable, Generic
A = TypeVar("A")
B = TypeVar("B")
@dataclass(frozen=True)
class Maybe(Generic[A]):
_value: A | None
@staticmethod
def just(value: A) -> Maybe[A]:
return Maybe(_value=value)
@staticmethod
def nothing() -> Maybe[A]:
return Maybe(_value=None)
@property
def is_nothing(self) -> bool:
return self._value is None
def map(self, f: Callable[[A], B]) -> Maybe[B]:
if self._value is None:
return Maybe.nothing()
return Maybe.just(f(self._value))
Funktionen auf optionale Werte anwenden, ohne None-Checks:
name = Maybe.just("Alice")
upper = name.map(str.upper) # Maybe.just("ALICE")
length = name.map(len) # Maybe.just(5)
empty = Maybe.nothing()
result = empty.map(str.upper) # Maybe.nothing() – kein Fehler!
Die Funktor-Gesetze
Ein Funktor muss zwei Gesetze erfüllen:
"Aside from facilitating code reuse by bringing in all standard functions of simple types in a more complex context, map allows us to work in a way that is predictable." — Category Theory Illustrated, S. 221
Gesetz 1: Identität – map(id) verändert nichts:
x = Maybe.just(42)
assert x.map(lambda a: a) == x
Gesetz 2: Komposition – Zweimal mappen = einmal mit komposierter Funktion:
f = lambda x: x + 1
g = lambda x: x * 2
x = Maybe.just(5)
assert x.map(f).map(g) == x.map(lambda a: g(f(a)))
"fmap preserves composition: fmap (g . f) = fmap g . fmap f" — Category Theory for Programmers, S. 99
Weitere Funktoren: Result
Der Result-Typ aus Artikel 2 ist ebenfalls ein Funktor:
Ok(42).map(lambda x: x * 2) # Ok(84)
Err("not found").map(lambda x: x * 2) # Err("not found")
Der Fehler wird durchgereicht – kein try/except nötig.
3. Applicative: Mehrere Kontexte kombinieren
Das Problem
Ein Funktor kann eine Funktion auf einen Wert im Kontext anwenden. Aber was bei mehreren Werten?
name = Maybe.just("Alice")
age = Maybe.just(30)
# Wie kombinieren zu Maybe.just(("Alice", 30))?
Applicative: apply
Ein Applicative ist ein Funktor mit apply: eine verpackte Funktion wird auf einen verpackten Wert angewandt:
def create_greeting(name: str) -> Callable[[int], str]:
return lambda age: f"Hallo {name}, du bist {age} Jahre alt!"
greeting = (
Maybe.pure(create_greeting)
.apply(Maybe.just("Alice"))
.apply(Maybe.just(30))
)
# → Maybe.just("Hallo Alice, du bist 30 Jahre alt!")
Wenn einer der Werte Nothing ist:
greeting = (
Maybe.pure(create_greeting)
.apply(Maybe.nothing())
.apply(Maybe.just(30))
)
# → Maybe.nothing() – automatisch, ohne if-Abfrage!
Praxisbeispiel: Parallele Validierung
"Applicatives are similar to monads; but rather than chaining monadic functions in series, an applicative allows you to combine monadic values in parallel." — Domain Modeling Made Functional, S. 225
Während Monaden sequentiell verketten, können Applicatives parallel kombinieren – ideal für Validierung:
@dataclass(frozen=True)
class Validation(Generic[A]):
_value: A | None
_errors: tuple[str, ...]
@staticmethod
def success(value: A) -> Validation[A]:
return Validation(_value=value, _errors=())
@staticmethod
def failure(*errors: str) -> Validation:
return Validation(_value=None, _errors=errors)
def apply(self, other: Validation) -> Validation:
if not self.is_success and not other.is_success:
return Validation.failure(*(self._errors + other._errors))
if not self.is_success:
return Validation.failure(*self._errors)
if not other.is_success:
return Validation.failure(*other._errors)
return Validation.success(self._value(other._value))
result = (
Validation.success(make_user)
.apply(validate_name("")) # ← Fehler 1
.apply(validate_age(-5)) # ← Fehler 2
.apply(validate_email("invalid")) # ← Fehler 3
)
# → Validation(errors=("Name zu kurz", "Ungültiges Alter", "E-Mail ungültig"))
Drei Fehler auf einmal – statt beim ersten abzubrechen.
4. Monad: Verkettete Berechnungen mit Kontext
Das Problem, das Funktor und Applicative nicht lösen
result = get_user(1).map(get_address_id)
# → Maybe.just(Maybe.just(42)) ← verschachtelt!
map liefert Maybe[Maybe[int]] statt Maybe[int].
bind: Die monadische Operation
"A monad is just a programming pattern that allows you to chain 'monadic' functions together in series." — Domain Modeling Made Functional, S. 225
def bind(self, f: Callable[[A], Maybe[B]]) -> Maybe[B]:
if self._value is None:
return Maybe.nothing()
return f(self._value)
Der Unterschied zu map:
- map: nimmt A → B, gibt Maybe[B] zurück
- bind: nimmt A → Maybe[B], gibt Maybe[B] zurück (kein Verschachteln!)
Das Ausgangsproblem – gelöst
city = (
get_user(1)
.bind(get_address_id)
.bind(get_address)
.bind(get_city)
)
# → Maybe.just("Berlin")
city = (
get_user(999) # ← User existiert nicht → Nothing
.bind(get_address_id) # wird übersprungen
.bind(get_address) # wird übersprungen
.bind(get_city) # wird übersprungen
)
# → Maybe.nothing()
Kein einziger None-Check. Die gesamte Fehlerbehandlung steckt in bind.
Die Monad-Gesetze
1. Linke Identität: pure(a).bind(f) == f(a)
2. Rechte Identität: m.bind(pure) == m
3. Assoziativität: m.bind(f).bind(g) == m.bind(lambda x: f(x).bind(g))
5. Die Hierarchie: Funktor → Applicative → Monad
"Every monad is an applicative functor and every applicative functor is a functor, all having their own properties and laws." — Haskell Functional Design and Architecture, S. 110
Jede Monade ist ein Funktor und ein Applicative:
| Situation | Abstraktion | Warum |
|---|---|---|
| Eine Funktion auf einen Wert im Kontext anwenden | Funktor (map) |
Einfachste Ebene |
| Mehrere unabhängige Werte kombinieren | Applicative (apply) |
Alle Fehler sammeln |
| Verkettete Schritte, jeder vom vorherigen abhängig | Monad (bind) |
Sequentielle Abhängigkeiten |
6. Result als Monade: Fehlerbehandlung ohne Exceptions
@dataclass(frozen=True)
class Ok(Generic[A]):
value: A
def map(self, f: Callable[[A], B]) -> Result:
return Ok(f(self.value))
def bind(self, f: Callable[[A], Result]) -> Result:
return f(self.value)
@dataclass(frozen=True)
class Err(Generic[E]):
error: E
def map(self, f: Callable) -> Err[E]:
return self
def bind(self, f: Callable) -> Err[E]:
return self
Praxisbeispiel: User-Registrierung
def register_user(raw_email: str, raw_age: str) -> Result:
return (
parse_email(raw_email)
.bind(lambda email:
parse_age(raw_age)
.bind(lambda age:
create_user(email, age)))
)
register_user("alice@example.com", "30")
# → Ok({"email": "alice@example.com", "age": 30, "status": "active"})
register_user("invalid", "30")
# → Err("Ungültige E-Mail-Adresse")
7. Praxisbeispiel: Daten-Pipeline mit Funktoren und Monaden
Eine Daten-Pipeline, die CSV-Zeilen verarbeitet:
def process_line(line: str) -> Result:
return (
parse_line(line)
.bind(validate_value) # Monad: kann fehlschlagen
.map(normalize) # Funktor: reine Transformation
)
Klare Trennung:
- bind für Schritte, die fehlschlagen können (Parsen, Validieren)
- map für reine Transformationen (Normalisieren)
Selbst ausprobieren
Ein vollständiges, eigenständiges Maybe-Beispiel: map für reine Transformationen, bind für Schritte, die fehlschlagen können. Beachte: Es gibt keinen einzigen None-Check im Pipeline-Code – die Kontext-Behandlung lebt in map/bind.
from dataclasses import dataclass
@dataclass(frozen=True)
class Just:
value: object
def map(self, f):
return Just(f(self.value))
def bind(self, f):
return f(self.value)
@dataclass(frozen=True)
class Nothing:
def map(self, f):
return self
def bind(self, f):
return self
def safe_div(a, b):
return Nothing() if b == 0 else Just(a / b)
def safe_sqrt(x):
return Nothing() if x < 0 else Just(x ** 0.5)
def pipeline(a, b):
return (
safe_div(a, b)
.bind(safe_sqrt) # Monad: kann fehlschlagen
.map(lambda x: round(x, 3)) # Funktor: reine Transformation
)
for a, b in [(16, 4), (10, 0), (-9, 1), (50, 2)]:
print(f"pipeline({a:>3}, {b}) → {pipeline(a, b)}")
Erweiterungs-Idee: Tausche Just/Nothing gegen Ok(value)/Err(reason) aus, sodass der Grund des Fehlers weitergereicht wird.
Zusammenfassung
| Abstraktion | Operation | Signatur | Zweck |
|---|---|---|---|
| Funktor | map |
(A → B) → F[A] → F[B] |
Wert im Kontext transformieren |
| Applicative | apply |
F[A → B] → F[A] → F[B] |
Mehrere Kontexte kombinieren |
| Monad | bind |
(A → F[B]) → F[A] → F[B] |
Kontexte sequentiell verketten |
Was erreicht wurde:
- Kein None-Check-Boilerplate –
Maybe.bind()handhabt das automatisch - Keine try/except-Kaskaden –
Result.bind()reicht Fehler durch - Alle Fehler auf einmal –
Validation.apply()sammelt statt abzubrechen - Klare Pipeline-Struktur –
mapfür reine Transformationen,bindfür fehlbare Schritte
Im nächsten Artikel zeigen wir an einem vollständigen Praxisbeispiel, wie diese Abstraktionen eine if/else-lastige Methode in eine elegante funktionale Komposition transformieren.
Quellen
- Bartosz Milewski: Category Theory for Programmers, S. 93–99, 111, 184–185, 317, 323, 345
- Boris Nikolić: Category Theory Illustrated, S. 202–205, 221
- Scott Wlaschin: Domain Modeling Made Functional, S. 215, 225
- Functional Programming in Kotlin by Tutorials, S. 277, 317, 320, 345, 365
- Alexander Granin: Haskell Functional Design and Architecture, S. 110, 182
Code-Beispiele
src/maybe_functor.py– Maybe als Funktor mit Gesetzensrc/maybe_monad.py– Maybe als vollständige Monade (map, apply, bind)src/result_monad.py– Result-Monade für Fehlerbehandlungsrc/validation.py– Applicative Validation (parallele Fehlersammlung)src/data_pipeline.py– Praxisbeispiel: CSV-Pipeline mit Funktor und Monade