Wie Programme funktionieren (1b): Fehlerbehandlung, Result-Typ und Ressourcen
Track Konzepte · Schicht 1: Sprachen und Laufzeit · ca. 50 Min.
Worum es geht
Eine Zeile in einer CSV-Datei ist kaputt. Soll das ganze Programm abstürzen, soll die Zeile übersprungen werden, oder soll der Fehler als Ergebnis zurückkommen? Und was passiert mit der Datei, die gerade offen ist, wenn mittendrin eine Exception fliegt? Das sind die zwei Fragen dieses Teils: Fehlerbehandlung (error handling) mit Exception, Result-Typ und Fail Fast, und Ressourcen (resource) mit Kontextmanager (context manager).
Was du aus Teil a brauchst: Du weißt, dass eine Funktion nur eine Kopie der Referenz bekommt und dass Objekte auf dem Heap leben, solange eine Referenz darauf zeigt. Teil a: Wert und Referenz, Seiteneffekte, Stack und Heap.
Am Ende von Teil b kannst du:
- einen Result-Typ (Ok/Err) selbst bauen und Funktionen damit verketten (Übung 1),
- begründen, wann Fail Fast mit Exception richtig ist und wann ein Result besser passt (Übung 2),
- Ressourcen mit einem Kontextmanager so freigeben, dass auch bei einer Exception nichts offen bleibt (Übung 3).
Plane ehrlich 50 Minuten ein: etwa 20 Minuten Lesen, 27 Minuten Übungen (drei Stück). Die vier Paradigmen am Ende sind nur ein kurzer Überblick.
Von JS/TS her gedacht
| Frage | JS/TS | Python |
|---|---|---|
| Fehler melden | throw new Error(...) |
raise ValueError(...) |
| Fehler fangen | try { } catch (e) { } |
try: ... except ValueError as e: |
| Aufräumen, auch bei Fehler | finally |
finally oder with |
| Result-Typ | selbst gebaut ({ ok, value }) oder Bibliothek |
selbst gebaut (zwei Klassen, siehe Schritt 1) |
| Ressourcenverwaltung per Syntax | using in neueren TypeScript-Versionen (bitte prüfen) |
with (Kontextmanager) |
Beide Sprachen haben Exceptions und finally. Der Unterschied liegt im Gebrauch: Python nutzt Exceptions auch für normale Abläufe (StopIteration beendet jede Schleife), und with ist dort das übliche Mittel für Ressourcen.
Konzept
Schritt 1: Fehlerbehandlung: Exception, Result, Fail Fast
Zwei Grundstile, Fehler zu melden:
- Exception (Ausnahme): Der Fehler fliegt aus dem normalen Ablauf heraus (
raise/throw) und wandert den Stack hoch, bis jemand ihn fängt. Vorteil: Der Erfolgspfad bleibt lesbar, und unbehandelte Fehler fallen laut auf (Stack Trace). Nachteil: Man sieht der Signatur nicht an, was schiefgehen kann. - Result-Typ: Die Funktion liefert den Fehler als normalen Wert zurück, entweder
Ok(wert)oderErr(fehler). Vorteil: Fehler sind im Rückgabetyp sichtbar, und der Aufrufer muss mit ihnen umgehen (in Sprachen mit Typprüfung, die das erzwingen, z. B. Rust). In Python erzwingt nichts, dass jemand das Ergebnis prüft.
Fail Fast heißt: Bei einem Zustand, der nicht sein darf, sofort und laut abbrechen, statt mit kaputten Daten weiterzumachen. Ein Fehler, der früh und mit klarer Meldung auffällt, ist billig. Derselbe Fehler, der erst drei Schritte später als falsche Zahl in einem Report auftaucht, ist teuer.
Ein Result-Typ in JS (Node), mit Verkettung. Beim ersten Fehler wird der Rest übersprungen:
const ok = v => ({ ok: true, value: v });
const err = e => ({ ok: false, error: e });
const parseAlter = s => Number.isInteger(+s) && s.trim() !== "" ? ok(+s) : err("keine Zahl: " + s);
const volljaehrig = n => n >= 18 ? ok(n) : err("zu jung: " + n);
const kette = s => { const r = parseAlter(s); return r.ok ? volljaehrig(r.value) : r; };
console.log(kette("30"), kette("abc"), kette("12"));Ausgabe:
{ ok: true, value: 30 } { ok: false, error: 'keine Zahl: abc' } { ok: false, error: 'zu jung: 12' }
In Python als zwei einfache Klassen. Zwei Methoden reichen: abbilden(f) (englisch map) wendet f auf den Wert an und lässt einen Fehler unberührt. und_dann(f) (englisch and_then oder flat_map) ruft f auf, das selbst ein Result liefert, und lässt einen Fehler ebenfalls unberührt:
Ausgabe: Ok(31) Err("keine Zahl: 'abc'") Err('zu jung: 12'). Lies die Kette lese_alter(text).und_dann(volljaehrig).abbilden(...) als Fließband: Jede Station bekommt den Wert und gibt entweder Ok weiter oder meldet Err. Ab dem ersten Err fahren alle weiteren Stationen leer durch.
Das ist das gemeinsame Muster hinter drei Dingen, die du schon kennst. Option (ein Wert oder nichts, wie ?. und ?? in TS), Result (Wert oder Fehler) und Promise (Wert oder Fehler, später) sind alle Container, in denen man Funktionen aneinanderkettet, und die Kette kümmert sich um den Sonderfall. Das ist alles, was hinter dem gefürchteten Wort “Monade” (monad) steckt: ein Container plus und_dann. promise.then(f) ist und_dann und abbilden in einem.
Eine Entscheidungshilfe, welcher Stil wann passt (das ist ein Trade-off, kein Gesetz):
| Situation | Besser |
|---|---|
| Ein Fehler ist ein Bug oder ein verletzter Vertrag (negative Menge aus eigenem Code), er sollte nie vorkommen | Exception, Fail Fast |
| Ein Fehler ist erwartbar und Teil der Fachlogik (Eingabe ungültig, Datei fehlt, Zeile kaputt) | Result (der Fehler ist normaler Rückgabewert) |
| Der Prozess kann ohne die Sache nicht sinnvoll laufen (Pflicht-Konfiguration beim Start fehlt) | Exception, Fail Fast, beim Start |
| Viele Einzelfälle sammeln, den Rest weiter verarbeiten | Result pro Fall, am Ende berichten |
In der Praxis mischt man beides: Result in der Fachlogik am Rand, Exception für Programmierfehler.
Schritt 2: Ressourcen und Lebenszeiten, kontrollierte Freigabe
Eine Ressource (resource) ist alles, was knapp ist und freigegeben werden muss: Dateihandles, Datenbankverbindungen, Sockets, Sperren (locks). Wer sie nicht freigibt, hat einen Leak (Ressourcenleck), und irgendwann ist die Datenbank “voll”, obwohl niemand etwas tut. Die Gefahr liegt beim Fehlerfall: Der Code gibt zwar am Ende frei, aber ein Fehler überspringt das Ende.
In JS kennst du das Muster mit try/finally. Hier ein Zähler als Stellvertreter einer offenen Ressource (Node):
function leseManuell(zeilen, offen) {
offen.push("a");
for (const z of zeilen) if (z === "") throw new Error("leere Zeile");
offen.pop();
}
function leseSicher(zeilen, offen) {
offen.push("a");
try {
for (const z of zeilen) if (z === "") throw new Error("leere Zeile");
} finally {
offen.pop();
}
}
const o1 = [], o2 = [];
try { leseManuell(["x", ""], o1); } catch (e) {}
try { leseSicher(["x", ""], o2); } catch (e) {}
console.log(o1.length, o2.length);Ausgabe: 1 0. Die manuelle Version lässt eine Ressource offen, die finally-Version nicht. finally läuft immer, auch bei Exception. In Python zeigt dasselbe die Simulation einer Datei, die sich bei jedem Öffnen in eine Liste einträgt:
Ausgabe: 1. Ein Leak. Die saubere Lösung ist ein Kontextmanager (context manager), den du mit with benutzt. Er garantiert: Beim Verlassen des Blocks, egal wie, wird aufgeräumt. Es gibt zwei Schreibweisen. Mit einem Generator und @contextmanager, try/finally rund um das yield:
Ausgabe: Fehler: leere Zeile und 0. Die Exception läuft weiter nach oben, aber die Ressource ist zu. Der Code vor yield ist das Öffnen, yield d gibt dem with-Block die Ressource (das ist der Wert hinter as), der Code im finally ist das Aufräumen. Achte auf die Stelle, an der das Öffnen steht: d = Datei(name) steht vor dem try. Würde das Öffnen selbst fehlschlagen, gäbe es nichts zu schließen. Steht es im try, läuft finally mit einer nie angelegten Variablen.
Die zweite Schreibweise ist eine Klasse mit __enter__ und __exit__:
Ausgabe: 1 und 0. __exit__ bekommt die Exception (falls eine fliegt) und entscheidet mit dem Rückgabewert, ob sie verschluckt wird. Fast immer: False.
Der Gedanke dahinter heißt RAII (Resource Acquisition Is Initialization, Ressourcenbesitz an die Lebenszeit eines Objekts oder Blocks binden), bekannt aus C++ und Rust: Eine Ressource gehört einem Objekt, und wenn der Scope des Objekts endet, wird sie freigegeben. Python hat das nicht automatisch (der Zeitpunkt, wann ein Objekt stirbt, ist nicht garantiert, siehe Teil a, Schritt 3), darum gibt es with. Die Finalizer (__del__) laufen “irgendwann” und nicht zuverlässig, du stützt dich nie darauf. In JS gibt es mit try/finally dasselbe Prinzip. In neueren TypeScript-Versionen gibt es die Schlüsselwörter using und await using für explizite Ressourcenverwaltung (bitte prüfen, welche TypeScript-Version und Laufzeit du einsetzt, hier nicht ausgeführt).
Kurz: die vier Paradigmen
Paradigmen (paradigms) sind Denkstile, keine Sprachen. Die meisten Sprachen mischen sie:
| Paradigma | Kernidee | Beispiel |
|---|---|---|
| Imperativ (imperative) | Schritt für Schritt Zustand ändern | Schleife mit i += 1 |
| Objektorientiert (object-oriented) | Daten und Verhalten in Objekten bündeln, Zustand kapseln | Klassen mit Methoden |
| Funktional (functional) | reine Funktionen, unveränderliche Daten, Funktionen als Werte | map, filter, reduce |
| Deklarativ (declarative) | beschreiben, was herauskommen soll, nicht wie | SQL, CSS, JSX in React |
Die Lektion (mit Teil a) war im Kern funktionale Hygiene (Reinheit, Immutability, Result) in einer imperativen und objektorientierten Sprache. Das ist die übliche Mischung.
Falle
- Ergebnis nicht prüfen. In Python zwingt dich nichts, ein
Errzu behandeln. Wer ein Result zurückgibt, riskiert, dass der Fehler stumm ignoriert wird. Bei echten Bugs ist die laute Exception ehrlicher (Übung 2). - Freigabe am Ende der Funktion statt in
finally. Der Fehlerfall überspringt das Ende (Übung 3). __del__als Aufräumhilfe. Der Zeitpunkt ist nicht garantiert.
Übungen
Übung 1: Result-Typ benutzen: Bestellzeilen prüfen (ca. 10 Min.)
Eine Bestellzeile kommt als Text: artikel;menge;cent, z. B. kabel;2;150 (Menge 2, Stückpreis 150 Cent). Schreibe bearbeite(zeile), die ein Result liefert und nie eine Exception wirft:
- Erfolg:
Ok(gesamt)mitgesamt = menge * cent. - Fehler:
Err(text), wobei der Fehlertext (Groß und Kleinschreibung egal) das passende Stichwort enthält:format: die Zeile hat nicht genau drei Felderzahl: Menge oder Preis ist keine ganze Zahlmenge: Menge ist 0 oder negativpreis: Preis ist negativ (0 ist erlaubt)
- Fail Fast in der Kette: Es zählt immer der erste fehlgeschlagene Schritt in der Reihenfolge Format, Zahl, Menge, Preis. Bei
kabel;0;-5ist esmenge, beikabel;-1;xist eszahl.
Beispiele: bearbeite("kabel;2;150") gibt Ok(300), bearbeite("kabel;2") gibt einen Err mit “format”. Ok und Err aus Schritt 1 (mit abbilden und und_dann) stehen bereit. Verkette kleine Schritt-Funktionen mit und_dann. Du darfst oberhalb von bearbeite Hilfsfunktionen schreiben. Die letzte Zeile gibt bearbeite zurück.
Jeder Prüfschritt ist eine kleine Funktion, die einen Wert nimmt und ein Ok mit dem (ggf. umgewandelten) Wert oder ein Err liefert. Welche Methode verbindet zwei solche Schritte, und welche wandelt nur am Ende den Wert um, ohne selbst fehlschlagen zu können? Wo muss int(...) stehen, damit keine Exception herausfliegt?
def zerlegen(zeile):
teile = zeile.split(";")
if len(teile) != 3:
return Err(f"Format: erwartet 3 Felder, gefunden {len(teile)}")
return Ok(teile)
def zahlen(teile):
name, menge, cent = teile
try:
return Ok((name, int(menge), int(cent)))
except ValueError:
return Err("Zahl: Menge und Preis müssen ganze Zahlen sein")
def pruefen(t):
name, menge, cent = t
if menge <= 0:
return Err("Menge: muss größer als 0 sein")
if cent < 0:
return Err("Preis: darf nicht negativ sein")
return Ok(t)
def bearbeite(zeile):
return (zerlegen(zeile)
.und_dann(zahlen)
.und_dann(pruefen)
.abbilden(lambda t: t[1] * t[2]))
bearbeiteund_dann verbindet Schritte, die selbst fehlschlagen können. abbilden wandelt den Wert am Ende um. Nach dem ersten Err werden alle weiteren Schritte übersprungen, das ist das Fail-Fast-Verhalten der Kette.
Übung 2: Fail Fast oder Result? Drei Fälle (ca. 7 Min.)
Wähle in jedem Fall den Ansatz, der zu den genannten Randbedingungen am besten passt. Gib ein Tupel mit drei Buchstaben zurück, z. B. ("A", "B", "A").
Fall 1: Ein Service braucht beim Start die Umgebungsvariable DB_URL. Fehlt sie, kann er nie etwas Sinnvolles tun, und jeder Standardwert würde auf die falsche Datenbank zeigen. Er läuft in einem Container, den ein Orchestrator bei einem Absturz neu startet und im Log meldet.
- A:
Noneweiterreichen und den Fehler erst bei der ersten Anfrage bemerken - B: Beim Start sofort mit klarer Meldung abbrechen
- C: Einen Standardwert wie
localhosteinsetzen und eine Warnung loggen - D: Ein
Errzurückgeben und den Service ohne Datenbank weiterlaufen lassen
Fall 2: Ein Import liest 20000 Kundenzeilen aus einer fremden CSV-Datei. Erfahrungsgemäß ist etwa jede hundertste Zeile kaputt. Die guten Zeilen sollen trotzdem importiert werden, und am Ende soll ein Bericht jede kaputte Zeile mit dem Grund auflisten. Fehler sind hier der Normalfall.
- A: Die erste kaputte Zeile wirft eine Exception, und der Import bricht ab
- B: Kaputte Zeilen liefern
None, und am Ende wird ihre Anzahl gezählt - C: Fehler mit
except: passverschlucken und weiterlaufen - D: Jede Zeile liefert
OkoderErrmit Grund, die Ergebnisse werden gesammelt
Fall 3: Eine interne Funktion buche(betrag_cent) bekommt aus dem eigenen Code einen negativen Betrag. Das kann nur durch einen Programmierfehler entstehen, nicht durch Benutzereingaben. Der Fehler soll auffallen, bevor Geld bewegt wird, und nicht still übersehen werden können. Du schreibst Python, und kein Typprüfer erzwingt, dass ein Aufrufer Rückgabewerte prüft.
- A: Eine
ValueErrorwerfen, sodass der Aufruf laut scheitert - B: Ein
Errzurückgeben, jeder Aufrufer entscheidet selbst - C: Den Betrag auf 0 setzen und trotzdem buchen
- D:
Nonezurückgeben und den Vorfall protokollieren
Frage dich pro Fall: Ist der Fehler ein Normalfall der Fachlogik oder ein Bug beziehungsweise ein unmöglicher Zustand? Und: Wer sieht den Fehler wann, und kann er übersehen werden?
antwort = ("B", "D", "A")
antwortFall 1: Fail Fast beim Start, der Orchestrator meldet den Absturz sofort. Fall 2: Result pro Zeile, der Fehler ist Normalfall und wird gesammelt. Fall 3: Exception, weil ein Bug laut auffallen muss und ein Err in Python ungeprüft bleiben kann.
Übung 3: Verbindungs-Leak reparieren (ca. 10 Min.)
Eine Anwendung leiht sich Verbindungen aus einem Pool (Vorrat) mit begrenzter Größe. kopiere sendet Zeilen über eine Verbindung. Bei einer kaputten Zeile fliegt eine Exception, und schliessen() wird nie erreicht: Die Verbindung bleibt offen. Nach ein paar Fehlern ist der Pool leer (PoolErschoepft), obwohl niemand mehr arbeitet.
Der Pool ist eine Simulation, die bereitsteht:
pool.oeffne()gibt eineVerbindungzurück. Ist der Pool voll, wirft esPoolErschoepft.verbindung.sende(zeile)sendet eine Zeile. Die Zeile"kaputt"wirftValueError.verbindung.schliessen()gibt die Verbindung zurück an den Pool. Ein zweitesschliessen()wirftRuntimeError.pool.offenzählt die offenen Verbindungen.
Deine Aufgabe in zwei Teilen:
- Schreibe einen Kontextmanager
offene_verbindung(pool)mit@contextmanager, der eine Verbindung öffnet, sie an denwith-Block liefert (yield) und sie immer schließt, auch bei einer Exception. Die Exception selbst darf nicht verschluckt werden. Wennoeffne()selbst fehlschlägt, bleibtPoolErschoepftunverändert (es gibt nichts zu schließen). - Schreibe
kopiere(pool, zeilen)so um, dass sieoffene_verbindungbenutzt, alle Zeilen sendet undlen(zeilen)zurückgibt.
Die letzte Zeile gibt beide zurück: (offene_verbindung, kopiere).
Was läuft garantiert, egal ob der Block normal endet oder eine Exception fliegt? Und an welcher Stelle muss das Öffnen stehen, damit es bei einem Fehler im Öffnen nichts zu schließen gibt?
from contextlib import contextmanager
@contextmanager
def offene_verbindung(pool):
v = pool.oeffne()
try:
yield v
finally:
v.schliessen()
def kopiere(pool, zeilen):
with offene_verbindung(pool) as v:
for zeile in zeilen:
v.sende(zeile)
return len(zeilen)
(offene_verbindung, kopiere)pool.oeffne() steht vor dem try: Schlägt es fehl, gibt es nichts zu schließen. Das finally läuft bei normalem Ende und bei Exception, die Exception fliegt danach weiter. Das manuelle schliessen() in kopiere entfällt, sonst würde die Verbindung doppelt geschlossen.
Merksatz
Echte Bugs fliegen laut als Exception, erwartbare Fehler kommen als Wert (Result) zurück, und Ressourcen werden mit with oder finally freigegeben, nicht mit Hoffnung auf den Garbage Collector.
Prüfstein
- Wann nimmst du eine Exception und Fail Fast, wann ein Result? Begründe an einem Beispiel.
- Warum reicht “am Ende der Funktion schließen” nicht, und was löst
with(oderfinally)?
Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “1. Wie Programme funktionieren” (Fehlerbehandlung: Exceptions vs. Result-Typen, Fail Fast, Paradigmen; Prüfstein zu Exception und Result). quellen/konzeptuebersicht-software-fortgeschritten.docx, Abschnitt “1. Sprachen, Typen und Laufzeit” (Option/Result/Promise als gemeinsames Muster; Ressourcen und Lebenszeiten: Ownership, RAII, Leaks, Finalizer, kontrollierte Freigabe).
Über die Quelle hinaus (allgemeines Fachwissen): die Aussagen zu __del__ als unzuverlässigem Finalizer, der Hinweis auf using in TypeScript (bitte prüfen), die Entscheidungstabelle Exception gegen Result, die Paradigmen-Tabelle und die Aussage zu StopIteration in Schleifen. Alle Zahlen und Ausgaben in dieser Lektion stammen aus dem Ausführen des Codes mit Python 3 (lokal 3.13.9) bzw. Node (20).