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.
- Panik! Das Programm bricht ab.
- 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 Option
s 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 vonDivisionByZero
. Was gibtprintln!("{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); } }; }