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

Funktionale Programmierung mit Python – Die Grundlagen

Bild: KI-generiert


Funktionale Programmierung mit Python – Die Grundlagen

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:

  1. Gleicher Input → gleicher Output – immer, ohne Ausnahme
  2. 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

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,
    }

→ 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

📄 Als PDF