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:
- Die Datei existiert überhaupt nicht (Eingabe-/Ausgabe-Fehler).
- Die Datei ist leer oder kann nicht gelesen werden.
- 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::ErrorundParseIntError) 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::Displayist zwingend erforderlich, damit ein Typ mit{}formatiert und ausgegeben werden kann (z. B. inprintln!). Wir nutzen Pattern Matching aufself, 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 bereitsDisplayundDebugimplementiert haben, sind die Voraussetzungen dafür voll erfüllt. - Zeile 30–34: Der Trait
From<io::Error>definiert, wie einio::Errorin einenTerminalErrorumgewandelt wird. Wenn der?-Operator auf einen Dateizugriffsfehler trifft, ruft Rust automatisch diesefrom-Funktion auf. - Zeile 44:
fs::read_to_string(dateipfad)?– Liest die Datei. Sollte die Datei nicht existieren, bricht die Funktion hier sofort ab, wandelt denio::Errorüber unserFrom-Implementierung in einenTerminalError::DateiFehlerum und gibt diesen alsErrzurü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 denParseIntErrorab, konvertiert ihn in einenTerminalError::FormatFehlerund gibt ihn zurück. - Zeile 58–61: In der
main-Funktion fangen wir das Ergebnis sauber mit einemmatchauf. Wir stürzen nicht ab, sondern informieren den Betreiber des Terminals verständlich über die Konsole.