Antra

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 Option oft klarer als Result.

Das ist fuer viele Faelle schon fast die ganze Geschichte.

Das Grundgefuehl#

In Antra gibt es zwei Hauptwege, mit Fehlschlaegen umzugehen:

  1. Du gibst einen Wert zurueck, der Erfolg oder Fehlschlag beschreibt.
  2. 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 zu x
  • %Err[e].^ propagiert %Err[e]
  • %Some[x].^ wird zu x
  • %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:

  • Result beantwortet meist: "Hat es geklappt, und falls nicht, warum?"
  • Option beantwortet 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).? // fallback

Wann .! 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 %Err

Das 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 ruhig
  • match behandelt 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:

  1. Oeffentliche API liefert %Ok[...] / %Err[...].
  2. Mehrere lineare Schritte bekommen ?.
  3. Lokale Form-Verzweigung nutzt match.
  4. Optional kommt spaeter eine ...!-Variante dazu.

Damit landest du in Antra meistens auf einer sehr lesbaren Fehleroberflaeche.

Siehe auch#

Quelle dieser Seite