Fehlerbehandlung
Fehlerbehandlung in der Praxis
Dieses Kapitel ist fuer Leute gedacht, die nicht zuerst alle Begriffe rund um
Result, Option, throw, %Error{...} und ? sortieren wollen, sondern
vor allem wissen moechten:
"Wie schreibe ich in Antra heute ganz praktisch lesbares Error-Handling?"
Wenn dir die grosse Einordnung wichtiger ist, lies zuerst 11-fehlerstile-und-result-apis.md. Hier geht es bewusster um Alltagsmuster.
Die Kurzversion#
Wenn du nur schnell eine brauchbare Arbeitsregel willst:
- Liefert eine Funktion einen erwartbaren Fehlschlag, nimm meist
%Ok[...]und%Err[...]. - Wenn du Erfolg und Fehler direkt auseinanderziehen willst, nimm
match. - Wenn du nur einen kleinen lokalen Erfolgs-/Fehler-Zweig brauchst, ist oft
if %Ok[v] <- ... { ... } else %Err[msg] { ... }die knappste Form. - Wenn mehrere Result-Schritte linear hintereinander laufen, nimm oft
.^. - Wenn ein Skript oder Test sofort scheitern soll, ist eine
...!-Variante oft passend. - Wenn "nicht vorhanden" kein eigentlicher Fehler ist, ist
Optionoft klarer alsResult.
Das ist fuer viele Faelle schon fast die ganze Geschichte.
Das Grundgefuehl#
In Antra gibt es zwei Hauptwege, mit Fehlschlaegen umzugehen:
- Du gibst einen Wert zurueck, der Erfolg oder Fehlschlag beschreibt.
- Du verlaesst den normalen Kontrollfluss ueber
throw,die(...)oder eine Bang-API.
Der erste Weg ist meist:
%Ok[value]
%Err[error_or_message]Der zweite Weg ist meist:
throw error("kaputt")
die("kaputt")
IO.slurp(path).!Fuer Bibliotheks- und Anwendungslogik ist der Rueckgabewert-Stil fast immer der ruhigere Start.
Erster Schritt: eine normale Result-API#
Stell dir vor, du laedst Konfiguration aus JSON. Der erste saubere Schnitt ist meist:
fn load_config(path) -> {
val text = IO.slurp(path).^
JSON.parse(text)
}Die Idee dabei ist:
IO.slurp(path)liefert%Ok[text]oder%Err[err].^entpackt den Erfolgsfall- bei
%Err[err]verlaesst die Funktion den aktuellen Kontext frueh mit%Err[err]
Der Aufrufer kann danach klar entscheiden:
match load_config("./app.json") {
%Ok[cfg] -> println(cfg.env)
%Err[err] -> println(err.message)
}Das ist oft lesbarer als ein fruehes try/catch, weil der Fehlschlag hier Teil
Der normalen API ist.
Wann match die beste Form ist#
match ist gut, wenn du Erfolg und Fehlschlag direkt lokal behandeln willst.
match JSON.parse(raw_text) {
%Ok[data] -> println(data.user.name)
%Err[err] -> println("JSON ungueltig: " <> err.message)
}Wenn derselbe Fall wirklich nur ein kleiner lokaler Zweiweg ist, ist die
Pattern-Branch-Form mit if %Ok[...] <- ... oft noch lesbarer als ein ganzes
match:
if %Ok[data] <- JSON.parse(raw_text) {
println(data.user.name)
} else %Err[err] {
println("JSON ungueltig: " <> err.message)
}Nimm match, wenn die Verzweigung selbst wichtig ist.
Nimm eher .^, wenn du nur sagen willst:
"Wenn etwas schiefgeht, gib es nach oben weiter."
Wann .^ die beste Form ist#
.^ ist gut fuer lineare Happy-Path-Logik.
fn first_user_name(path) -> {
val text = IO.slurp(path).^
val data = JSON.parse(text).^
val user = Map.fetch(data, :user).^
%Ok[user.name]
}Das liest sich fast wie imperativer Code, bleibt aber im Result-Stil.
Wichtig ist dabei: .^ ist keine reine Result-Sonderform, sondern die
Oberflaeche des Propagate-Protocols.
Praktisch heisst das fuer den Alltag:
%Ok[x].^wird zux%Err[e].^propagiert%Err[e]%Some[x].^wird zux%None[].^propagiert%None[]
Wenn du die volle Hintergrund-Semantik dazu lesen willst, siehe specs/spec_propagation_operator.md.
Result oder Option?#
Diese Unterscheidung spart spaeter viel Reibung.
Nimm Result, wenn der Fehlschlag selbst Information traegt:
- warum etwas ungueltig ist
- warum ein Dateizugriff gescheitert ist
- welche Parse-Stelle kaputt ist
Beispiel:
JSON.parse(text) # -> %Ok[data] | %Err[%Error{...}]Nimm Option, wenn nur zaehlt, ob etwas da ist oder nicht:
- erster Treffer vorhanden oder nicht
- optionales Feld vorhanden oder nicht
- Lookup erfolgreich oder nicht
Beispiel:
head(posts) # -> %Some[post] | %None[]Eine gute Daumenregel ist:
Resultbeantwortet meist: "Hat es geklappt, und falls nicht, warum?"Optionbeantwortet meist: "Gibt es etwas oder nicht?"
Ein kleines Option-Beispiel#
import Antra::Option as Option
fn first_title(posts) -> {
val post = head(posts).^
%Some[post.title]
}Hier ist head(posts) schon ein %Some[...]- oder %None[]-Wert.
Das .^ entpackt also nicht irgendeinen nackten Wert, sondern eine bereits
vorhandene Option.
Wenn du in so einem Fall lokal nur einen nackten Wert oder undef willst,
nimmt man stattdessen .?:
fn first_title_or(posts, fallback) -> head(posts).? // fallbackWann .! sinnvoll ist#
Result-liefernde Funktionen haben nur die weiche Form; die harte Variante wählst
du an der Call-Site mit dem postfixen .!:
Bytes.from_file(path) # %Ok[...] / %Err[...]
Bytes.from_file(path).! # entpackt, oder wirft bei %ErrDas ist sinnvoll, wenn beide Stile legitime Use-Cases haben:
- Bibliotheks- und Pipeline-Code will oft
%Ok[...]/%Err[...] - Skripte, Tests oder kleine CLI-Einstiegspunkte wollen oft fail-fast sein
Die Lesart ist dann:
- ohne
.!: Aufrufer entscheidet selbst weiter - mit
.!: Fehler wird direkt geworfen
Wann try/catch besser passt#
try/catch ist gut, wenn du bewusst im Exception-Stil arbeitest oder einen
Boundary-Schritt kapseln willst.
try {
println(JSON.parse(raw_text).!)
} catch {
%Error{message: msg, ..} -> println("Fehler: " <> msg)
}Das passt oft fuer:
- kleine Skripte
- Kommandozeilen-Tools
- Tests
- Uebergaenge zwischen Bang-APIs und lokaler Behandlung
Fuer normale Bibliothekslogik ist Result meist trotzdem die bessere Default-
Surface.
Zwischen Result und Exceptions wechseln#
In Antra kannst du bewusst zwischen beiden Stilen wechseln.
Von Result in den Exception-Stil:
Result.unwrap!(result)Von Throws in den Result-Stil:
Result.try {
risky!()
}Das ist praktisch, wenn du an einer Stelle bewusst die Surface wechseln willst, aber nicht das ganze Programm.
Ein kompletter kleiner Flow#
Hier ist ein typischer End-to-End-Fall:
import Antra::IO as IO
import Antra::JSON as JSON
fn load_port(path) -> {
val text = IO.slurp(path).^
val cfg = JSON.parse(text).^
Map.fetch(cfg, :port)
}
match load_port("./app.json") {
%Ok[port] -> println("Port: " <> str(port))
%Err[err] -> println("Konfiguration kaputt: " <> err.message)
}Darin stecken schon fast alle alltagswichtigen Regeln:
- Boundary-APIs liefern
Result ?haelt den Happy Path ruhigmatchbehandelt den Fehler dort, wo er fuer den User sichtbar wird
Ein genauso legitimer Skript-Stil#
Wenn du nicht weiterbehandeln willst, sondern sofort abbrechen moechtest, darf es auch absichtlich strenger sein:
import Antra::IO as IO
import Antra::JSON as JSON
val text = IO.slurp("./app.json").!
val cfg = JSON.parse(text).!
println(cfg.env)Das ist nicht "schlechter", sondern nur eine andere Surface.
Die Frage ist nicht moralisch, sondern praktisch:
- Soll der Aufrufer noch sinnvoll entscheiden koennen? Dann eher
Result. - Soll ein kaputter Zustand diesen Pfad sofort beenden? Dann kann
...!passen.
Laufzeitfehler lesen#
Ein Laufzeitfehler nennt die Zeile im Skript. Passiert er in einem Paket — einem eigenen oder der Stdlib —, ist das die Zeile des Aufrufs; darunter steht, wo im Paket es tatsaechlich schiefging:
RUNTIME ERROR (line 2): Type error: String cannot be used with '+' — use num() for explicit conversion
in lib/Antra/Array.ant (line 259)Hier hat Array.sum([1, "x"]) in Zeile 2 ein Element bekommen, das sich nicht
addieren laesst. Liegt der Fehler im eigenen Skript, auch in einem Lambda, das
ein Paket aufruft, fehlt die zweite Zeile: die genannte Zeile ist dann schon
der Ort.
Drei typische Fehlentscheidungen#
Alles sofort werfen#
Wenn jede normale Bibliotheksfunktion sofort throw oder die(...) nutzt,
verlierst du schnell lokale Steuerbarkeit.
Alles als Option modellieren#
Wenn der Grund des Fehlschlags wichtig ist, ist Option zu schwach. Dann fehlt
dem Aufrufer spaeter die Diagnose.
Result und Throw wild mischen#
Schwierig wird es, wenn eine API mal %Err[...] liefert und mal einfach wirft,
ohne dass das im Vertrag klar ist. Besser ist eine bewusste Surface mit
Result- oder Bang-Schwester.
Eine brauchbare Default-Regel#
Wenn du unsicher bist, starte oft mit dieser Reihenfolge:
- Oeffentliche API liefert
%Ok[...]/%Err[...]. - Mehrere lineare Schritte bekommen
?. - Lokale Form-Verzweigung nutzt
match. - Optional kommt spaeter eine
...!-Variante dazu.
Damit landest du in Antra meistens auf einer sehr lesbaren Fehleroberflaeche.
