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

Algebraische Datentypen & Pattern Matching in Python

Bild: KI-generiert


Algebraische Datentypen & Pattern Matching in Python

Wie dataclass, Enum und match die Domäne so modellieren, dass ungültige Zustände strukturell unmöglich sind.


1. Was sind Algebraische Datentypen?

Es gibt genau zwei Bausteine, aus denen sich alle Datenstrukturen zusammensetzen:

  1. Product Types (UND-Verknüpfung): Ein Wert hat Eigenschaft A und Eigenschaft B
  2. Sum Types (ODER-Verknüpfung): Ein Wert ist entweder Variante A oder Variante B

"The canonical implementation of a product of two types in a programming language is a pair." — Category Theory for Programmers, S. 96

"A choice type like this is called a discriminated union in F#." — Domain Modeling Made Functional, S. 77

Warum "algebraisch"?

Weil sich diese Typen wie Algebra verhalten. Product Types multiplizieren die möglichen Werte, Sum Types addieren sie:

# Product Type: Bool × Bool = 2 × 2 = 4 mögliche Werte
# (True, True), (True, False), (False, True), (False, False)

# Sum Type: Bool + Bool = 2 + 2 = 4 mögliche Werte
# Left(True), Left(False), Right(True), Right(False)

"We've seen two commutative monoidal structures underlying the type system: We have the sum types with Void as the neutral element, and the product types with the unit type, (), as the neutral element." — Category Theory for Programmers, S. 106

Das ist ein Werkzeug, mit dem sich die Anzahl möglicher Zustände exakt kontrollieren lässt.

2. Product Types: Dinge zusammenfassen

Ein Product Type bündelt mehrere Werte zu einem. In Python ist das natürlichste Werkzeug die dataclass mit frozen=True:

from dataclasses import dataclass

@dataclass(frozen=True)
class Customer:
    name: str
    email: str
    age: int

frozen=True macht die Instanz immutable – ganz im Sinne funktionaler Programmierung.

Warum nicht einfach ein Dict?

# ❌ Dict: Keine Garantien
customer = {"name": "Alice", "email": "alice@example.com"}
# Wo ist "age"? Typo in "emial"? Kein Fehler zur Definitionszeit.

# ✅ Dataclass: Struktur ist klar
alice = Customer(name="Alice", email="alice@example.com", age=30)
# Fehlende Felder → TypeError. Tippfehler → sofort sichtbar.

Verschachtelte Product Types

@dataclass(frozen=True)
class Address:
    street: str
    city: str
    zip_code: str

@dataclass(frozen=True)
class CustomerWithAddress:
    name: str
    email: str
    address: Address

Tupel als leichtgewichtige Product Types

from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float

origin = Point(0.0, 0.0)
print(origin.x)  # 0.0

3. Sum Types: Entweder-Oder modellieren

Ein Sum Type sagt: Ein Wert ist genau eine von mehreren Varianten.

Einfache Sum Types mit Enum

from enum import Enum, auto

class Color(Enum):
    RED = auto()
    GREEN = auto()
    BLUE = auto()

Ein Color-Wert ist immer genau einer der drei definierten Werte. Kein vierter Zustand, kein None.

Sum Types mit Daten: Der Payment-Fall

@dataclass(frozen=True)
class CreditCard:
    number: str
    expiry: str
    cvv: str

@dataclass(frozen=True)
class PayPal:
    email: str

@dataclass(frozen=True)
class BankTransfer:
    iban: str
    bic: str

Payment = CreditCard | PayPal | BankTransfer

4. Pattern Matching mit match/case

Python 3.10 hat Structural Pattern Matching eingeführt. Damit lassen sich Sum Types elegant und sicher verarbeiten:

def describe_payment(payment: Payment) -> str:
    match payment:
        case CreditCard(number=num):
            masked = "****" + num[-4:]
            return f"Kreditkarte {masked}"
        case PayPal(email=email):
            return f"PayPal ({email})"
        case BankTransfer(iban=iban):
            return f"Überweisung an {iban}"

"Pattern matching is a natural fit for functional programming." — Functional Anthology, S. 218

Guards: Zusätzliche Bedingungen

def shipping_cost(weight_kg: float) -> float:
    match weight_kg:
        case w if w <= 0:
            raise ValueError("Gewicht muss positiv sein")
        case w if w <= 1.0:
            return 4.99
        case w if w <= 5.0:
            return 7.99
        case w if w <= 20.0:
            return 12.99
        case _:
            return 24.99

Verschachtelte Patterns

@dataclass(frozen=True)
class Order:
    customer: str
    payment: Payment
    amount: float

def validate_order(order: Order) -> str:
    match order:
        case Order(amount=a) if a <= 0:
            return "Ungültiger Betrag"
        case Order(payment=CreditCard(number=num)) if len(num) != 16:
            return "Ungültige Kartennummer"
        case Order(payment=BankTransfer(iban=iban)) if not iban.startswith("DE"):
            return "Nur deutsche IBANs erlaubt"
        case Order(customer=name, amount=amount):
            return f"Bestellung von {name} über {amount:.2f}€ ist gültig"

5. Making Illegal States Unrepresentable

Die mächtigste Idee hinter ADTs:

"Make Invalid States Unrepresentable is a design guideline that suggests modeling the types and data structures in such a way that it's impossible, or at least difficult, to represent a state that is not valid within the domain." — Haskell Pragmatic Type-Level Design, S. 89

❌ Das Problem: Flags und optionale Felder

@dataclass
class EmailContact:
    email: str
    is_verified: bool
    verified_at: str | None = None

Nichts hindert an einem inkonsistenten Zustand:

broken = EmailContact("test@example.com", is_verified=True, verified_at=None)
also_broken = EmailContact("test@example.com", is_verified=False, verified_at="2024-01-01")

✅ Die Lösung: Sum Type statt Flags

"We converted a design with a flag into a design with two choices, one for each state: 'Unverified' and 'Verified.'" — Domain Modeling Made Functional, S. 134

@dataclass(frozen=True)
class UnverifiedEmail:
    email: str

@dataclass(frozen=True)
class VerifiedEmail:
    email: str
    verified_at: str

EmailStatus = UnverifiedEmail | VerifiedEmail

Jetzt kann kein inkonsistenter Zustand existieren: - UnverifiedEmail hat kein verified_at → kann nicht versehentlich gesetzt werden - VerifiedEmail hat immer ein verified_at → kann nicht vergessen werden

Bestellstatus als State Machine

"By using state machines for these cases, each state can have different allowable behavior." — Domain Modeling Made Functional, S. 136

@dataclass(frozen=True)
class Draft:
    items: tuple[str, ...]

@dataclass(frozen=True)
class Submitted:
    items: tuple[str, ...]
    submitted_at: str

@dataclass(frozen=True)
class Paid:
    items: tuple[str, ...]
    submitted_at: str
    paid_at: str
    transaction_id: str

@dataclass(frozen=True)
class Shipped:
    items: tuple[str, ...]
    tracking_number: str

@dataclass(frozen=True)
class Cancelled:
    reason: str

OrderStatus = Draft | Submitted | Paid | Shipped | Cancelled

Jeder Zustandsübergang wird eine Funktion, die nur den richtigen Eingabezustand akzeptiert:

def submit_order(draft: Draft, timestamp: str) -> Submitted:
    if not draft.items:
        raise ValueError("Leere Bestellung kann nicht abgeschickt werden")
    return Submitted(items=draft.items, submitted_at=timestamp)

def pay_order(submitted: Submitted, timestamp: str, txn_id: str) -> Paid:
    return Paid(
        items=submitted.items,
        submitted_at=submitted.submitted_at,
        paid_at=timestamp,
        transaction_id=txn_id,
    )

def cancel_order(order: Draft | Submitted, reason: str) -> Cancelled:
    return Cancelled(reason=reason)

cancel_order akzeptiert nur Draft | Submitted. Eine bezahlte oder versendete Bestellung kann strukturell nicht storniert werden.

6. Result-Type: Fehlerbehandlung ohne Exceptions

Eine der wichtigsten Anwendungen von Sum Types – explizite Fehlerbehandlung:

"While Optional represents a kind of container that can either be empty or contain an object of type T, there might be a case when the container is never empty and contains a value of type A or a value of type B. This data type is Either." — FP in Kotlin by Tutorials, S. 240

from typing import Generic, TypeVar

T = TypeVar("T")
E = TypeVar("E")

@dataclass(frozen=True)
class Ok(Generic[T]):
    value: T

@dataclass(frozen=True)
class Err(Generic[E]):
    error: E

Result = Ok[T] | Err[E]

Anwendung: User-Validierung

def validate_user(name: str, email: str, age: int) -> Ok[User] | Err[ValidationError]:
    if not name.strip():
        return Err(ValidationError("name", "Name darf nicht leer sein"))
    if "@" not in email:
        return Err(ValidationError("email", "Ungültige E-Mail-Adresse"))
    if age < 0 or age > 150:
        return Err(ValidationError("age", "Ungültiges Alter"))
    return Ok(User(name=name.strip(), email=email.lower(), age=age))

def greet_user(result: Ok[User] | Err[ValidationError]) -> str:
    match result:
        case Ok(value=user):
            return f"Willkommen, {user.name}!"
        case Err(error=err):
            return f"Fehler in '{err.field}': {err.message}"

Vorteile gegenüber Exceptions: - Der Rückgabetyp dokumentiert, dass etwas schiefgehen kann - Der Aufrufer muss sich mit dem Fehlerfall beschäftigen - Kein unsichtbarer Kontrollfluss

7. Praxisbeispiel: Formular-Verarbeitung

Alles zusammen – ein Registrierungsformular mit mehreren Validierungsschritten:

@dataclass(frozen=True)
class FormInput:
    username: str
    email: str
    password: str
    password_confirm: str

@dataclass(frozen=True)
class ValidatedForm:
    username: str
    email: str
    password_hash: str

FormError = EmptyField | InvalidEmail | PasswordMismatch | PasswordTooShort

Jede Validierung ist eine Pure Function mit der Signatur str → Ok[str] | Err[FormError]:

def check_not_empty(value: str, field_name: str) -> Ok[str] | Err[EmptyField]:
    if value.strip():
        return Ok(value.strip())
    return Err(EmptyField(field_name))

def check_email(email: str) -> Ok[str] | Err[InvalidEmail]:
    if "@" in email and "." in email.split("@")[-1]:
        return Ok(email.lower())
    return Err(InvalidEmail(email))

def check_passwords_match(pw: str, confirm: str) -> Ok[str] | Err[PasswordMismatch]:
    if pw == confirm:
        return Ok(pw)
    return Err(PasswordMismatch())

Was wurde erreicht?

  1. Jeder mögliche Fehler hat einen eigenen Typ – kein generisches ValueError("irgendwas")
  2. Alle Validierungen sind Pure Functions – testbar, komposierbar, ohne Seiteneffekte
  3. Pattern Matching erzwingt Vollständigkeit – jeder Fehlerfall muss behandelt werden
  4. Keine Exceptions – der Kontrollfluss ist immer sichtbar

Selbst ausprobieren

from dataclasses import dataclass

@dataclass(frozen=True)
class Ok:
    value: object

@dataclass(frozen=True)
class Err:
    error: str

def parse_age(raw: str) -> Ok | Err:
    if not raw.strip():
        return Err("Alter darf nicht leer sein")
    if not raw.isdigit():
        return Err(f"'{raw}' ist keine Zahl")
    age = int(raw)
    if age < 0 or age > 150:
        return Err(f"Ungültiges Alter: {age}")
    return Ok(age)

def describe(result: Ok | Err) -> str:
    match result:
        case Ok(value=age):
            return f"OK – Alter: {age}"
        case Err(error=msg):
            return f"Fehler: {msg}"

for raw in ["42", "", "abc", "200", "0"]:
    print(f"{raw!r:>6} → {describe(parse_age(raw))}")

Versuche, parse_age um eine zusätzliche Regel zu erweitern (z. B. Mindestalter 18) – ohne die Signatur zu brechen.

Welche Aussage über algebraische Datentypen (ADTs) und Pattern Matching in Python ist *falsch*?

Zusammenfassung

Konzept Python-Werkzeug Zweck
Product Type @dataclass(frozen=True) Daten bündeln (UND)
Sum Type X \| Y \| Z (Union) Varianten modellieren (ODER)
Pattern Matching match/case Sum Types sicher verarbeiten
Enum enum.Enum Einfache endliche Mengen
Result Type Ok[T] \| Err[E] Explizite Fehlerbehandlung

Die zentrale Erkenntnis: Wenn die möglichen Zustände der Daten mit ADTs eingeschränkt werden, verschwinden ganze Kategorien von Bugs.


Im nächsten Artikel geht es den entscheidenden Schritt weiter: Die Datenstrukturen sind Funktoren und Monaden – und das erlaubt, Komplexität aus dem fachlichen Code in die Datenstrukturen selbst zu verlagern.


Quellen

Code

📄 Als PDF