© Valery Brozhinsky/Shutterstock.com
Erstellen eines eigenen Error-Typs in Rust

Individuelle Fehler


Vor einiger Zeit bat mich ein Freund auf Twitter um meine Hilfe beim Erstellen eines Error-Typs für eine Bibliothek, die sich wie anyhow verhält, sich aber dazu eignet, als Teil eines Library-APIs zugänglich gemacht zu werden. Nachdem ich auf meine Erfahrung im Schreiben von Error-Handling-Bibliotheken zurückgegriffen und etwas zusammengeschustert habe, gab es sehr viel positives Feedback. In diesem Artikel habe ich die Lösungsfindung zusammengefasst.

Strukturell werden wir zunächst das Problem betrachten, danach den Lösungsansatz. Die angewandten Techniken sind nicht neu und ähneln std::io::Error sowie dem Error-Kind-Muster aus dem Failure Book [1]. Angereichert wird das Ganze mit ein wenig Spaß, den ich bei der Arbeit mit dem Quellcode von anyhow gelernt habe, als ich eyre schrieb. Ich habe lediglich die Design Patterns, die im Ökosystem Standard sind, auf dieses spezielle Problem angewandt, und das auf die mir am sinnvollsten erscheinende Weise. Ich maße mir nicht zu glauben an, das sei der beste Weg, einen Error-Typ für eine Bibliothek zu schreiben. Vielleicht ist diese Lösung nicht für alle Fälle und Use Cases geeignet. Mein Ziel ist es, allen die Angst zu nehmen, Design Patterns zu vermischen und aneinander anzupassen, um einen Error-Typ zu bekommen, der exakt den eigenen Ansprüchen entspricht und in den Rest der individuell vorliegenden Softwarearchitektur passt.

Das Problem und der Plan

Lasst mich kurz zusammenfassen, welches Problem dem vorgestellten Lösungsansatz für den theoretischen Error-Typ zugrunde liegt. Der Typ braucht ein programmatisches Interface, das in eine Bibliothek passt. Ich habe daraus entnommen, dass er ein Enum braucht, das einfach dafür eingesetzt werden kann, verschiedene Arten (Kinds) von Fehlern zu verarbeiten. Es muss fähig sein, Abläufe zu erfassen und dem Fehler kontextuelle Stacktraces hinzufügen. Anders ausgedrückt war die Idee, dass dem Fehler neue Fehlermeldungen hinzugefügt werden können, ohne unbedingt den Error-Kind des Fehlers, bei dem er ausgelöst wird, zu verändern. Ähnlich der Methode .wrap_err in eyre oder der Methode .context in anyhow.

Ich erstellte daraufhin einen noch recht vagen Plan: Für das API wollte ich einen Error-Typ und einen Kind-Typ erstellen, wobei der Fehler ein Struct mit privaten internen Membern und der Kind ein non_exhaustive Enum sein sollte. Dann den Ablauf zu bekommen, ist recht einfach. Man muss nur den Member dafür dem äußeren Error-Typ hinzufügen und ihn aufnehmen, wann immer ein Error konstruiert wird.

Das letztgenannte Feature war der Punkt, an dem ich etwas kreativ werden musste. Mein Plan war es, einen separaten ErrorMessage-Typ zu erstellen, der recht einfach von einem beliebigen impl Display-Typ konstruiert werden kann und der optional eine andere ErrorMessage beinhaltet. Diese sollte beim Hinzufügen einer neuen Fehlermeldung als Quelle für die vorherige Fehlermeldung dienen. Das API sollte in etwas wie folgt aussehen:

fn parse_config() -> Result<(), Error> { Err(Kind::NoFile).wrap_err("config is invalid")? }

Beim Ausgeben des Fehlers mit einem Error Reporter wie anyhow oder eyre sollte in etwa das Folgende erscheinen:

Error: 0: config is invalid 1: file not found Backtrace: ...

Programmatisches API – oder: auf spezifische Fehler reagieren

Nun fing ich damit an, das API und verschiedene grundlegende Typen zu skizzieren. Zunächst lag der Fokus einzig und allein auf dem Error Handling, da es mir am einfachsten erschien. Ich teilte es in zwei Dateien, lib.rs (Listing 1) und examples/report.rs (Listing 2) auf, um den Test ausführen und den Output visuell inspizieren zu können. Das diente der Prüfung, ob alle APIs wie geplant zusammenarbeiteten.

Listing 1: lib.rs

#![allow(clippy::try_err)] pub fn foo_handle() -> Result<(), Error> { Err(Kind::Important)? } pub fn foo_no_handle() -> Result<(), Error> { Err(Kind::NotImportant)? } pub struct Error { kind: Kind, } #[non_exhaustive] pub enum Kind { Important, NotImportant, }

Listing 2: examples/report.rs

fn main() -> Result<(), eyre::Report> { match playground::foo_no_handle() { Ok(_) => {} Err(e) if e.kind() == playground::Kind::Important => Err(e)?, Err(_) => {} } match playground::foo_handle() { Ok(_) => {} Err(e) if e.kind() == playground::Kind::Important => Err(e)?, Err(_) => {} } Ok(()) }

Nun, da das Set-up erstellt war, ließ ich rustc die Kontrolle übernehmen und sah mir an, was dabei herauskommen würde (Listing 3).

Listing 3

error[E0277]: `?` couldn't convert the error to `Error` --> src/lib.rs:8:28 | 7 | pub fn foo_no_handle() -> Result<(), Error> { | ----------------- expected `Error` because of this 8 | Err(Kind::NotImportant)? | ^ the trait `From<Kind>` is not implemented for `Error` | = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait = note: required by `from` error: aborting due to 2 previous errors

Nun können wir das Record-Query hinzufügen (Ausgabe von rustc in Listing 4):

impl From<Kind> for Error { fn from(kind: Kind) -> Self { Self { kind } } }

Listing 4

› cargo check --example report warning: field is never read: `kind` --> src/lib.rs:12:5 | 12 | kind: Kind, | ^^^^^^^^^^ | = note: `#[warn(dead_code)]` on by default warning: 1 warning emitted Checking playground v0.1.0 (/home/jlusby/playground) error[E0599]: no method named `kind` found for struct `playground::Error` in the current scope --> examples/report.rs:4:21 | 4 | Err(e) if e.kind() == playground::Kind::Important => Err(e)?, | ^^^^ private field, not a method

Als nächstes schalten wir das Record-Query zur Warnung vor totem Code ab und fügen die kind-Methode hinzu (Ausgabe von rustc in Listing 5):

#![allow(dead_code)] impl Error { pub fn kind(&self) -> Kind { self.kind } }

Listing 5

› cargo check --example report Checking playground v0.1.0 (/home/jlusby/playground) error[E0507]: cannot move out of `self.kind` which is behind a shared reference --> src/lib.rs:18:9 | 18 | self.kind | ^^^^^^^^^ move occurs because `self.kind` has type `Kind`, which does not implement the `Copy` trait error: aborting due to previous error

Hupsi, da habe ich doch tatsächlich vergessen, copy via impl zu implementieren. Das holen wir nun nach und fügen das Record-Query hinzu, außerdem Debug, wenn wir schon dabei sind (Ausgabe von rustc in Listing 6):

#[derive(Debug, Copy, Clone)] #[non_exhaustive] pub enum Kind { Important, NotImportant, }

Listing 6

› cargo check --example report Checking playground v0.1.0 (/home/jlusby/playground) error[E0369]: binary operation `==` cannot be applied to type `Kind` --> examples/report.rs:4:28 | 4 | Err(e) if e.kind() == playground::Kind::Important => Err(e)?, | -------- ^^ --------------------------- Kind | | | Kind error[E0277]: the trait bound `playground::Error: std::error::Error` is not satisfied --> examples/report.rs:4:68 | 4 | Err(e) if e.kind() == playground::Kind::Important => Err(e)?, | ^ the trait `std::error::Error` is not implemented for `playground::Error` | = note: required because of the requirements on the impl of `From<playground::Error>` for `Report` = note: required by `from`

Nun scheint es an der Zeit zu sein, Kind und Error ein paar weitere Eigenschaften zu verleihen. Wenn ich mich recht erinnere, benötigt man nur PartialEq, um == zu verwenden, auch wenn ich überrascht bin, dass die Fehlermeldung in Listing 7 nicht darauf hinweist.

Listing 7

// struct Error { ... } impl std::error::Error for Error {} #[derive(Debug, Copy, Clone, PartialEq)] #[non_exhaustive] pub enum Kind { Important, NotImportant, }

rustc wird mir nun sagen, dass die Implementierungen Debug und Display in Error hinzugefügt werden müssen, also mache ich das eben schon einmal vorweg schnell selbst (Listing 8). Ich weiß zwar jetzt schon, dass ich das später noch ändern werde, aber um es für das Beispiel leichter zu machen, nutze ich einfach die Display-Implementierung in Kind als Quelle der Display-Implementierung in Error. Das kann ich später noch ändern, wenn ich die Funktion .wrap_err hinzufüge.

Listing 8

use std::fmt; impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.kind.fmt(f) } } impl fmt::Display for Kind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use Kind::*; match self { Important => write!(f, "this error was important to us"), NotImportant => write!(f, "this error was not a place of honor"), } } }

Nun sollte rustc glücklich sein, schauen wir uns einmal an, was es sagt (Listing 9).

Listing 9

› cargo check --example report warning: returning an `Err(_)` with the `?` operator --> examples/report.rs:4:62 | 4 | Err(e) if e.kind() == playground::Kind::Important => Err(e)?, | ^^^^^^^ help: try this: `return Err(e.into())` | = note: `#[warn(clippy::try_err)]` on by default = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#try_err warning: returning an `Err(_)` with the `?` operator --> examples/report.rs:10:62 | 10 | Err(e) if e.kind() == playground::Kind::Important => Err(e)?, | ^^^^^^^ help: try this: `return Err(e.into())` | = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#try_err warning: 2 warnings emitted Finished dev [unoptimized + debuginfo] target(s) in 0.00s

Nun denke ich mir: „Ich sollte diese Warnung in clippy wirklich deaktivieren, uff“. Gesagt, getan:

#![allow(clippy::try_err)]

Das sollte funktionieren, schauen wir uns ...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Gewinner-Teams

Wir bieten Lizenz-Lösungen für Teams jeder Größe: Finden Sie heraus, welche Lösung am besten zu Ihnen passt.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang