Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kapitel 09.3: Systematische Fehlerbehandlung für Profis und Systemarchitekten

In der professionellen Softwareentwicklung ist die Fehlerbehandlung kein lästiges Anhängsel, sondern ein zentraler Pfeiler der Softwarearchitektur. Ein robustes System zeichnet sich dadurch aus, dass es Fehlerszenarien präzise klassifiziert, Ressourcen auch im Fehlerfall sicher freigibt und dem Entwickler sowie dem Endanwender aussagekräftige Diagnosemöglichkeiten bietet.

Dieses Kapitel richtet sich an fortgeschrittene Rust-Entwickler, die über die bloße Verwendung von match auf Result\<T, E\> hinausgehen wollen. Wir betrachten die Fehlerbehandlung aus der Perspektive des Systemdesigns und etablieren Best Practices in Form von durchnummerierten Empfehlungen (“Items”).


Item 26: Nutze Result für erwartbare Domänenfehler und reserviere panic! für unerwartbare API-Fehlanwendungen

Der wichtigste architektonische Schritt bei der Fehlerbehandlung ist die korrekte Klassifizierung des Fehlers. Rust unterscheidet fundamental zwischen behandelbaren Fehlern (Result\<T, E\>) und unbehandelbaren Ausnahmesituationen (panic!). Die falsche Wahl kann entweder zu instabilen Anwendungen führen (wenn das Programm bei Kleinigkeiten abstürzt) oder den Code mit unnötigem Boilerplate überladen (wenn unmögliche Zustände krampfhaft mit Result abgefangen werden).

Die Alltagsanalogie: Die Restaurantküche

Stellen Sie sich eine professionelle Restaurantküche vor:

  • Der erwartbare Domänenfehler (Result::Err): Ein Gast bestellt ein Tomaten-Risotto, aber der Koch stellt fest, dass die Tomaten aufgebraucht sind. Dies ist ein erwartbares Problem im täglichen Betrieb. Der Koch bricht nicht die Arbeit ab und rennt schreiend aus der Küche. Stattdessen meldet er dem Service-Personal: „Tomaten sind aus!“ (Fehler-Rückgabe). Der Service geht zum Gast und schlägt eine Alternative vor (Fehlerbehandlung).
  • Die unbehandelbare Ausnahmesituation (panic!): Mitten im Service bricht in der Küche die Hauptwasserleitung und die gesamte Elektrik explodiert. In diesem Zustand ist kein sicherer Betrieb mehr möglich. Der Küchenchef ruft die Feuerwehr, evakuiert das Gebäude und schaltet den Strom ab (Notabschaltung/Abort). Niemand versucht jetzt noch, Risotto zu kochen.

Die Faustregel für die Praxis

  1. Nutze Result\<T, E\>, wenn der Fehler durch äußere Umstände oder valide Benutzereingaben entstehen kann. Dazu gehören fehlende Dateien, Netzwerkunterbrechungen, ungültige Benutzereingaben auf der Konsole oder abgelaufene Sessions.
  2. Nutze panic! (oder Hilfsmittel wie assert!, expect()), wenn der Fehler eine Verletzung einer Invariante oder einen Programmierfehler darstellt. Wenn eine API-Funktion dokumentiert, dass der Übergabeparameter niemals 0 sein darf, und der Aufrufer übergibt dennoch 0, dann handelt es sich um einen Bug im aufrufenden Code. Der Aufrufer hat den Vertrag der API gebrochen. Hier ist eine panic! die sauberste Lösung, um den Fehler sofort aufzudecken (Fail-Fast).

Implementierungsbeispiel: Bankkonto-API

Das folgende Beispiel zeigt die Abgrenzung in einer realistischen Bank-Domäne:

/// Repräsentiert ein Bankkonto mit einem Guthaben in Cent.
pub struct Bankkonto {
    kontostand_in_cent: i64,
}

/// Mögliche Domänenfehler bei Transaktionen.
#[derive(Debug, PartialEq)]
pub enum TransaktionsFehler {
    Ueberziehung(i64), // Enthält den Betrag, der gefehlt hat
    UngueltigerBetrag,
}

impl Bankkonto {
    /// Erstellt ein neues Bankkonto mit einem Startguthaben.
    ///
    /// # Panics
    ///
    /// Diese Funktion paniziert, wenn das Startguthaben negativ ist. Ein negatives
    /// Startguthaben verletzt die Systeminvariante eines neu eröffneten Kontos.
    pub fn neu(startguthaben: i64) -> Self {
        // Invariantenprüfung: Ein Programmierfehler liegt vor, wenn ein negatives Startguthaben übergeben wird.
        assert!(
            startguthaben >= 0,
            "Systeminvariante verletzt: Startguthaben darf nicht negativ sein (übergeben: {}).",
            startguthaben
        );

        Bankkonto {
            kontostand_in_cent: startguthaben,
        }
    }

    /// Bucht einen Betrag vom Konto ab.
    ///
    /// Gibt ein `Result` zurück, da eine Überziehung ein erwartbares Ereignis im
    /// Geschäftsbetrieb ist (Domänenfehler).
    pub fn abbuchen(&mut self, betrag: i64) -> Result<i64, TransaktionsFehler> {
        if betrag <= 0 {
            return Err(TransaktionsFehler::UngueltigerBetrag);
        }

        if self.kontostand_in_cent < betrag {
            let fehlbetrag = betrag - self.kontostand_in_cent;
            return Err(TransaktionsFehler::Ueberziehung(fehlbetrag));
        }

        self.kontostand_in_cent -= betrag;
        Ok(self.kontostand_in_cent)
    }
}

fn main() {
    // 1. Behandlung eines erwartbaren Domänenfehlers
    let mut konto = Bankkonto::neu(10_000); // 100,00 €
    
    match konto.abbuchen(15_000) { // Versuch, 150,00 € abzubuchen
        Ok(neuer_stand) => println!("Abbuchung erfolgreich. Neuer Kontostand: {} Cent", neuer_stand),
        Err(TransaktionsFehler::Ueberziehung(fehlend)) => {
            eprintln!("Transaktion abgelehnt: Es fehlen {} Cent auf dem Konto.", fehlend);
        }
        Err(TransaktionsFehler::UngueltigerBetrag) => {
            eprintln!("Transaktion abgelehnt: Der Betrag muss positiv sein.");
        }
    }

    // 2. Demonstration einer bewussten Panic bei Invariantenverletzung
    println!("Versuche Konto mit negativem Guthaben zu erstellen...");
    // Folgende Zeile würde das Programm kontrolliert abstürzen lassen (Panik):
    // let _ungueltiges_konto = Bankkonto::neu(-500);
}

Zeilenweise Code-Erklärung:

  • Zeile 2–4: Wir definieren die Struktur Bankkonto mit einem privaten Feld kontostand_in_cent. Die Kapselung stellt sicher, dass das Guthaben nicht von außen manipuliert werden kann.
  • Zeile 6–11: Der Enum TransaktionsFehler definiert genau die Fehlerfälle, mit denen die aufrufende Anwendung zur Laufzeit rechnen muss.
  • Zeile 19–25: In der Funktion neu verwenden wir assert!. Da ein negatives Startguthaben bei einer Kontoeröffnung logisch unmöglich sein sollte, stufen wir dies als Programmierfehler ein. Der Konstruktor bricht das Programm ab, bevor ein ungültiges Objekt im Speicher entsteht.
  • Zeile 31–41: Die Methode abbuchen gibt ein Result\<i64, TransaktionsFehler\> zurück. Eine Überdeckung des Kontos ist kein Programmfehler, sondern ein geschäftlicher Regelfall. Der Fehler wird sauber an den Aufrufer zurückgegeben, der darauf reagieren kann.

Item 27: Implementiere das standardisierte Fehler-Pattern für eigene Fehlertypen

Wer eigene Bibliotheken schreibt oder große Applikationen strukturiert, muss eigene Fehlertypen definieren. Damit diese nahtlos mit dem Rust-Ökosystem (z. B. Logging-Bibliotheken oder asynchronen Laufzeitumgebungen) zusammenarbeiten, müssen sie drei Bedingungen erfüllen:

  1. Sie müssen das std::fmt::Debug-Trait implementieren (meist über ein einfaches #[derive(Debug)]).
  2. Sie müssen das std::fmt::Display-Trait implementieren, um eine menschenlesbare Fehlermeldung auszugeben.
  3. Sie müssen das Trait std::error::Error implementieren.

Die Alltagsanalogie: Der Einbau-Netzstecker

Stellen Sie sich vor, jeder Hersteller von Haushaltsgeräten würde seine eigenen Steckdosen und Stecker entwerfen. Sie könnten Ihre Kaffeemaschine nicht an der Wand anschließen, ohne einen teuren, proprietären Adapter zu kaufen. In Rust ist das Trait std::error::Error der standardisierte “Schukostecker”. Jedes Tool im Ökosystem weiß, wie man mit einem Typ umgeht, der dieses Trait implementiert. Es erlaubt das Protokollieren des Fehlers, das Abrufen der Fehlerursache (source()) und die Integration in übergeordnete Fehlerketten.

Die manuelle Implementierung des Fehler-Musters

Viele Entwickler greifen sofort zu Crates wie thiserror. Um jedoch zu verstehen, was diese Crates im Hintergrund tun (und um in Umgebungen ohne externe Abhängigkeiten arbeiten zu können), müssen Sie in der Lage sein, das Pattern manuell zu implementieren.

Hier ist die vollständige Implementierung für einen benutzerdefinierten Konfigurationsfehler:

use std::fmt;
use std::error::Error;

/// Repräsentiert Fehler, die beim Laden einer Konfiguration auftreten können.
#[derive(Debug)]
pub enum KonfigurationsFehler {
    DateiNichtGefunden(String),
    UngueltigesFormat(usize), // Zeilennummer des Fehlers
    FehlendeRechte,
}

// 1. Implementierung von Display für die menschenlesbare Ausgabe.
impl fmt::Display for KonfigurationsFehler {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            KonfigurationsFehler::DateiNichtGefunden(pfad) => {
                write!(f, "Die Konfigurationsdatei unter '{}' wurde nicht gefunden.", pfad)
            }
            KonfigurationsFehler::UngueltigesFormat(zeile) => {
                write!(f, "Syntaxfehler in der Konfigurationsdatei in Zeile {}.", zeile)
            }
            KonfigurationsFehler::FehlendeRechte => {
                write!(f, "Unzureichende Leserechte für die Konfigurationsdatei.")
            }
        }
    }
}

// 2. Implementierung des Error-Traits. 
// Seit Rust 1.42.0 haben alle Methoden des Traits Standardimplementierungen,
// sodass ein leerer `impl`-Block ausreicht, sofern es keine zugrundeliegende Ursache gibt.
impl Error for KonfigurationsFehler {
    // Falls unser Fehler durch einen anderen Fehler (z.B. std::io::Error) ausgelöst wurde,
    // könnten wir hier die Methode `source` überschreiben. Da das hier nicht der Fall ist,
    // überlassen wir dies der Standardimplementierung (die `None` zurückgibt).
}

/// Eine Funktion, die simuliert, wie der Fehler erzeugt wird.
fn lade_konfig(pfad: &str) -> Result<String, KonfigurationsFehler> {
    if pfad.is_empty() {
        return Err(KonfigurationsFehler::DateiNichtGefunden(pfad.to_string()));
    }
    
    // Simulierter Formatfehler
    Err(KonfigurationsFehler::UngueltigesFormat(42))
}

fn main() {
    match lade_konfig("") {
        Ok(konfig) => println!("Konfiguration geladen: {}", konfig),
        Err(fehler) => {
            // fmt::Display wird durch '{}' aufgerufen
            eprintln!("Benutzerfreundlicher Fehler: {}", fehler);
            
            // fmt::Debug wird durch '{:?}' aufgerufen (gut für Logdateien)
            eprintln!("Entwickler-Debug-Fehler: {:?}", fehler);
        }
    }
}

Zeilenweise Code-Erklärung:

  • Zeile 5–10: Der Enum KonfigurationsFehler definiert unsere Fehlervarianten. Beachten Sie, dass wir Metadaten anhängen (den Pfad als String oder die Zeilennummer als usize), um die Fehlermeldung so informativ wie möglich zu machen.
  • Zeile 13–27: Wir implementieren fmt::Display. Diese Implementierung bestimmt, wie der Fehler formatiert wird, wenn er dem Endbenutzer angezeigt wird (z. B. auf der Konsole oder in einer Web-UI). Wir nutzen das write!-Makro, um in den übergebenen Formatter zu schreiben.
  • Zeile 33–37: Wir implementieren das Error-Trait für unseren Typ. Durch diesen leeren Block signalisieren wir dem Compiler und allen Bibliotheken, dass KonfigurationsFehler ein vollwertiger Bürger im Fehlerbehandlungssystem von Rust ist.

Item 28: Nutze das From-Trait zur automatischen Fehlerkonvertierung und nahtlosen Propagation mit dem ?-Operator

In der Realität stösst eine Funktion oft auf verschiedene Fehlerarten. Eine Netzwerkfunktion muss beispielsweise IP-Adressen parsen (kann AddrParseError auslösen) und danach eine TCP-Verbindung aufbauen (kann std::io::Error auslösen).

Anstatt jeden dieser Fehler manuell abzufangen und umzuwandeln, nutzt Rust den ?-Operator in Kombination mit dem From-Trait.

Wie der ?-Operator im Hintergrund arbeitet

Wenn Sie den ?-Operator auf ein Result\<T, E\> anwenden, passiert im Erfolgsfall (Ok(wert)) gar nichts: Der Wert wird ausgepackt und das Programm läuft weiter. Im Fehlerfall (Err(fehler)) bricht die Funktion sofort ab und gibt den Fehler zurück.

Der Clou: Rust gibt den Fehler nicht unverändert zurück, sondern wendet im Hintergrund die Methode From::from an. Das bedeutet:

#![allow(unused)]
fn main() {
// Aus diesem Code:
let datei = std::fs::File::open("config.json")?;

// Macht der Compiler im Hintergrund diesen Code:
let datei = match std::fs::File::open("config.json") {
    Ok(val) => val,
    Err(err) => return Err(From::from(err)),
};
}

Wenn die aufrufende Funktion einen Fehlertyp F zurückgibt, und für F das Trait From\<E\> implementiert ist (wobei E der Typ des aufgetretenen Fehlers ist), konvertiert Rust den Fehler völlig geräuschlos und automatisch!

Die Alltagsanalogie: Der Währungswechsler am Kiosk

Stellen Sie sich vor, Sie stehen an einem Kiosk in der Schweiz. Der Kioskbesitzer akzeptiert nur Schweizer Franken (CHF). Sie haben jedoch nur Euro (EUR) und US-Dollar (USD) in der Tasche. Glücklicherweise hat der Kiosk einen automatischen Geldwechsler an der Kasse (das From-Trait). Wenn Sie mit Euro bezahlen (?), nimmt die Kasse Ihre Euros entgegen, wechselt sie automatisch in Franken um und schließt den Bezahlvorgang ab. Sie müssen sich nicht selbst darum kümmern, eine Wechselstube aufzusuchen.

Praktisches Beispiel: Automatisierte Fehlerkonvertierung

Wir schreiben eine Funktion, die eine Zahl aus einer Datei liest und parst. Dabei können zwei unterschiedliche Fehler auftreten: ein I/O-Fehler beim Lesen und ein Parse-Fehler beim Konvertieren des Strings in eine Zahl.

use std::fs::File;
use std::io::{self, Read};
use std::num::ParseIntError;
use std::fmt;
use std::error::Error;

/// Unser vereinheitlichter Fehlertyp für die Anwendung.
#[derive(Debug)]
pub enum AnwendungsFehler {
    Io(io::Error),
    Parsing(ParseIntError),
}

impl fmt::Display for AnwendungsFehler {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AnwendungsFehler::Io(err) => write!(f, "Dateifehler: {}", err),
            AnwendungsFehler::Parsing(err) => write!(f, "Konvertierungsfehler: {}", err),
        }
    }
}

impl Error for AnwendungsFehler {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            AnwendungsFehler::Io(err) => Some(err),
            AnwendungsFehler::Parsing(err) => Some(err),
        }
    }
}

// 1. Automatische Konvertierung von std::io::Error in AnwendungsFehler
impl From<io::Error> for AnwendungsFehler {
    fn from(err: io::Error) -> Self {
        AnwendungsFehler::Io(err)
    }
}

// 2. Automatische Konvertierung von ParseIntError in AnwendungsFehler
impl From<ParseIntError> for AnwendungsFehler {
    fn from(err: ParseIntError) -> Self {
        AnwendungsFehler::Parsing(err)
    }
}

/// Liest den Inhalt einer Datei und parst ihn in eine Zahl.
/// Dank `From` und `?` können wir unterschiedliche Fehlertypen nahtlos in `AnwendungsFehler` umwandeln.
fn lese_und_parse_zahl(pfad: &str) -> Result<i32, AnwendungsFehler> {
    // File::open gibt Result<File, io::Error> zurück.
    // Das '?' konvertiert io::Error automatisch in AnwendungsFehler.
    let mut datei = File::open(pfad)?;

    let mut inhalt = String::new();
    // read_to_string gibt Result<usize, io::Error> zurück.
    // Das '?' konvertiert io::Error automatisch in AnwendungsFehler.
    datei.read_to_string(&mut inhalt)?;

    // inhalt.trim().parse gibt Result<i32, ParseIntError> zurück.
    // Das '?' konvertiert ParseIntError automatisch in AnwendungsFehler.
    let zahl: i32 = inhalt.trim().parse()?;

    Ok(zahl)
}

fn main() {
    match lese_und_parse_zahl("zahl.txt") {
        Ok(zahl) => println!("Erfolgreich gelesene Zahl: {}", zahl),
        Err(AnwendungsFehler::Io(err)) => eprintln!("I/O-Problem aufgetreten: {}", err),
        Err(AnwendungsFehler::Parsing(err)) => eprintln!("Datei enthielt keine gültige Zahl: {}", err),
    }
}

Zeilenweise Code-Erklärung:

  • Zeile 9–12: Wir definieren AnwendungsFehler als Enum, das die Originalfehler (io::Error und ParseIntError) einbettet. So behalten wir den vollen Kontext des ursprünglichen Fehlers bei.
  • Zeile 21–28: Im Error-Trait überschreiben wir die Methode source(). Dies ist ein wichtiges Pattern: Es ermöglicht es Debugging-Werkzeugen, die Kette der Fehlerursachen rückwärts zu verfolgen (Error Chaining).
  • Zeile 31–43: Wir implementieren das From-Trait zweimal: Einmal für io::Error und einmal für ParseIntError. Dadurch weiß der Compiler exakt, wie er diese Typen in unseren AnwendungsFehler überführen kann.
  • Zeile 47–61: In lese_und_parse_zahl nutzen wir dreimal den ?-Operator. Obwohl die drei Operationen (File::open, read_to_string und parse) unterschiedliche Fehlertypen zurückgeben, kompiliert der Code fehlerfrei. Der Compiler führt die Konvertierungen vollautomatisch über unsere From-Implementierungen durch.

Item 29: Verwende funktionale Kombinatoren zur deklarativen Fehlerbehandlung auf Result und Option

Rust ist stark von der funktionalen Programmierung beeinflusst. Das zeigt sich besonders bei den Typen Result\<T, E\> und Option\<T\>. Obwohl imperativer Code mit match oder if let absolut solide ist, führt er bei komplexeren Transformationen oft zu tiefen Verschachtelungen und viel Boilerplate.

Funktionale Kombinatoren erlauben es Ihnen, Daten- und Fehlerpfade deklarativ als Pipelines zu beschreiben.

Die Alltagsanalogie: Das Montage-Fließband

Stellen Sie sich ein Fließband in einer Fabrik vor, das Bauteile verarbeitet:

  • Imperativer Ansatz (match): An jedem Arbeitsschritt nimmt ein Arbeiter das Paket vom Band, öffnet es, prüft, ob das Bauteil fehlerfrei ist. Wenn ja, führt er seine Arbeit aus, verpackt es wieder und legt es aufs Band. Wenn nein, legt er das defekte Bauteil auf ein separates Fehlerband.
  • Funktionaler Ansatz (Kombinatoren): Das Bauteil durchläuft eine Reihe von Stationen auf dem Band, ohne dass es ständig ausgepackt wird. Die Stationen sind so programmiert, dass sie ihre Werkzeuge gar nicht erst ansetzen, wenn das Bauteil als “defekt” markiert ist (es läuft einfach durch). Erst ganz am Ende des Bands entscheidet ein einziger Arbeiter, ob er ein fertiges Produkt entnimmt oder das defekte Teil aussortiert.

Die wichtigsten Kombinatoren im Detail

  1. map: Transformiert den inneren Wert im Erfolgsfall (Ok oder Some), lässt den Fehler- oder Zustandspfad (Err oder None) jedoch völlig unberührt.
  2. and_then: Dient der sequentiellen Verknüpfung von Operationen, die ihrerseits ein Result oder eine Option zurückgeben (entspricht dem monadischen Bind). Verhindert, dass Sie am Ende einen Typ wie Result\<Result\<T, E\>, E\> erhalten.
  3. map_err: Transformiert ausschließlich den Fehlerfall (Err), während der Erfolgsfall (Ok) unverändert durchgereicht wird. Extrem nützlich für die On-the-fly-Anpassung von Fehlertypen.
  4. unwrap_or_else: Gibt den inneren Wert im Erfolgsfall zurück. Im Fehlerfall wird eine Closure ausgeführt, die einen Standardwert dynamisch berechnet. Dies ist performanter als unwrap_or, da der Standardwert nur dann erzeugt wird, wenn er auch wirklich benötigt wird (Lazy Evaluation).

Praktisches Beispiel: Deklarative Pipeline

Das folgende Beispiel zeigt eine Datenverarbeitungskette, die einen rohen String liest, Leerzeichen entfernt, ihn parst, verdoppelt und im Fehlerfall sauber einen Standardwert liefert – ohne ein einziges match.

#[derive(Debug)]
pub enum VerarbeitungsFehler {
    LeerzeichenFehler,
    UngueltigeZahl(String),
}

/// Bereinigt einen Eingabestring. Gibt `None` zurück, wenn der String leer ist.
fn bereinige_eingabe(rohe_daten: &str) -> Option<&str> {
    let bereinigt = rohe_daten.trim();
    if bereinigt.is_empty() {
        None
    } else {
        Some(bereinigt)
    }
}

/// Parst den bereinigten String in eine Zahl.
fn parse_zahl(daten: &str) -> Result<i32, VerarbeitungsFehler> {
    daten.parse::<i32>()
        .map_err(|_| VerarbeitungsFehler::UngueltigeZahl(daten.to_string()))
}

fn verarbeite_daten(rohe_daten: &str) -> i32 {
    // Start der funktionalen Pipeline
    bereinige_eingabe(rohe_daten)
        // Option<T> in ein Result<T, E> konvertieren
        .ok_or(VerarbeitungsFehler::LeerzeichenFehler)
        // Wenn Ok, versuchen wir die Zahl zu parsen (and_then verhindert Verschachtelung)
        .and_then(parse_zahl)
        // Wenn Ok, verdoppeln wir die Zahl (map transformiert den inneren Wert)
        .map(|zahl| zahl * 2)
        // Im Fehlerfall loggen wir den Fehler und liefern den Standardwert 0
        .unwrap_or_else(|fehler| {
            eprintln!("Fehler in der Pipeline: {:?}. Verwende Standardwert 0.", fehler);
            0
        })
}

fn main() {
    // Fall 1: Gültige Eingabe
    let ergebnis_1 = verarbeite_daten("  42  ");
    println!("Ergebnis 1: {}", ergebnis_1); // Ausgabe: 84

    // Fall 2: Ungültige Eingabe (keine Zahl)
    let ergebnis_2 = verarbeite_daten("keine_zahl");
    println!("Ergebnis 2: {}", ergebnis_2); // Ausgabe: 0 (Fehler geloggt)

    // Fall 3: Leere Eingabe
    let ergebnis_3 = verarbeite_daten("    ");
    println!("Ergebnis 3: {}", ergebnis_3); // Ausgabe: 0 (Fehler geloggt)
}

Zeilenweise Code-Erklärung:

  • Zeile 17–20: parse_zahl nutzt .map_err(). Die Methode parse() gibt bei Fehlern einen ParseIntError zurück. Da wir jedoch unseren eigenen Fehlertyp VerarbeitungsFehler erzwingen wollen, nutzen wir .map_err(), um den Originalfehler abzufangen und in unsere Variante UngueltigeZahl zu transformieren.
  • Zeile 25: Wir starten in verarbeite_daten mit einem Option\<&str\>.
  • Zeile 27: .ok_or() ist ein fundamentaler Brückenkopf. Er konvertiert ein Option\<T\> in ein Result\<T, E\>. Wenn die Option Some(val) war, wird daraus Ok(val). Wenn sie None war, wird daraus Err(E).
  • Zeile 29: .and_then() wird verwendet, weil parse_zahl selbst ein Result zurückgibt. Hätten wir hier .map() verwendet, wäre das Ergebnis vom Typ Result\<Result\<i32, VerarbeitungsFehler\>, VerarbeitungsFehler\>. .and_then() flacht dieses Ergebnis sofort wieder ab (Monaden-Flachklopfen).
  • Zeile 31: .map() verdoppelt den Wert. Da dies eine reine In-Memory-Operation ist, die nicht fehlschlagen kann, ist .map() die perfekte Wahl.
  • Zeile 33–36: .unwrap_or_else() beendet die Kette. Es extrahiert den Erfolgs-Wert. Wenn auf dem Weg durch die Pipeline an irgendeiner Stelle ein Fehler aufgetreten ist (sei es bei .ok_or oder bei .and_then), wird die Closure ausgeführt. Der Fehler wird protokolliert und der sichere Standardwert 0 zurückgegeben.

Item 30: Nutze standardisierte Fehler-Crates (thiserror für Bibliotheken und anyhow für Applikationen) zur Reduzierung von Boilerplate

Obwohl die manuelle Implementierung des Fehler-Musters (Item 27 und 28) für das Verständnis essenziell ist, führt sie in der alltäglichen Praxis zu erheblichem Schreibaufwand (Boilerplate-Code). Das Rust-Ökosystem hat sich daher auf zwei herausragende Standard-Bibliotheken geeinigt, die je nach Projektart eingesetzt werden sollten:

CrateHauptfokusTypischer AnwendungsbereichHauptmerkmal
thiserrorPräzise, dedizierte FehlertypenBibliotheken (Libraries), wiederverwendbare ModuleGeneriert Display/Error-Implementierungen via Makros. Maximale Kontrolle für den Aufrufer.
anyhowEinfache, generische FehlerkapselungAnwendungen (Applications), CLI-Tools, Web-ServicesStellt einen dynamischen Fehlertyp anyhow::Error zur Verfügung, der jeden Standardfehler kapseln kann.

Die Alltagsanalogie: Der Ziegelstein vs. Der Müllcontainer

  • thiserror ist wie ein Set aus passgenauen Ziegelsteinen: Wenn Sie eine Bibliothek schreiben, bauen Sie ein Fundament, auf dem andere Entwickler aufsetzen. Der Anwender Ihrer Bibliothek muss genau wissen: Ist dies ein Verbindungsproblem oder ein Berechtigungsfehler? Er braucht diskrete Fehlervarianten, um im Code darauf reagieren zu können.
  • anyhow ist wie ein Schuttcontainer: Wenn Sie eine konkrete Anwendung schreiben (z. B. ein Kommandozeilen-Tool), wollen Sie meistens nur, dass Fehler schnell nach oben gereicht, mit Kontext versehen und dem Benutzer ausgegeben werden. Sie wollen nicht für jeden kleinen Arbeitsschritt ein eigenes Enum-Feld anlegen. Sie werfen alle Fehler in denselben Container (anyhow::Error) und transportieren sie zum Programmende ab.

1. Bibliotheken mit thiserror entwickeln

thiserror bietet ein deklaratives Makro, mit dem Sie die Traits Display und Error direkt an der Definition Ihres Enums implementieren können. Das spart hunderte Zeilen manuellen Code und ist extrem lesbar.

#![allow(unused)]
fn main() {
// HINWEIS: Um diesen Code in einem realen Cargo-Projekt zu nutzen,
// müssen Sie `thiserror = "1.0"` in Ihrer Cargo.toml hinzufügen.
use thiserror::Error;

#[derive(Error, Debug)]
pub enum DatenbankFehler {
    #[error("Die Verbindung zur Datenbank unter '{0}' ist fehlgeschlagen.")]
    VerbindungsFehler(String),

    #[error("Der Eintrag mit der ID {id} existiert nicht.")]
    EintragNichtGefunden { id: u64 },

    // Das #[from]-Attribut generiert automatisch die From-Implementierung!
    #[error("Interner I/O-Fehler der Datenbank.")]
    IoFehler(#[from] std::io::Error),
}
}

Warum das genial ist:

  • Zeile 6 & 9: Das #[error(...)]-Attribut nimmt einen Format-String entgegen. thiserror generiert daraus automatisch die komplette fmt::Display-Implementierung. Sie können Positionsargumente wie {0} oder benannte Felder wie {id} direkt verwenden.
  • Zeile 13: Das #[from]-Attribut generiert im Hintergrund die From\<std::io::Error\>-Implementierung. Tritt in Ihrer Datenbank ein I/O-Fehler auf, konvertiert der ?-Operator diesen vollautomatisch in einen DatenbankFehler::IoFehler.

2. Applikationen mit anyhow strukturieren

In einer Anwendung (z. B. einem Webserver) wollen Sie Fehler oft nur protokollieren und dem Client eine Fehlermeldung schicken. anyhow stellt dafür den Typ anyhow::Result\<T\> zur Verfügung, der eine Abkürzung für Result\<T, anyhow::Error\> ist.

anyhow::Error verhält sich wie ein intelligenter Wrapper um jeden Typ, der std::error::Error implementiert.

// HINWEIS: Erfordert `anyhow = "1.0"` in der Cargo.toml.
use anyhow::{Context, Result};
use std::fs::File;

fn lese_datenbank_passwort() -> Result<String> {
    // 1. anyhow fängt den io::Error ab
    // 2. Mit `.context()` fügen wir dem Fehler wertvolle Metadaten hinzu
    let mut datei = File::open("passwort.txt")
        .context("Konnte die Passwortdatei nicht öffnen.")?;

    let mut passwort = String::new();
    use std::io::Read;
    datei.read_to_string(&mut passwort)
        .context("Fehler beim Lesen des Passwortinhalts.")?;

    Ok(passwort.trim().to_string())
}

fn main() {
    if let Err(err) = lese_datenbank_passwort() {
        // anyhow formatiert den Fehler und gibt die gesamte Kette (inkl. Kontext) aus!
        eprintln!("Schwerwiegender Fehler: {}", err);
        
        // Mit '{:#}' können wir alle zugrundeliegenden Fehlerursachen zeilenweise auflisten
        eprintln!("\nFehler-Details:");
        let mut chain = err.chain();
        while let Some(cause) = chain.next() {
            eprintln!("  Ursache: {}", cause);
        }
    }
}

Warum das genial ist:

  • Zeile 5: Die Funktion gibt ein anyhow::Result\<String\> zurück. Wir müssen keinen eigenen Fehlertyp definieren.
  • Zeile 8–9: Wir rufen .context(...) auf das Result auf. Wenn File::open fehlschlägt, enthält der Fehler nicht nur die kryptische Betriebssystemmeldung „No such file or directory (os error 2)“, sondern zusätzlich unsere Nachricht „Konnte die Passwortdatei nicht öffnen“. Dies macht das Debuggen im produktiven Betrieb extrem viel einfacher.
  • Zeile 24–27: Über err.chain() können wir die Kette der Fehlerursachen komplett durchlaufen und ausgeben.

Zusammenfassung und Checkliste für Ihre Fehlerarchitektur

  1. Domänengrenzen definieren: Verwenden Sie Result für reguläre Geschäftsvorfälle und panic! nur für Programmierfehler, die zur Entwicklungszeit behoben werden müssen.
  2. Standard-Traits einhalten: Wenn Sie eigene Fehlertypen schreiben, stellen Sie sicher, dass diese Debug, Display und Error implementieren.
  3. Konvertierung automatisieren: Nutzen Sie das From-Trait und den ?-Operator, um Ihren Code flach und lesbar zu halten.
  4. Funktional denken: Verwenden Sie Kombinatoren wie .map(), .and_then() und .map_err(), um verschachtelte Kontrollflüsse zu vermeiden.
  5. Crates weise wählen: Nutzen und erzwingen Sie thiserror in wiederverwendbaren Bibliotheken und anyhow in Anwendungen.