Home Fp Basics Python Artikel 3
Teil 3 Funktionale Programmierung & Kategorientheorie

Funktor, Applicative, Monad – Kategorientheorie für Python-Entwickler

Bild: KI-generiert


Funktor, Applicative, Monad – Kategorientheorie für Python-Entwickler

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ätmap(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:

  1. Kein None-Check-BoilerplateMaybe.bind() handhabt das automatisch
  2. Keine try/except-KaskadenResult.bind() reicht Fehler durch
  3. Alle Fehler auf einmalValidation.apply() sammelt statt abzubrechen
  4. Klare Pipeline-Strukturmap für reine Transformationen, bind fü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

Code-Beispiele

📄 Als PDF