Optionale Werte und Fehlerbehandlung

In diesem Abschnitt lernen wir zwei generische Aufzählungstypen kennen, die eine große Bedeutung in Rust haben und von Rust bereit gestellt werden.

Optionale Werte

In anderen Sprachen wie C++, Java oder Python muss man zuweilen zur Laufzeit überprüfen, ob der Wert einer Variable vorhanden ist oder ob ihr Wert Null ist. In C++ entspricht das nullptr, in Java null oder in Python None. Manchmal wird eine derartige Überprüfung vergessen. Dann wird auf den nullartigen Wert in einer Art zugegriffen die vom Nullwert nicht unterstützt wird. Das führt zu hässlichen Fehlern. Der Erfinder der Laufzeit-Null nennt ihre Einführung ein "Billion-Dollar Mistakte". In Rust gibt es zwar einen Wert None. Der Zugriff wird jedoch bereits zur Kompilierzeit überprüft. Es ist dementsprechend nicht ohne Weiteres möglich, Laufzeitfehler wegen eines Zugriffs auf None zu produzieren. Zur Implementierung stellt Rust den generischen Aufzählungstyp Option<T> bereit. Option<T> hat die beiden Varianten Some(T) und None und ist folgendermaßen in der Standardbibliothek definiert.

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Im folgenden Beispiel werden wir Option so verwenden, wie wir das von Aufzählungstypen kennen.

fn add(a: Option<i32>, b: Option<i32>) -> Option<i32> {
    match a {
        Some(a) => match b { 
            Some(b) => Some(a + b),
            _ => None
        }
        _ => None, 
    }
}

fn main() {
    assert_eq!(add(Some(1), Some(2)), Some(3));
    assert_eq!(add(Some(1), None), None);
}

Da Option integraler Bestandteil der Sprache ist, brauchen wir weder Option importieren, noch Option:: vor die Varianten Some und None schreiben. Der obere Schnipsel lässt sich vereinfachen, da man match auch auf Tupel anwenden kann.

fn add(a: Option<i32>, b: Option<i32>) -> Option<i32> {
    match (a, b) {
        (Some(a), Some(b)) => Some(a + b),
        _ => None
    }
}
fn main() {
    assert_eq!(add(Some(1), Some(2)), Some(3));
    assert_eq!(add(Some(1), None), None);
}

Es geht auch ohne match. Der Aufzählungstyp Option implementiert die Methoden map und and_then. Die Methode map bekommt als Argument eine FnOnce-Closure, die T auf U abbildet. Falls die Option einen einen Wert beinhaltet, ist das Ergebnis ein Some(U) ansonten None. Auch and_then hat nur eine FnOnce-Closure als Argument. Diese gibt allerdings direkt den resultierenden optionalen Wert vom Typ Option<U> zurück.

fn add(a: Option<i32>, b: Option<i32>) -> Option<i32> {
    a.and_then(|a| b.map(|b| a+b))
}
fn main() {
    assert_eq!(add(Some(1), Some(2)), Some(3));
    assert_eq!(add(Some(1), None), None);
}

Weitere hilfreiche Methoden sind in der Dokumentation zu finden. Beispielsweise kann man mit unwrap_or einen Default-Wert im None-Fall festlegen. Wenn man sich ganz sicher ist, dass der Wert einer Variable die Variante Some beinhaltet und nicht None, kann man mit expect direkt den Wert auspacken. Hier ist sehr große Vorsicht geboten! Falls die Variable wider Erwarten doch None war, bricht das Programm kontrolliert ab und gibt eine Fehlermeldung aus. Die Methode unwrap verhält sich wie expect. Man kann unwrap jedoch keine konkrete Fehlermeldung übergeben. Die Methoden unwrap und expect finden beispielsweise in Tests Verwendung.

Fehlerbehandlung

In Rust gibt es 2 Arten von Fehlern.

  1. Panik! Das Programm bricht ab.
  2. Fehler die im Kontrollfluss des Programms behandelt werden, sind in den Aufzählungstyp Result<T, E> verpackt.

Es gibt keine Exceptions wie man sie aus anderen Sprachen wie Java, Python oder C++ kennt. Fehler führen entweder zum Abbruch des Programms oder können dem Rückgabewert einer Funktion entnommen werden. Allerdings bringt Rust ein paar Werkzeuge mit, um die Fehlerbehandlung per Rückgabewert angenehmer zu gestalten wie den ?-Operator.

Panik

Um ein Programm in Rust kontrolliert mit einer Fehlermeldung zu beenden, kann man das Makro panic! verwenden.

#![allow(unused)]
fn main() {
panic!("There was an unrecoverable error.");
}

Das ist sinnvoll, wenn der Fehler so gravierend ist, dass das Programm nicht sinnvoll weiter ausgeführt werden kann. Beispiele sind ein Zugriff auf einen Array außerhalb des verwalteten Speichers oder ein unwrap auf ein None.

Fehler im Kontrollfluss

Oft können Programme auf Fehler reagieren und weiter funktionieren. Wie man dieses Verhalten in Rust abbildet, schauen wir uns anhand eines Beispiels an. Dazu betrachten wir unseren Punkt-Typen. Wir implementieren Div als Division durch einen Skalar. Bei Division durch 0 werden wir einen Fehler zurückgeben. Dazu verwenden wir den Aufzählungstyp Result, den Rust für uns bereit stellt. Seine Definition sieht im Wesentlichen folgendermaßen aus.

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E)
}
}

Analog zu Options Varianten sind auch Ok und Err direkt verfügbar. Die Verwendung von Result::Ok oder Result::Err ist unnötig. Der erste generische Parameter T ist der Typ des Rückgabenwerts, für den wir uns im Erfolgsfall interessieren. Der zweite generische Parameter E ist der Typ des Fehlers, der uns im bei Fehlschlagen um die Ohren fliegt. Da in Rust 1.0/0.0 den Wert f64::INFINITY und 0.0/0.0 den Wert f64::NAN ergibt, ist das Teilen durch 0 für primitive Skalare kein Fehler. Es gibt dementsprechend auch keinen DivisionByZero-Fehlertypen, den wir einfach verwenden könnten. Daher legen wir einen Fehlertypen für das Teilen durch 0 an. Dieser implementiert den Fehler-Trait std::error::Error und die Anzeige-Traits std::fmt::Display und std::fmt::Debug.

#![allow(unused)]
fn main() {
use std::{error::Error, fmt::{self, Display, Debug, Formatter}};
#[derive(Debug)]
struct DivisionByZero;
impl Error for DivisionByZero{}
impl Display for DivisionByZero {
    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
        write!(f, "Division by zero error")
    }
}
}

Die Methode fmt des Display-Traits definiert wie die String-Repräsentation des Strukturtypen DivisionByZero aussieht. Der Rückgabetyp std::fmt::Result von fmt ist eine Typdefinition und sieht im Wesentlichen wie folgt aus.

#![allow(unused)]
fn main() {
type Result = Result<(), std::fmt::Error>;
}

Das heißt fmt gibt im Erfolgsfall das leere Tupel zurück und im Fehlerfall Fehlerfall den Fehlertyp std::fmt::Error Gerade bei generischen Typen, die man oft mit den gleichen Typparametern verwenden möchte, erspart type einem einiges an Schreib- und Lesearbeit.

Quiz

Es sei e eine Instanz von DivisionByZero. Was gibt println!("{e}"); auf dem Bildschirm aus?

Fehlermeldungen können also durch println!("{e}"); für eine Instanz e eines Fehlertyps angezeigt werden. Mit dem Fehlertypen DivisionByZero implementieren wir jetzt Division durch Skalare für Point.

use std::{ops::Div, convert::From};
use std::{error::Error, fmt::{self, Display, Debug, Formatter}};
#[derive(Debug)]
struct DivisionByZero;
impl Error for DivisionByZero{}
impl Display for DivisionByZero {
   fn fmt(&self, f: &mut Formatter) -> fmt::Result {
       write!(f, "division by zero error")
   }
}
#[derive(Copy, Clone)]
struct Point<T>
where 
    T: Div<Output=T> + Copy
{
    x: T,
    y: T,
}
impl<T> Div<T> for Point<T>
where 
    T: Div<Output=T> + Copy + PartialEq + From<i32>
{
    // Output ist nicht Self im Gegensatz zu vorherigen Beispielen
    type Output = Result<Self, DivisionByZero>;

    fn div(self, rhs: T) -> Self::Output {
        if rhs == T::from(0) {
            Err(DivisionByZero{})
        } else {
            let p = Point{    
                x: self.x / rhs,
                y: self.y / rhs,
            };
            Ok(p)
        }
    }
}
fn main() {
    let p = Point::<f64>{ x: 1.0, y: 1.0 };
    let q = p / 2.0;
    if let Ok(q) = q {
        assert!((q.x - 0.5).abs() < 1e-12);
        assert!((q.y - 0.5).abs() < 1e-12);
    } else {
        panic!("Error not expected");
    }
    let q = p / 0.0;
    match q {
        Ok(_x) => panic!("I expect an error!"),
        Err(e) => println!("The expected error is '{e}'"),
    };
}

Der Trait std::convert::From<i32> erfordert von implementierenden Typen, dass sie aus einem i32 erstellt werden können. Für f64 ist From<i32> implementiert. An dieser Stelle verwenden wir From, um eine generische 0 zu erzeugen, mit der wir den Nenner vergleichen können. Da wir den Typ T allgemein halten, kennen wir das konkrete 0-Literal nicht. Ein Resultat immerzu aus dem Aufzählungstypen Result auspacken zu müssen kann lästig werden. Zum Glück gibt es den ?-Operator in Rust. Wenn eine Funktion eine Instanz von Result<T, E> zurückgibt, kann sie Instanzen von Result<U, E> mit ? entpacken. Ein Fehler führt zum vorzeitigen Abbruch der Funktion und zur Rückgabe des Fehlers e eingepack in die Variante Err(e).

use std::{ops::Div, convert::From};
use std::{error::Error, fmt::{self, Display, Debug, Formatter}};
#[derive(Debug)]
struct DivisionByZero;
impl Error for DivisionByZero{}
impl Display for DivisionByZero {
   fn fmt(&self, f: &mut Formatter) -> fmt::Result {
       write!(f, "division by zero error")
   }
}
#[derive(Copy, Clone)]
struct Point<T>
where 
    T: Div<Output=T> + Copy
{
    x: T,
    y: T,
}
impl<T> Div<T> for Point<T>
where 
    T: Div<Output=T> + Copy + PartialEq + From<i32>
{
    type Output = Result<Self, DivisionByZero>;
    fn div(self, rhs: T) -> Self::Output {
        if rhs == T::from(0) {
            Err(DivisionByZero{})
        } else {
            let p = Point{    
                x: self.x / rhs,
                y: self.y / rhs,
            };
            Ok(p)
        }
    }
}

fn print_division(p: Point<f64>, s: f64) -> Result<Point<f64>, DivisionByZero> {

    let p = (p / s)?;
    
    println!("{}, {}", p.x, p.y);
    Ok(p)
}

fn main() -> Result<(), DivisionByZero> {
    let p = Point::<f64>{ x: 1.0, y: 1.0 };
    let q = print_division(p, 2.0)?;
    assert!((q.x - 0.5).abs() < 1e-12);
    assert!((q.y - 0.5).abs() < 1e-12);
    let q = print_division(p, 0.0);
    if let Err(e) = q {
        println!("as expected the result is not printed and we have a '{e}'");
    } else {
        panic!("we expected an error but didn't get one");
    }
    Ok(())
}

Genauer gesagt entspricht

#![allow(unused)]
fn main() {
let p = (p / s)?;
}

dem folgenden Schnipsel

#![allow(unused)]
fn main() {
p = match p / x {
    Ok(res) => res,
    Err(e) => {
        return Err(e);
    }
};
}