Rust-lernen.de

Fehlerbehandlung in Rust

Letzte Änderung: 13.10.2024

Rust kennt keine Exceptions wie in Java, die man werfen und wieder fangen kann. Stattdessen gibt es zwei grundlegend unterschiedliche Fehlerbehandlungsmöglichkeiten in Rust.

Rust CGI

Programmbeispiel

Sehen wir uns folgende Funktion an, der zwei Parameter x und y übergeben werden und die als Rückgabewert das Ergebnis der Ganzzahldivision x / y zurückgeben soll.

fn divide(x: i32, y: i32) -> i32 {
    if y != 0 {
        x / y
    } else {
        // Fehlerbehandlung!
    }
}

Was soll allerdings passieren, wenn der Parameter y den Wert 0 hat? Anstelle des Standardverhaltens von Rust wollen wir uns selbst um die Fehlerbehandlung kümmern und im Folgenden verschiedene Ansätze vergleichen.

Ansatz 1: Das Programm abbrechen

Eine mögliche Fehlerbehandlung für obiges Programmbeispiel ist, das Programm mit einer Fehlermeldung abzubrechen. Die Funktion sieht dann so aus:

fn divide(x: i32, y: i32) -> i32 {
    if y != 0 {
        x / y
    } else {
        panic!("Division durch null!");
    }
}

Beim Aufruf von divide(5, 0) gibt das Programm folgende Fehlermeldung auf der Konsole aus:

thread 'main' panicked at src/main.rs:5:9:
Division durch null!
stack backtrace:
   0: rust_begin_unwind
             at /rustc/eeb9.../library/std/src/panicking.rs:665:5
   1: core::panicking::panic_fmt
             at /rustc/eeb9.../library/core/src/panicking.rs:74:14
   2: fehlerbehandlung::divide
             at ./src/main.rs:5:9
   3: fehlerbehandlung::main
             at ./src/main.rs:10:30
   4: core::ops::function::FnOnce::call_once
             at /rustc/eeb9.../library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

In der zweiten Zeile sehen wir unsere Fehlermeldung „Division durch null!“. Es folgt die Aufrufhistorie zum Zeitpunkt des Fehlers, deren Details hier aber nicht wichtig sind.

Dies ist die einfachste Art der Fehlerbehandlung in Rust. Sie bietet sich an, wenn ein nicht behebbarer Fehler vorliegt, bei dem eine weitere Ausführung des Programms keinen Sinn mehr macht. Auch bei einfachen Scripten reicht so eine Fehlerbehandlung meist aus, wenn man sich auf die eigentliche Programmlogik konzentrieren will.

Nebem dem expliziten Aufruf von panic!() gibt es noch weitere Methoden, die das Programm im Fehlerfall sofort abbrechen, beispielsweise unwrap() und expect().

Ein Nachteil der Fehlerbehandlung mit panic!() ist, dass der aufrufende Code keine Kontrolle mehr zurückerlangt. Im Unterschied zu Exceptions in Java, die vom aufrufenden Code gefangen und behandelt werden können, kann bei Rust der durch panic!() ausgelöste Programmabbruch nicht mehr abgewendet werden. Ein panic!-Aufruf in einer Bibliothek kann also das ganze Programm beenden.

Ansatz 2: Das Programm fortsetzen

Soll das Programm möglichst robust sein und trotz Probleme weiter ausgeführt werden, muss der Fehlerfall im Rückgabetyp der Funktion berücksichtigt werden. Nachfolgend werden einige Möglichkeiten vorgestellt.

Optionaler Rückgabewert

Wenn das Ergebnis unserer Funktion divide nicht berechnet werden kann, wir das Programm aber deswegen nicht abbrechen wollen, ist eine einfache Lösung, einen optionalen Rückgabewert mittels Option<i32> zu verwenden. Unsere Funktion sieht dann so aus:

fn divide(x: i32, y: i32) -> Option<i32> {
    if y != 0 {
        Some(x / y)
    } else {
        None
    }
}

Ruft man die Funktion beispielsweise mit den Parameterwerten 10 und 5 auf, wird Some(2) zurückgegeben. Beim Versuch durch null zu dividieren, wird hingegen None ohne eine konkrete Zahl zurückgegeben.

In der Funktion, die divide aufruft, kann man den Fehlerfall dann wie folgt behandeln:

fn aufrufende_funktion() {
    if let Some(result) = divide(10, 5) {
        println!("Das Ergebnis ist {result}.");
    } else {
        println!("Division durch null!");
    }
}

Beim Rückgabewert Some(2) wird der Variablen result der Wert 2 zugewiesen und der Text „Das Ergebnis ist 2.“ in der Konsole ausgegeben. Wäre der Rückgabewert hingegen None, würde „Division durch null!“ ausgegeben werden.

Mithilfe des Rückgabetyps Option<i32> können wir also auch den Fehlerfall berücksichtigen und in der aufrufenden Funktion festlegen, wie wir damit umgehen wollen, ohne das Programm abbrechen zu müssen. Wir erhalten im Fehlerfall allerdings keine weitergehende Information über das Problem. Der nachfolgende Ansatz behebt diesen Mangel.

Rückgabewert mit Fehlerursache

Um genauere Infos zur Fehlerursache zurückzugeben, bietet sich der Datentyp Result als Rückgabetyp an. Dieser unterscheidet explizit zwischen Erfolgs- und Fehlerfall und erlaubt die Angabe beliebiger Fehlerwerte. Unsere Funktion divide könnte dann wie folgt aussehen:

enum DivideError {
    DivisionByZero
}

fn divide(x: i32, y: i32) -> Result<i32, DivideError> {
    if y != 0 {
        Ok(x / y)
    } else {
        Err(DivideError::DivisionByZero)
    }
}

Für den Rückgabewert im Fehlerfall führen wir die Aufzählung DivideError mit dem (in diesem Fall einzigen) Wert DivisionByZero ein.

In der aufrufenden Funktion behandeln wir den Fehlerfall wie folgt:

fn aufrufende_funktion() {
    match divide(10, 5) {
        Ok(result) => println!("Das Ergebnis ist {result}."),
        Err(DivideError::DivisionByZero) => println!("Division durch null!"),
    }
}

Erfolgs- und Fehlerfall werden nun gleichwertig berücksichtigt und mittels match die Fallunterscheidung durchgeführt. Im Erfolgsfall ist der Rückgabewert Ok(result) und result enthält das Berechnungsergebnis der Division. Im Fehlerfall erhalten wir als Rückgabewert Err(DivideError::DivisionByZero), inklusive der Fehlerursache.

Bei einer komplexeren Funktion erweitert man die Aufzählung DivideError einfach um weitere Werte und die match-Fallunterscheidung entsprechend. Die Aufzählungswerte können bei Bedarf sogar zusätzliche Informationen kapseln, worauf wir hier aber nicht weiter eingehen.

Anstelle von match kann man den Result-Rückgabewert auch mit Hilfsfunktionen wie unwrap_or(), unwrap_or_default() oder unwrap_or_else() auswerten.

Weitergeben (Propagieren) eines Fehlers

Kann oder will man einen Fehlerfall in der aufrufenden Funktion nicht direkt behandeln, kann man ihn in der Aufrufkette weiter nach oben reichen, wie folgendes Beispiel zeigt:

fn aufrufende_funktion() -> Result<(), DivideError> {
    let result = divide(10, 5)?;
    println!("Das Ergebnis ist {result}.");
    Ok(())
}

Man beachte den Operator ?, der den Rückgabewert von divide() im Erfolgfall aus Ok() auspackt und im Fehlerfall die Funktion mit dem Fehlerwert als Rückgabewert beendet und dadurch den Fehlerwert an die ihrerseits aufrufende Funktion weiterreicht.

Der Operator ? ist nur eine Kurzform für folgenden Code:

fn aufrufende_funktion() -> Result<(), DivideError> {
    let result = match divide(10, 5) {
        Ok(result) => result,
        Err(e) => return Err(e),
    }
    println!("Das Ergebnis ist {result}.");
    Ok(())
}

Der Operator ? kann nur dann verwendet werden, wenn die Fehlertypen in Result der aufrufenden und aufgerufenen Funktion identisch oder zumindest zuweisungskompatibel (siehe Merkmal std::convert::From) sind.

Welche Fehlerbehandlung soll ich nun verwenden?

Hier ein paar Hilfestellungen, welche Art der Fehlerbehandlung du in Rest verwenden solltest:

Quellen