Error-Handling ist ein integraler Bestandteil jedes professionellen Software-Produkts. In Rust funktioniert das Error-Handling anders als in herkömmlichen Programmiersprachen wie C#, C++ oder Python. In diesen Sprachen wirft eine Funktion, die fehlschlagen kann, eine Exception. Diese Exception kann weiter oben im Call-Stack behandelt werden. Rust verfolgt einen anderen Ansatz und macht mögliche Fehlerfälle in der Funktionssignatur sichtbar und erzwingt damit explizites Error-Handling.
Konkret geht es hier um «recoverable errors», also Fehler, die nicht direkt zum Absturz des Programms führen, sondern behandelbar sind. Bei einem solchen Fehler können wir etwas machen und sei es nur einen Log-Eintrag erstellen und das Programm organisiert beenden.
Quiz
Testen Sie Ihr Rust-Wissen!
Fundamentale Bausteine des Error-Handlings in Rust
Man sieht anhand des Rückgabetyps in einer Funktionssignatur, ob eine Funktion fehlschlagen kann:
pub fn fallible_foo<T>(input: &str) -> Result<T, ErrorType>
Das Resultat kann entweder vom gewünschten Typ T sein oder ein Fehler vom Typ ErrorType.
Als Benutzer dieser Funktion untersuchen wir das Result mittels der match Expression:
let result = match fallible_foo("something") {
Ok(result) => result,
Err(e) => {
// Deal with the error or pass it on
println!("Error: {}", e);
}
};
Um einen allfälligen Fehler weiterzureichen, muss das Resultat nicht zwingend explizit untersucht werden, sondern der «?»-Operator kann eingesetzt werden:
fn other_fallible_foo() -> Result<String, ErrorType> {
let result = fallible_foo("something")?;
//Some logic here
Ok(result)
}
Diese fundamentalen Bausteine des Error-Handlings sind im Rust-Buch detailliert beschrieben. Im Folgenden soll deutlich werden, wie diese Bausteine zu einem konsistenten Error-Handling-Konzept in einer Applikation kombiniert werden.
Ziel eines professionellen Error-Handlings
Error-Handling ist ein architektonisches Querschnittsthema, wofür es ein Konzept braucht, das in der gesamten Software durchgezogen werden sollte. Je nach Anwendungsfall ist die Zielsetzung für das Error-Handling verschieden. Für eine Library, die in einem grösseren Kontext eingesetzt werden kann, sind folgende Ziele sinnvoll:
- Innerhalb der Library werden diejenigen Fehler behandelt, die behandelt werden können. Um verschiedene Fehler zu behandeln, müssen sie voneinander unterscheidbar sein.
- Gegen aussen wird nur mitgeteilt, was schief gegangen ist, zusammen mit genügend Kontext.
- Angestrebt werden soll ein mächtiges und solides Error-Handling-Konzept mit möglichst wenig Boiler-Plate-Code.
Ersetzt Rust bald C++?
Das Rennen der Programmiersprachen
Error-Handling-Konzept mit mehreren Schichten
In dieser Betrachtung existiert eine Architektur nach dem Zwiebelschalenprinzip. Das heisst, dass äussere Schichten von inneren Schichten abhängen. So haben die äusseren Schichten Kenntnis der Typen, Funktionen und Methoden der inneren Schichten, aber nicht umgekehrt.
Innerste Schicht: Domäne
Um Fehler voneinander unterscheiden zu können, liegt die Verwendung von Custom Error Types nahe. Implementiert werden diese mittels Enumerations. Eine nützliche Crate, damit diese Errors komfortabel weitergereicht und mit Kontext angereichter werden können, ist «thiserror».
In der Domäne gibt es Funktionen, die fehlschlagen können. Dies ist signalisiert mit entsprechenden Error-Typen und Funktionssignaturen.
#[derive(Debug, Error)]
pub enum DomainError {
#[error("Invalid Input {0}")]
EmptyInput(String),
#[error("Processing failed")]
ProcessFailure,
}
pub fn domain_fallible_process(input: &str) -> Result<String, DomainError> {
if input.is_empty() {
return Err(DomainError::EmptyInput(format!("Input {} is empty", input)));
}
// logic
if processing_is_ok() {
Ok(input.to_string())
} else {
Err(DomainError::ProcessFailure)
}
}
Das Error-Macro (#[error(“…”)]), von thiserror automatisiert die Implementation des Display- und Error-Traits der Error-Enums. Dies erspart repetitive Schreibarbeit.
Wenn eine Operation fehlschlägt, wird der entsprechende Error zurückgegeben, angereichert mit dem Text im Error-Macro.
Rust Transition Service
Sicher in die Zukunft
Mittlere Schicht: Use Cases
In der Use-Case-Schicht können die Errors einfach weitergereicht werden mit explizitem Error-Matching oder, falls eine Konversion existiert, mit dem «?»-Operator.
#[derive(Debug, Error)]
pub enum UseCaseError {
#[error("Domain error: {0}")]
DomainError(#[from] DomainError),
#[error("Validation error: {0}")]
ValidationError(String),
}
Eine Konversion zu einem anderen Error-Typ kann mit dem #[from]-Macro automatisiert werden. Im Beispiel wird ein DomainError in einen UseCase::DomainError verpackt.
Je nach Error-Typ und Situation kann ein Fehler in dieser Schicht direkt behandelt werden:
pub fn usecase_handle_some_errors(input: &str) -> Result<String, UseCaseError> {
let domain_result = match domain_fallible_process(input) {
Ok(r) => r,
Err(DomainError::EmptyInput(_)) => {
let improved_input = format!("sensible_improvement_{}", input);
domain_fallible_process(&improved_input)?
}
Err(e) => return Err(UseCaseError::from(e)),
};
// logic
Ok(format!("Use Case processed: '{}'", domain_result))
}
In obigem Beispiel wird der Domänenprozess noch einmal aufgerufen, aber mit modifiziertem Argument. Andere Fehler oder wenn der Prozess ein weiteres Mal fehlschlägt, werden als Error dieser Schicht weitergereicht.
Äusserste Schicht: API
Auch API-Funktionen können fehlschlagen. Für die API-Schicht ist es aber sinnvoll, nur einen einzigen Fehlertyp zurückzugeben. So kann die Library weiterentwickelt werden, ohne die API zu ändern. Um trotzdem aussagekräftige Fehlermeldungen zur Verfügung zu stellen, können die Errors mit Kontext angereichter werden. Dazu dient die Crate «anyhow».
pub fn api_fallible_operation(input: &str) -> anyhow::Result<String> {
let use_case_result = usecase_passon_errors(input)
.with_context(|| format!("Failed processsing Input {}", input))?;
// logic
Ok(format!("API processed: {}", use_case_result))
}
In diesem Beispiel werden die Fehler aus der Use-Case-Schicht mit Kontext angereichert und konvertiert, sodass ein uniformes Resultat vom Typ anyhow::Result zurückgegeben wird.
Fazit: Error-Handling-Konzept zur richtigen Intervention
Mit diesem Konzept wird ein explizites, aber leichtgewichtiges Error-Handling erreicht. Das Konzept erlaubt es, Fehler zu unterscheiden und damit auf geeigneter Stufe zu intervenieren. Dies ist ein wichtiger Baustein, um zuverlässige Software zu schreiben.
In diesem Rust Playground gibt es eine lauffähige Version eines einfachen, aber kompletten Error-Handling-Konzepts.
Nehmen Sie mit unseren Experten Kontakt auf und diskutieren das Error-Handling in Ihrem spezifischen Produkt.

Der Experte
Oliver With
Oliver With ist Spezialist für Embedded Software. Als Senior-Entwickler ist er überzeugt, dass eng zusammenarbeitende Teams komplexe Probleme am besten lösen. Kreativität in der Lösungsfindung vereint er mit Qualität in der Entwicklung, um erfolgreiche Produkte zu schaffen. Er ist Rust-Enthusiast, weil es mit Rust erstmals eine Sprache gibt, die Sicherheit, Performance, Akzeptanz in der Industrie und Ergonomie für Entwickler kombiniert.