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

Praxisteil & Übungen: Robuste Fehlerbehandlung und eigene Fehlertypen

In diesem Praxisteil lernen wir, wie wir in Rust Programme schreiben, die unvorhergesehene Fehler (wie fehlende Dateien oder ungültige Benutzereingaben) elegant abfangen und verarbeiten, ohne abzustürzen. Wir verabschieden uns von unwrap() und bauen stattdessen ein professionelles, stabiles Fehler-Handling auf.


1. Praxis-Szenario: Konfigurationsdatei-Parser für ein Logistikterminal

Wir entwickeln ein Modul für ein Frachtterminal im Hafen. Das Modul muss eine Konfigurationsdatei (terminal_config.txt) einlesen. Diese Datei enthält wichtige Parameter, zum Beispiel die maximale Traglast eines Krans als Ganzzahl.

Beim Einlesen der Konfiguration können typische Fehler auftreten:

  1. Die Datei existiert überhaupt nicht (Eingabe-/Ausgabe-Fehler).
  2. Die Datei ist leer oder kann nicht gelesen werden.
  3. Die maximale Traglast in der Datei ist keine gültige Ganzzahl (Parsing-Fehler).

Wir wollen diese unterschiedlichen Fehler in einem einzigen, maßgeschneiderten Fehlertyp (TerminalError) bündeln. Auf diese Weise kann der Aufrufer unserer Funktion präzise auf die verschiedenen Fehlerszenarien reagieren.


2. Strukturierte Praxis-Einheiten

2.1 Option vs. Result

Rust verzichtet bewusst auf das Konzept von Ausnahmen (Exceptions), wie man sie aus Java, C++ oder Python kennt. Stattdessen nutzt Rust zwei Enums, um das Fehlen von Werten oder das Auftreten von Fehlern explizit im Typensystem abzubilden:

  • Option<T>: Repräsentiert das optionale Vorhandensein eines Wertes. Es gibt entweder einen Wert (Some(T)) oder keinen (None).
  • Result<T, E>: Repräsentiert das Ergebnis einer Operation, die fehlschlagen kann. Es liefert entweder den Erfolgsfall (Ok(T)) oder eine Fehlerursache (Err(E)).

Die Analogie: Die Pralinenschachtel vs. das Postpaket

  • Option<T>: Eine Schachtel Pralinen. Sie öffnen sie voller Vorfreude. Entweder ist eine leckere Praline darin (Some), oder die Schachtel ist leer (None). Es gibt keinen “Fehler”, die Schachtel ist einfach leer.
  • Result<T, E>: Ein Einschreiben per Post. Wenn der Bote klingelt, erhalten Sie entweder das Paket mit dem gewünschten Inhalt (Ok). Wenn etwas schiefgegangen ist (z. B. Adresse nicht gefunden), erhalten Sie keinen Inhalt, sondern ein Fehlerprotokoll (Err), auf dem genau steht, was schiefgelaufen ist.

2.2 Der ?-Operator zur Fehlerweiterleitung

Wenn wir mehrere Operationen nacheinander ausführen, die fehlschlagen können, müssten wir ohne Hilfsmittel jeden Schritt mit match überprüfen. Das führt zu unübersichtlichem, tief eingerücktem Code (“Pyramid of Doom”). Rust bietet dafür den ?-Operator.

Die Analogie: Das Weitergeben der heißen Kartoffel

Stellen Sie sich ein Fließband in einer Fabrik vor. Jeder Arbeiter prüft ein Teil. Wenn ein Arbeiter einen Fehler entdeckt, repariert er ihn nicht selbst, sondern wirft das Teil sofort dem Vorarbeiter zu (?), damit dieser entscheidet, was zu tun ist. Die Funktion reicht den Fehler einfach an ihren Aufrufer weiter.

Der Code-Vergleich:

Ohne ? (umständlich):

#![allow(unused)]
fn main() {
fn lies_zahl() -> Result<i32, std::io::Error> {
    let inhalt = match std::fs::read_to_string("config.txt") {
        Ok(text) => text,
        Err(e) => return Err(e),
    };
    // ... parsen ...
}
}

Mit ? (elegant):

#![allow(unused)]
fn main() {
fn lies_zahl() -> Result<i32, std::io::Error> {
    let inhalt = std::fs::read_to_string("config.txt")?; // Leitet Fehler sofort weiter!
    // ...
}
}

2.3 Eigene Fehlertypen (Custom Errors)

Oft wirft ein Programm verschiedene Fehler (z. B. std::io::Error beim Lesen und std::num::ParseIntError beim Parsen). Damit eine Funktion diese unterschiedlichen Fehler gesammelt zurückgeben kann, definieren wir ein eigenes Fehler-Enum und implementieren das std::error::Error-Trait.

Der Compilerfehler beim Mischen von Fehlertypen (CDD-Ansatz):

#![allow(unused)]
fn main() {
fn parse_config() -> Result<i32, std::io::Error> {
    let inhalt = std::fs::read_to_string("config.txt")?; // Gibt std::io::Error zurück
    let zahl: i32 = inhalt.trim().parse()?; // Fehler! Gibt ParseIntError zurück
    Ok(zahl)
}
}

Der Compiler bricht mit einem Typfehler ab:

error[E0277]: the trait bound `std::io::Error: From<std::num::ParseIntError>` is not satisfied
  --> src/main.rs:3:43
   |
3  |     let zahl: i32 = inhalt.trim().parse()?;
   |                                           ^ the trait `From<std::num::ParseIntError>` is not implemented for `std::io::Error`

Warum lehnt der Compiler das ab? Der ?-Operator versucht, den aufgetretenen ParseIntError automatisch in den Rückgabetyp der Funktion (std::io::Error) zu konvertieren. Da diese Konvertierung nicht definiert ist, scheitert der Vorgang. Wir müssen einen gemeinsamen Fehlertyp schaffen, in den sich beide Fehler konvertieren lassen.


3. Genaue Code-Erklärung der Musterlösung

Hier sehen wir ein vollständiges, kompilierbares Programm, das einen eigenen Fehlertyp TerminalError definiert und nutzt:

1:  use std::fmt;
2:  use std::fs;
3:  use std::io;
4:  use std::num::ParseIntError;
5:  use std::error::Error;
6:  
7:  // 1. Definition des eigenen Fehlertyps
8:  #[derive(Debug)]
9:  enum TerminalError {
10:     DateiFehler(io::Error),
11:     FormatFehler(ParseIntError),
12:     KonfigurationLeer,
13: }
14: 
15: // 2. Implementierung des Display-Traits für benutzerfreundliche Fehlermeldungen
16: impl fmt::Display for TerminalError {
17:     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18:         match self {
19:             TerminalError::DateiFehler(e) => write!(f, "Verbindungsfehler zur Konfigurationsdatei: {}", e),
20:             TerminalError::FormatFehler(e) => write!(f, "Ungültiges Zahlenformat in der Datei: {}", e),
21:             TerminalError::KonfigurationLeer => write!(f, "Die Konfigurationsdatei ist leer!"),
22:         }
23:     }
24: }
25: 
26: // 3. Implementierung des Error-Traits
27: impl Error for TerminalError {}
28: 
29: // 4. Konvertierungen (From) implementieren, damit der ?-Operator funktioniert
30: impl From<io::Error> for TerminalError {
31:     fn from(err: io::Error) -> Self {
32:         TerminalError::DateiFehler(err)
33:     }
34: }
35: 
36: impl From<ParseIntError> for TerminalError {
37:     fn from(err: ParseIntError) -> Self {
38:         TerminalError::FormatFehler(err)
39:     }
40: }
41: 
42: // 5. Die eigentliche Verarbeitungsfunktion
43: fn lade_maximale_traglast(dateipfad: &str) -> Result<i32, TerminalError> {
44:     let inhalt = fs::read_to_string(dateipfad)?;
45:     
46:     if inhalt.trim().is_empty() {
47:         return Err(TerminalError::KonfigurationLeer);
48:     }
49:     
50:     let traglast: i32 = inhalt.trim().parse()?;
51:     Ok(traglast)
52: }
53: 
54: fn main() {
55:     // Wir schreiben eine temporäre Testdatei
56:     fs::write("terminal_config.txt", "15000").unwrap();
57: 
58:     match lade_maximale_traglast("terminal_config.txt") {
59:         Ok(wert) => println!("Erfolgreich geladen! Maximale Traglast: {} kg", wert),
60:         Err(fehler) => println!("Kritischer Terminalfehler: {}", fehler),
61:     }
62:     
63:     // Test eines Fehlerfalls (ungültige Zahl)
64:     fs::write("terminal_config.txt", "KEINE_ZAHL").unwrap();
65:     if let Err(fehler) = lade_maximale_traglast("terminal_config.txt") {
66:         println!("Erwarteter Testfehler: {}", fehler);
67:     }
68: 
69:     // Aufräumen
70:     let _ = fs::remove_file("terminal_config.txt");
71: }

Zeilen-Analyse der Lösung:

  • Zeile 8–13: Wir definieren das enum TerminalError. Durch das Umschließen (Wrapping) der originalen Fehler (io::Error und ParseIntError) behalten wir die exakten Details der ursprünglichen Fehlerursache bei, während wir sie unter einem gemeinsamen Dach vereinen.
  • Zeile 16–24: Der Trait fmt::Display ist zwingend erforderlich, damit ein Typ mit {} formatiert und ausgegeben werden kann (z. B. in println!). Wir nutzen Pattern Matching auf self, um für jede Variante eine maßgeschneiderte, deutschsprachige Fehlermeldung zu generieren.
  • Zeile 27: impl Error for TerminalError {} – Wir implementieren das Standard-Error-Trait. Da wir bereits Display und Debug implementiert haben, sind die Voraussetzungen dafür voll erfüllt.
  • Zeile 30–34: Der Trait From<io::Error> definiert, wie ein io::Error in einen TerminalError umgewandelt wird. Wenn der ?-Operator auf einen Dateizugriffsfehler trifft, ruft Rust automatisch diese from-Funktion auf.
  • Zeile 44: fs::read_to_string(dateipfad)? – Liest die Datei. Sollte die Datei nicht existieren, bricht die Funktion hier sofort ab, wandelt den io::Error über unser From-Implementierung in einen TerminalError::DateiFehler um und gibt diesen als Err zurück.
  • Zeile 46–48: Wir prüfen manuell, ob die Datei leer ist. Wenn ja, erzeugen wir aktiv einen eigenen Fehler mit return Err(TerminalError::KonfigurationLeer).
  • Zeile 50: inhalt.trim().parse()? – Konvertiert den Text in eine Ganzzahl (i32). Schlägt dies fehl (z. B. weil der Text “fünf” lautet), fängt der ?-Operator den ParseIntError ab, konvertiert ihn in einen TerminalError::FormatFehler und gibt ihn zurück.
  • Zeile 58–61: In der main-Funktion fangen wir das Ergebnis sauber mit einem match auf. Wir stürzen nicht ab, sondern informieren den Betreiber des Terminals verständlich über die Konsole.