Fehlerbehandlung in Rust - Teil 2

Idiomatische Entwurfsmuster

Idiomatische Entwurfsmuster

Fehlerbehandlung in Rust - Teil 2

Idiomatische Entwurfsmuster


In der letzten Ausgabe haben wir die grundlegende Fehlerbehandlung in Rust kennengelernt. Mit Enums wie Option oder Result können wir sicherstellen, dass wir ungewollte Zustände und Fehler auch tatsächlich behandeln müssen. Der Fragezeichen-Operator erlaubt uns gleichzeitig höchste Ergonomie, ohne auf Sicherheit verzichten zu müssen. Und doch gibt es einen kleinen Wermutstropfen: Es fehlt die dynamische Bindung bei mehreren Fehlertypen. Mit einem bestimmten idiomatischen Entwurfsmuster können wir allerdings den Compiler wieder alles statisch auflösen lassen.

Ein kurzes Resümee vom letzten Mal: Option und Result sind in Rust beides Enum-Typen, die uns zwingen, mit Fehlerzuständen umzugehen. Entweder müssen wir überprüfen, ob Werte da sind, bevor wir sie nutzen können, oder wir ignorieren die möglichen Fehler explizit. Somit gibt es keine Situation, in denen unsere Software unabsichtlich Fehler „schluckt“, ohne dass wir vorher als Entwickler:innen eine bewusste Aktion setzen.

Diese Aktion kann so einfach sein, wie mit einem Fragezeichen den Fehler propagieren zu lassen. Wenn unsere Funktion oder Methode ein Result zurückgibt, können wir den Fehlerfall des Enums einfach hochschicken. Wir arbeiten im Erfolgsfall einfach mit dem gewonnenen Wert weiter. Damit sieht unser Code aus, als würden wir uns gar nicht um Fehler kümmern. In Wirklichkeit allerdings verlagern wir die Behandlung nur eine Ebene weiter nach oben. In der Realität sieht das Ganze ungefähr wie in Listing 1 aus.

Listing 1

use std::error;
 
fn read_number_from_file(filename: &str) -> Result<u64, Box<dyn error::Error>> {
  let mut file = File::open(filename)?; /* 1 */
 
  let mut buffer = String::new();
  file.read_to_string(&mut buffer)?; /* 1 */
 
  let parsed: u64 = buffer.trim().parse()?; /* 2 */
 
  Ok(parsed)
}

Wenn wir uns den Code aus Listing 1 ansehen, erkennen wir drei Stellen, an denen Fehler passieren können: Bei den mit * 1 * markierten Zeilen öffnen wir eine Datei und lesen Daten in einen Buffer. Beide Operationen können I/O-Fehler verursachen (std::io::Error). Mit dem Fragezeichen am Ende jeder Operation brechen wir im Fehlerfall unseren Code ab und geben den Fehler weiter an die Funktion, die den Code aufgerufen hat.

An der mit * 2 * markierten Stelle wollen wir die eingelesene Zeichenkette in eine Zahl verwandeln. Das kann funktionieren, muss es aber nicht. Der hier entstehende Fehler ist vom Typ ParseIntError. Wir können den Fehler „nach oben“ schicken, damit die aufrufende Funktion sich darum kümmert, dass dieser Fehler auch tatsächlich behandelt wird.

So etwas augenscheinlich Einfaches, wie beide Fehlertypen propagieren zu lassen, stellt den Rust-Compiler allerdings vor eine große Herausforderung – eine Herausforderung, die auch gleich fantastisch illustriert, was bei einem Übersetzungsvorgang eigentlich genau passiert. Beide Typen sind nämlich nicht kompatibel. Klar, beide implementieren den Error Trait, haben also die Fähigkeit, als Fehler wahrgenommen zu werden. Allerdings ist das Speicherlayout der beiden im Zweifelsfall höchst unterschiedlich. Rust weiß hier nicht, wieviel Speicher allokiert werden muss. Das bedeutet für Rust: Ab damit auf den Heap! Das zeigen wir durch zwei Dinge an:

  1. Mit dem Schlüsselwort dyn sagen wir Rust, dass wir hier etwas erwarten, das den Error Trait implementiert, dessen finales Speicherlayout allerdings erst zur Laufzeit bekannt ist.

  2. Mit dem Typ Box legen wir den dynamisch angelegten Typ auf den Heap und haben einen Smart Pointer, den wir auf dem Stack herumschieben können. Die Größe einer Box ist eindeutig.

Die tatsächlichen Daten liegen später woanders...