Programmieren in Rust

Fehlerwerte

Inhaltsverzeichnis

  1. Optionale Werte
  2. Resultate
  3. Fehler weiterreichen
  4. Universeller Fehlertyp
  5. Fehler aufzeichnen
  6. Individuelle Fehlertypen

Optionale Werte

Wir wollen eine Funktion programmieren die ein Array nach einem Wert durchsucht und den ersten Index zurückgibt wo dieser Wert gefunden wurde. Es kann natürlich aber auch sein, dass dieser Wert nicht im Array vorkommt. Man könnte in diesem Fall z. B. den negativen Index -1 zurückgeben. Jedoch geben Enumerationen unsere Absicht besser wieder.

Für solche optionalen Werte gibt es schon einen vordefinierten Datentyp:

enum Option<T> {
    Some(T),
    None
}

Hierbei ist T eine Typvariable für die ein beliebiger aber fester Datentyp eingesetzt wird. In der Variante None trägt der Wert keine Daten. In der Variante Some(T) trägt der Wert genau einen Wert vom Typ T. Das Suchprogramm lässt sich damit so formulieren:

fn find(a: &[&str], s: &str) -> Option<usize> {
    for index in 0..a.len() {
        if a[index] == s {return Some(index);}
    }
    None
}

fn print_index(a: &[&str], s: &str) {
    match find(a,s) {
        Some(index) => println!("Der Index zu '{}' ist {}.", s, index),
        None => println!("Konnte '{}' nicht finden.", s)
    }
}

fn main() {
    let a = vec!["Kaffee", "Tee", "Mate"];
    print_index(&a, "Tee");
    print_index(&a, "Pfefferminztee");
}

Resultate

Blieb der Erfolg bei der Ausführung einer Funktion aus, würde man gerne mehr darüber erfahren wie es dazu kam. Die Funktion muss dafür Information über den Fehler zurückgeben. Man könnte diese Information in einer zusätzlichen Variablen speichern, was jedoch mit dem Schönheitsfehler behaftet ist, dass diese Variable dann auch vernachlässigt werden könnte. Wir würden lieber korrektes Benutzen des Rückgabewertes erzwingen, was sich mittels Enumerationen bewerkstelligen lässt.

Für Resultate gibt es auch einen vordefinierten Datentyp:

enum Result<T, E> {
    Ok(T),
    Err(E)
}

In der Variante Ok(T) trägt der Wert einen normalen Wert vom Typ T, in der Variante Err(E) einen Fehlerwert vom Typ E.

Als Beispiel wollen wir ein Programm zur Umwandlung von Zeichenketten in Zahlen schreiben. Kommen in der Zeichenkette aber Zeichen vor die keine Ziffern sind, wird ein darüber Aufschluss gebender Fehlerwert zurückgegeben. Einen entsprechenden Fehlerwert soll es geben falls die Zahl größer ausfällt als u32 halten kann.

Zunächst die Funktion ohne Fehlerbehandlung:

fn int(s: &str) -> u32 {
    let mut value: u32 = 0;
    for c in s.chars() {
        let digit = u32::from(c) - u32::from('0');
        value = 10*value + digit;
    }
    value
}

Und nun mit Fehlerbehandlung:

enum ParseError {InvalidDigit, Overflow}

fn int(s: &str) -> Result<u32, ParseError> {
    let mut value: u64 = 0;
    for c in s.chars() {
        if !c.is_ascii_digit() {
            return Err(ParseError::InvalidDigit);
        }
        let digit = u32::from(c) - u32::from('0');
        value = 10*value + u64::from(digit);
        if value>u64::from(std::u32::MAX) {
            return Err(ParseError::Overflow);
        }
    }
    Ok(value as u32)
}

fn print_number(s: &str) {
    match int(s) {
        Ok(value) => println!("Zahl: {}", value),
        Err(ParseError::InvalidDigit) => println!("Keine Zahl."),
        Err(ParseError::Overflow) => println!("Zahl zu groß.")
    }
}

fn main() {
    print_number("12");
    print_number("Firlefanz");
    print_number("10000000000")
}

Fehler weiterreichen

Bei Verzicht auf detailierte Fehlerinformation erlaubt sich auch diese Ausgestaltung:

fn int(s: &str) -> Option<u32> {
    let mut value: u32 = 0;
    for c in s.chars() {
        if !c.is_ascii_digit() {return None;}
        let digit = u32::from(c) - u32::from('0');
        value = value.checked_mul(10)?.checked_add(digit)?;
    }
    Some(value)
}

Die Operationen checked_mul und checked_add überprüfen, ob es bei der Multiplikation zu einem Überlauf kommt, der Rückgabewert hat den Typ Option<u32>. Die Operation x? ist eine Kurzschreibweise für:

match x {
    Some(value) => value,
    None => return None
}

Das ist ein sogenannter Wächter-Ausdruck. Falls x den Wert None haben sollte, wird die Ausführung der Funktion abgebrochen und None zurückgeben. Da im weiteren Verlauf also None ausgeschlossen ist, kann value einfach aus Some(value) herausgeholt werden.

Den Fragezeichen-Operator gibt es auch für Resultate:

match x {
    Ok(value) => value,
    error => return error
}

Die vollständige Definition des Operators kann zudem eine Konvertierung zwischen Fehlerarten vornehmen:

match x {
    Ok(value) => value,
    Err(error) => return Err(From::from(error))
}

Universeller Fehlertyp

Nicht immer ist die lokale Verarbeitung von Fehlern zielführend. Wie schon dargelegt, besteht die Vorgehensweise dann im Weiterreichen des Fehlers nach oben an den Aufrufer. Dabei tut sich nun recht schnell die Unbequemlichkeit auf, dass eine Funktion zwei oder mehr unterschiedliche Fehlertypen zurückgeben können muss.

Natürlich ließen sich die Fehlertypen mittels Enumeration zu einem gemeinsamen Typ zusammenfassen. Bei fortwährendem Weiterreichen kann dies allerdings zum umständlichen Verschachteln oder Neugruppieren von Enumerationen führen.

Was wir bräuchten wäre so eine Art Enumeration aus unendlich vielen Typen. Solche Datentypen gibt es in Rust tatsächlich, sie heißen Trait-Objekt-Typen und werden mit dem Schlüsselwort dyn eingeleitet. Hinter dem Schlüsselwort befindet sich der Name einer Traitsignatur, in welcher die abstrakte Schnittstelle des Typen kodiert ist. Konzeptuell kommt weiter hinzu, dass dyn eine Zusammenfassung von Typen aller möglichen Speichergrößen ist. Aus diesem Grund kann dyn Trait keine feste Speichergröße besitzen – man spricht von einem Typ dynamischer Größe. Um überhaupt damit arbeiten zu können, darf ein solcher Typ nur eingehüllt durch einen Zeiger vorkommen – bspw. als Referenz &dyn Trait oder mittels Smart-Pointer-Typ Box als Box<dyn Trait>.

Die Standardbibliothek enthält so einen Trait std::error::Error. Damit lässt sich ein Kontrollfluss formulieren, welcher dem aus anderen Programmiersprachen bekannten Werfen und Abfangen von Ausnahmen nahe kommt.

type Error = Box<dyn std::error::Error>;

fn get_path() -> Result<String, String> {
    match std::env::args().nth(1) {
        Some(path) => Ok(path),
        None => Err(String::from(
            "Kommandozeilenargument erwartet"))
    }
}

fn load() -> Result<String, Error> {
    let path = get_path()?;
    let text = std::fs::read_to_string(path)?;
    Ok(text)
}

fn main() -> Result<(), Error> {
    let text = load()?;
    println!("{}", text);
    Ok(())
}

Das Programm verhält sich so als würde im Fehlerfall panic geworfen. Was haben wir demgegenüber nun gewonnen? Nun, Fehler lassen sich jetzt an jeder Stelle nach Wunsch abfangen, umformen und verarbeiten.

Zum Schluss noch eine Warnung. Ein Wert vom universellen Fehlertyp Box<dyn std::error::Error> ist – gerade weil er alle möglichen Fehler enthalten kann – untypisiert. Man sollte Programme immer so stark wie möglich typisieren und solche universellen Typen nur als letzten Ausweg benutzen. Andernfalls kann es leichter dazu kommen, dass ein bestimmtes Programmverhalten übersehen bzw. falsch verarbeitet wird.

Fehler aufzeichnen

Beim Ablauf eines Programms kann es zu unkritischen Fehlern kommen, die zwar bedeuten, dass eine bestimmte Funktionalität nicht richtig arbeitet, allerdings nicht sofort zum Programmabbruch führen müssen. Nur weil beim einem Flugzeug beispielsweise ein Instrument oder Navigationsgerät ausfällt, muss das nicht gleich heißen, dass die gesamte Avionik den Geist aufgibt. Damit solche Fehler nicht übersehen werden, sollten diese zumindest in einen Fehler-Log einfließen. Das kann man z. B. so bewerkstelligen:

fn log_err(file: &str, line: u32, text: String) {
    eprintln!("Huch, da ist ein Fehler in Datei {}, \
        Zeile {} aufgetreten:\n{}", file, line, text);
}

fn main() {
    const DEFAULT: u8 = 0;
    let x: u32 = 1000;
    let y = u8::try_from(x).unwrap_or_else(|err| {
        log_err(file!(), line!(), err.to_string());
        DEFAULT
    });
    println!("{}", y);
}

Individuelle Fehlertypen

In bestimmten Situation kann die Definition neuer individueller Fehlertypen pragmatisch sein. Soll ein individueller Fehlertyp in den universellen Fehlertyp umwandelbar sein, muss eine Implementierung des Traits std::error::Error vorliegen. Das bedeutet an sich lediglich, dass die Traits Display und Debug implementiert sind. Man bewerkstelligt das so:

use std::fmt::{self, Display};
type Error = Box<dyn std::error::Error>;

#[derive(Debug)]
struct CustomError {text: String}

impl CustomError {
    fn new(text: &str) -> Self {Self {text: text.to_string()}}
}

impl Display for CustomError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.text)
    }
}

impl std::error::Error for CustomError {}

fn main() -> Result<(), Error> {
    Err(CustomError::new("Fehler").into())
}