Die fundamentalste Designentscheidung im Rust-Error-Handling ist die Wahl zwischen panic! und Result<T, E>. Diese zwei Werkzeuge sind nicht austauschbar — sie modellieren grundlegend verschiedene Situationen. Panic ist für Zustände, die niemals auftreten sollten (Programmier-Fehler, verletzte Invarianten); Result ist für Zustände, die erwartet werden und vom Aufrufer behandelt werden sollen (I/O-Fehler, Parser-Fehler, Validierungs-Fehler). Wer die Trennung verinnerlicht hat, schreibt robusten Code, der weder fragil (Panic an falscher Stelle) noch unergonomisch (Result, wo es keinen Sinn ergibt) ist.
Die Trennlinie
Die Wahl zwischen panic! und Result ist nicht eine Stil-Frage — sie ist eine semantische Aussage über die Natur des Fehlers. Wenn du falsch wählst, verwirrst du Konsumenten deines Codes und produzierst entweder fragile Programme oder unhandliche APIs.
Panic signalisiert: „Hier ist ein Bug aufgetreten. Der Programm-Zustand ist inkonsistent. Es gibt keine sinnvolle Reaktion außer Programm-Abbruch." Beispiele sind ein Index-out-of-Bounds-Zugriff auf einen Vec, eine unwrap() auf einer None-Option (die laut Code-Logik nicht möglich sein sollte), oder ein assert! in deinem eigenen Code, der eine Invariante verletzt sieht. Diese Fälle sind immer Bugs, die im Code repariert werden müssen — niemals etwas, was der aufrufende Code abfangen sollte.
Result signalisiert: „Hier kann eine Operation aus regulären, erwarteten Gründen scheitern. Der Aufrufer soll entscheiden, was bei einem Fehler zu tun ist." Beispiele sind das Öffnen einer Datei (sie könnte fehlen oder nicht lesbar sein), das Parsen von Nutzer-Input (er könnte ungültig sein), eine Netzwerk-Operation (sie könnte timeouten), eine Datenbank-Abfrage (sie könnte einen Constraint verletzen). Diese Fälle sind keine Bugs — sie sind erwartete Programm-Zustände, mit denen jeder produktive Code umgehen muss.
Eine einfache Heuristik: Wenn du dir vorstellst, ein Aufrufer würde den Fehler mit if let Err(e) = ... oder ? behandeln — und das wäre sinnvoll —, dann ist Result richtig. Wenn die einzige sinnvolle Reaktion wäre „Programm abbrechen und Bug-Report schreiben", dann ist panic richtig.
panic! im Detail
Das panic!-Makro ist das Stdlib-Konstrukt, um einen Panic auszulösen.
fn dividieren(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("Division durch Null! a={a}, b={b}");
}
a / b
}
fn main() {
// dividieren(10, 0); // → Panic mit Stack-Trace
let r = dividieren(10, 2);
println!("{r}"); // 5
}Das panic!-Makro nimmt einen Format-String wie println!, plus optionale Argumente. Bei einem Panic passiert folgendes: die Panic-Nachricht wird auf stderr ausgegeben, eine Stack-Trace wird gerendert (sofern RUST_BACKTRACE=1 gesetzt ist), und der aktuelle Thread beginnt mit dem Stack-Unwinding.
Während des Unwindings werden alle lokalen Werte im Stack-Frame gedroppt — File-Handles werden geschlossen, Mutex-Guards freigegeben, eigene Drop-Impls laufen. So bleibt der Programm-Zustand soweit möglich konsistent, auch wenn der Thread stirbt. Wenn der Unwinding-Prozess den Top-Level-Frame des Threads erreicht und der Thread der Main-Thread war, wird der gesamte Prozess beendet; sonst stirbt nur der Worker-Thread, und der Main-Thread kann ihn per JoinHandle::join als Err empfangen.
Implizite Panics in der Stdlib
Viele Stdlib-Operationen panicken bei Misbrauch:
fn main() {
let v = vec![1, 2, 3];
// let x = v[10]; // Panic: index out of bounds
// let n: Option<i32> = None;
// let m = n.unwrap(); // Panic: unwrap on None
// let r: Result<i32, &str> = Err("x");
// let n = r.unwrap(); // Panic: unwrap on Err
// Sichere Alternativen:
let x = v.get(10); // Option, kein Panic
let n: Option<i32> = None;
let m = n.unwrap_or(0); // Default, kein Panic
}Hier siehst du die zwei Welten nebeneinander. v[10] panickt — der direkte Index-Zugriff geht davon aus, dass du als Programmierer weißt, dass der Index gültig ist. v.get(10) ist die sichere Alternative: sie gibt Option<&T> zurück und verlangt explizite Behandlung. Ähnlich unwrap() (Panic bei None/Err) vs. unwrap_or(default) (Default bei None/Err).
Welche du wählst, hängt vom Kontext ab. Wenn du gerade selbst die Länge des Vecs geprüft hast und der Index garantiert gültig ist, ist v[i] korrekt — eine Panic wäre dann ein echter Bug. Wenn der Index aus unsicherer Quelle (User-Input, Berechnung mit möglichen Edge-Cases) kommt, ist v.get(i) die richtige Wahl.
Unwinding vs. Abort
Beim Panic gibt es zwei mögliche Modi: Unwinding (Default) oder Abort.
Unwinding (Default)
Beim Stack-Unwinding läuft die Panic durch alle Stack-Frames des Threads, dropt jeden lokalen Wert sauber, und endet entweder im main (Prozess-Ende) oder im Spawn-Punkt eines Worker-Threads.
struct Cleanup(&'static str);
impl Drop for Cleanup {
fn drop(&mut self) {
println!("Cleanup: {}", self.0);
}
}
fn arbeitet() {
let _a = Cleanup("a");
let _b = Cleanup("b");
panic!("etwas ging schief");
}
fn main() {
// arbeitet();
// Ausgabe bei Panic mit Unwinding:
// Cleanup: b
// Cleanup: a
// (Stack-Trace)
}Das Unwinding ist wichtig für RAII-basiertes Resource-Management. Auch bei einem Panic werden Files geschlossen, Locks freigegeben, Datenbank-Transaktionen rolled-back. Das macht Rust-Code robust gegen Panics — der Programm-Zustand bleibt konsistent, auch wenn ein Thread abstürzt.
Abort (Performance-Modus)
In Cargo.toml kannst du den Panic-Modus auf abort umstellen:
[profile.release]
panic = "abort"Mit panic = "abort" wird bei einem Panic kein Unwinding gemacht — der Prozess wird sofort beendet, ohne dass Drop-Implementierungen laufen. Vorteile: kleinere Binaries (kein Unwinding-Code), etwas schnellere Hot-Paths (kein Unwinding-Overhead in Funktions-Prologen). Nachteile: keine Ressourcen-Cleanup-Garantie, kein catch_unwind.
Für embedded Systeme oder sehr performance-kritische Anwendungen ist abort oft die richtige Wahl — die Unwinding-Garantie zahlt sich nur aus, wenn dein Code wirklich Cleanup-Logik braucht.
catch_unwind — die seltene Ausnahme
Es gibt einen Weg, Panics doch abzufangen: std::panic::catch_unwind.
use std::panic;
fn main() {
let result = panic::catch_unwind(|| {
println!("vor Panic");
panic!("kontrolliert");
});
match result {
Ok(_) => println!("Closure lief ohne Panic"),
Err(_) => println!("Panic abgefangen — Programm läuft weiter"),
}
}catch_unwind führt eine Closure aus und fängt einen eventuellen Panic ab. Das Ergebnis ist Result<T, Box<dyn Any>> — bei normalem Return das Ergebnis der Closure, bei Panic ein Box mit dem Panic-Payload.
Wichtige Einschränkungen: erstens funktioniert das nur mit Unwinding-Modus, nicht mit Abort. Zweitens ist es nicht für „Exception-artiges" Error-Handling gedacht — es ist eine Notbremse für sehr spezielle Anwendungsfälle: FFI-Boundaries (Panics dürfen nicht über C-Code propagieren — Undefined Behavior), Plugin-Systeme (ein crashender Plugin soll nicht den Host mitreißen), Test-Frameworks (jeder Test soll isoliert sein).
Wenn du catch_unwind für normales Error-Handling nutzt, machst du etwas falsch — du solltest stattdessen Result verwenden.
assert, debug_assert, unreachable
Drei verwandte Makros für defensive Programmierung:
fn berechne(n: i32) -> i32 {
assert!(n > 0, "n muss positiv sein, war {n}");
debug_assert!(n < 1000, "n unerwartet groß: {n}");
match n % 4 {
0 => 100,
1 => 200,
2 => 300,
3 => 400,
_ => unreachable!("n % 4 kann nur 0..=3 sein"),
}
}assert!(condition, message) läuft in jedem Build und panickt, wenn die Bedingung nicht erfüllt ist. Für harte Invarianten, die immer geprüft werden müssen — etwa Vorbedingungen, ohne die die Funktion gar nicht arbeiten kann.
debug_assert!(condition, message) läuft nur im Debug-Build (cargo build), nicht im Release-Build (cargo build --release). Für teure Checks, die in Production-Performance keinen Sinn machen, aber während der Entwicklung Bugs frühzeitig fangen.
unreachable!(message) signalisiert „diese Code-Stelle kann logisch nicht erreicht werden". Wenn sie doch erreicht wird, panickt sie. Ideal in match-Fallback-Armen, wo der Compiler die Exhaustiveness nicht selbst beweisen kann (etwa bei Guards oder bei komplexer Range-Logik).
Alle drei sind Panic-basierte Werkzeuge und damit für Bugs in deinem eigenen Code gedacht — nicht für erwartete Fehler-Situationen.
Entscheidungs-Tabelle
| Situation | panic oder Result? |
|---|---|
| I/O-Operation (Datei, Netzwerk, DB) | Result |
| Parsen von Nutzer-Input | Result |
| Index in bekannt-gültigem Vec | direkt [i] (kein Panic erwartet) |
| Index aus User-Input | get() + Result/Option |
| Verletzte interne Invariante | panic (assert, unreachable) |
| Validierung von Konstruktor-Argumenten | meist Result (Library), panic nur in Prototypen |
| Programmier-Fehler (z. B. doppeltes init) | panic |
| Cleanup nach erwartetem Fehler | Result |
| Verarbeitung externer API-Antwort | Result |
| Stdlib-Routinen mit klarer Vorbedingung | panic bei Verletzung |
Eine wichtige Faustregel: in Library-Code ist Result fast immer richtig — der Library-Autor weiß nicht, wie der Konsument reagieren will, also gibt er ihm die Wahl. In Application-Code ist Panic für klare Bugs angemessen — wenn deine eigene App-Logik konsistent sein soll, ist Panic ein klares Signal.
Praxis: panic vs. Result im echten Code
Konfigurations-Parser
use std::fs;
// Result — der Aufrufer entscheidet
pub fn lade_config(pfad: &str) -> Result<String, std::io::Error> {
fs::read_to_string(pfad)
}
// assert — Library garantiert Vorbedingung
pub fn neue_config(workers: u32) -> AppConfig {
assert!(workers > 0, "workers muss > 0 sein");
assert!(workers <= 1024, "workers > 1024 nicht unterstützt");
AppConfig { workers }
}
# struct AppConfig { workers: u32 }lade_config kann scheitern (Datei fehlt, keine Lese-Rechte) — das ist erwartet, also Result. neue_config mit workers = 0 wäre ein Bug im aufrufenden Code, kein erwarteter Fehler — also Panic via assert.
Diese Mischung ist typisch. Innerhalb einer Library sind beide Werkzeuge zu finden, jeweils dort eingesetzt, wo sie semantisch passen.
Vec-Zugriff mit unterschiedlicher Sicherheit
pub fn drittes_element(v: &[i32]) -> i32 {
// Direkter Index — Panic bei Misuse
assert!(v.len() >= 3, "Vec muss mind. 3 Elemente haben");
v[2]
}
pub fn element_an(v: &[i32], i: usize) -> Option<i32> {
// Sicherer Zugriff — None bei Out-of-Bounds
v.get(i).copied()
}Zwei Funktionen, zwei API-Stile. drittes_element hat eine dokumentierte Vorbedingung (Vec mind. 3 Elemente) und panickt bei Verletzung — schnell, ohne Result-Overhead. element_an ist defensiv und gibt eine Option zurück — der Aufrufer kann sicher mit beliebigen Indices arbeiten.
Welche Form die richtige ist, hängt vom API-Vertrag ab. Wenn der Aufrufer eine Garantie geben kann (etwa weil er die Vec-Länge selbst kontrolliert), ist die Panic-Variante effizienter. Wenn die Indices aus unsicherer Quelle stammen, ist die Option-Variante sicherer.
Network-Operation
use std::io;
pub fn lese_server_status(url: &str) -> Result<u16, io::Error> {
// ... fake-Implementation
if url.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "URL leer"));
}
Ok(200)
}
pub fn validiere_url(url: &str) {
// Vorbedingung — wäre Bug, wenn verletzt
assert!(!url.is_empty(), "URL darf nicht leer sein");
assert!(url.starts_with("http"), "URL muss mit http(s) beginnen");
}lese_server_status ist eine I/O-Operation — sie kann aus tausenden Gründen scheitern (Netzwerk-Probleme, DNS-Fehler, ungültige URL). Result ist der richtige Rückgabe-Typ.
validiere_url ist eine reine Vorbedingungs-Prüfung — wenn der Aufrufer eine ungültige URL übergibt, ist das ein Bug, kein erwarteter Zustand. Panic ist angemessen.
Database-Transaction mit Cleanup
struct Tx { committed: bool }
impl Drop for Tx {
fn drop(&mut self) {
if !self.committed {
println!("ROLLBACK auf Drop");
}
}
}
pub fn execute_query(query: &str) -> Result<u64, &'static str> {
let _tx = Tx { committed: false };
if query.is_empty() {
return Err("leere Query");
}
// Verarbeitung — bei Erfolg fortfahren
Ok(42)
}Bei einem Panic im Body würde der Tx::drop automatisch laufen und ein Rollback ausführen — das ist der Unwinding-Vorteil. Mit panic = "abort" würdest du dieses Cleanup verlieren. Solche Cleanup-kritischen Anwendungen sind ein guter Grund, beim Default-Unwinding-Modus zu bleiben.
Tests mit gezielten Panics
# struct Counter { wert: u32 }
# impl Counter { fn neu() -> Self { Counter { wert: 0 } } fn inkrementieren(&mut self) { self.wert += 1; } }
#[test]
fn counter_startet_bei_null() {
let c = Counter::neu();
assert_eq!(c.wert, 0);
}
#[test]
#[should_panic(expected = "overflow")]
fn counter_panic_bei_overflow() {
let mut c = Counter { wert: u32::MAX };
c.inkrementieren(); // erwarteter Panic
}In Tests sind Panics ein normales Werkzeug. assert_eq! panickt bei Ungleichheit; #[should_panic] markiert Tests, die einen Panic erwarten. Das ganze Test-Framework basiert auf der Tatsache, dass jeder Test in einem eigenen Thread läuft und Panics dort isoliert behandelt werden.
Library-Konstruktor mit Validierung
pub struct Port(u16);
impl Port {
pub fn neu(wert: u16) -> Result<Self, &'static str> {
if wert < 1024 {
return Err("Port < 1024 ist privilegiert");
}
Ok(Port(wert))
}
// Alternative für Prototypen / Tests: panic bei Misuse
pub fn neu_unsafe(wert: u16) -> Self {
assert!(wert >= 1024, "Port < 1024 nicht erlaubt");
Port(wert)
}
}Library-APIs sollten typischerweise Result für Validierungs-Fehler nutzen — der Konsument kann dann entscheiden, ob er den Fehler weiterreicht, einen Default verwendet, oder den Nutzer fragt. Eine Panic-Variante kann zusätzlich angeboten werden (manchmal mit _unchecked-Suffix), aber sollte nicht die einzige Form sein.
Interessantes
Result für erwartete Fehler, panic für Bugs.
Die wichtigste Faustregel im Rust-Error-Handling. Wenn ein Aufrufer den Fehler sinnvoll behandeln kann, ist Result die richtige Wahl. Wenn der „Fehler" ein Code-Bug ist, der gar nicht auftreten dürfte, ist panic angemessen.
panic! macht Stack-Unwinding (Default).
Beim Default-Panic-Modus laufen alle Drop-Implementierungen im Stack — File-Handles schließen, Mutex freigeben, Transaktionen rollback. Diese Cleanup-Garantie ist der Hauptgrund, beim Default zu bleiben.
panic = "abort" für Performance und kleine Binaries.
In Cargo.toml einstellbar. Spart Unwinding-Overhead und Binary-Größe, opfert aber Cleanup-Garantie und catch_unwind. Sinnvoll bei Embedded-Targets oder sehr performance-kritischen Anwendungen.
catch_unwind ist kein normales Error-Handling.
Es ist eine Notbremse für FFI-Grenzen, Plugin-Systeme und Test-Frameworks. Wer es für gewöhnliches Error-Handling nutzt, hat etwas missverstanden — Result ist das richtige Werkzeug.
assert! immer, debug_assert! nur im Debug-Build.
assert! läuft in jedem Build — für kritische Invarianten ohne Performance-Bedeutung. debug_assert! nur im Debug — für teure Checks, die in Production weggespart werden sollen.
unreachable! für „kann nicht passieren“-Code-Stellen.
In Match-Armen, wo der Compiler die Exhaustiveness nicht selbst sehen kann. Wenn der Code doch erreicht wird, ist es ein Bug. Klare Signalisierung der Annahme.
Library-Code: meist Result. Application-Code: gemischt.
Libraries wissen nicht, wie ihre Konsumenten reagieren wollen — also liefern sie Result und lassen die Wahl offen. Applications kennen ihren eigenen Kontext und können entsprechend einsetzen.
Vec[i] panickt; v.get(i) ist sicher.
Direkter Index-Zugriff ist die schnelle Variante mit Panic-Risiko. get() ist die sichere Variante mit Option. Wahl je nach Sicherheit der Index-Quelle.
Weiterführende Ressourcen
Externe Quellen
- The Rust Book – To panic! or Not to panic!
- The Rust Book – Unrecoverable Errors with panic!
- std::panic – Modul-Doc
- std::panic::catch_unwind
- Rust Reference – Panic