Wann panic!
aufrufen und wann nicht?
Wie entscheidest du also, wann du panic!
aufrufen und wann Result
zurückgeben sollst? Wenn Code abbricht, gibt es keine Möglichkeit sich vom
Fehler zu erholen. Du könntest panic!
in jeder Fehlersituation aufrufen,
unabhängig davon, ob es eine Möglichkeit zur Fehlerbehebung gibt oder nicht,
aber dann triffst du die Entscheidung für den aufrufenden Code, dass eine
Situation nicht rettbar ist. Wenn du dich dafür entscheidest, einen
Result
-Wert zurückzugeben, überlässt du dem aufrufenden Code die
Wahlmöglichkeit, anstatt die Entscheidung für ihn zu treffen. Der aufrufende
Code könnte sich dafür entscheiden, sich vom Fehler auf eine sinnvolle Weise
zu erholen, oder er könnte sich dafür entscheiden, dass ein Err
-Wert in
diesem Fall nicht behebbar ist und panic!
aufrufen, und so deinen behebbaren
Fehler in einen nicht behebbaren verwandeln. Daher ist die Rückgabe von
Result
eine gute Standardwahl, wenn du eine Funktion definierst, die
fehlschlagen könnte.
In Beispielen, Prototyp-Code und Tests ist es sinnvoller, Code zu schreiben,
der das Programm abbricht, anstatt ein Result
zurückzugeben. Lass uns
herausfinden, warum das so ist, und dann Situationen besprechen, in denen der
Compiler nicht feststellen kann, dass ein Fehler unmöglich ist, du als Mensch
aber schon. Das Kapitel schließt mit einigen allgemeinen Richtlinien zur
Entscheidung, ob in Bibliothekscode ein Programm abgebrochen werden soll.
Beispiele, Code-Prototypen und Tests
Wenn du ein Beispiel schreibst, um ein Konzept zu veranschaulichen, kann die
Einbeziehung von robustem Fehlerbehandlungscode das Beispiel unklarer machen.
In Beispielen wird davon ausgegangen, dass der Aufruf einer Methode wie
unwrap
, die das Programm abbrechen könnte, als Platzhalter für die Art und
Weise gedacht ist, wie deine Anwendung mit Fehlern umgehen soll, die je
nachdem, was der Rest deines Codes tut, unterschiedlich sein können.
In ähnlicher Weise sind die Methoden unwrap
und expect
bei Prototypen sehr
praktisch, wenn du noch nicht entscheiden willst, wie mit Fehlern umzugehen
ist. Du hinterlässt klare Markierungen in deinem Code für später, wenn du dein
Programm robuster machst.
Wenn ein Methodenaufruf in einem Test fehlschlägt, würdest du wollen, dass der
gesamte Test fehlschlägt, auch wenn diese Methode nicht die zu testende
Funktionalität ist. Da ein Test mit panic!
als fehlgeschlagen markiert wird,
ist der Aufruf von unwrap
und expect
genau das, was passieren sollte.
Fälle, in denen du mehr Informationen als der Compiler hast
Es wäre auch sinnvoll, unwrap
oder expect
aufzurufen, wenn du eine andere
Logik hast, die sicherstellt, dass Result
einen Ok
-Wert hat, aber die Logik
kann vom Compiler nicht verstanden werden. Du wirst immer noch ein Result
haben, mit dem du umgehen musst: Welche Operation auch immer du aufrufst, es
besteht immer noch die Möglichkeit, dass sie im Allgemeinen scheitert, auch
wenn es in deiner speziellen Situation logischerweise unmöglich ist. Wenn du
durch manuelle Codeinspektion sicherstellen kannst, dass du niemals eine
Err
-Variante haben wirst, ist es vollkommen akzeptabel, unwrap
aufzurufen,
und noch besser ist es, den Grund, warum du glaubst, dass du niemals eine
Err
-Variante haben wirst, im expect
-Text zu dokumentieren. Hier ist ein
Beispiel:
#![allow(unused)] fn main() { use std::net::IpAddr; let home: IpAddr = "127.0.0.1" .parse() .expect("Fest programmierte IP-Adresse sollte gültig sein"); }
Wir erstellen eine IpAddr
-Instanz, indem wir eine hartkodierte Zeichenkette
parsen. Wir können sehen, dass 127.0.0.1
eine gültige IP-Adresse ist, sodass
es akzeptabel ist, hier expect
zu verwenden. Eine hartkodierte, gültige
Zeichenkette ändert jedoch nicht den Rückgabetyp der parse
-Methode: Wir
erhalten immer noch einen Result
-Wert und der Compiler wird von uns
verlangen, Result
so zu behandeln, als ob die Err
-Variante möglich wäre,
weil der Compiler nicht klug genug ist, um zu erkennen, dass diese
Zeichenkette stets eine gültige IP-Adresse ist. Wenn die
IP-Adressen-Zeichenkette von einem Benutzer kam, anstatt fest im Programm
kodiert zu sein, und daher möglicherweise fehlschlagen könnte, würden wir
stattdessen definitiv Result
auf eine robustere Weise behandeln wollen.
Richtlinien zur Fehlerbehandlung
Es ist ratsam, dass dein Code abbricht, wenn es möglich ist, dass dein Code in einem schlechten Zustand enden könnte. In diesem Zusammenhang ist ein schlechter Zustand (bad state) dann gegeben, wenn eine Annahme, eine Garantie, ein Vertrag oder eine Invariante gebrochen wurde, z.B. wenn ungültige Werte, widersprüchliche Werte oder fehlende Werte an deinen Code übergeben werden – sowie einer oder mehrere der folgenden Punkte zutreffen:
- Der schlechte Zustand ist etwas Unerwartetes, im Gegensatz zu etwas, das wahrscheinlich gelegentlich vorkommt, wie die Eingabe von Daten in einem falschen Format durch einen Benutzer.
- Dein Code muss sich nach diesem Punkt darauf verlassen können, dass er sich in keinem schlechten Zustand befindet, anstatt bei jedem Schritt auf das Problem zu prüfen.
- Es gibt keine gute Möglichkeit, diese Informationen in den von dir verwendeten Typen zu kodieren. Wir werden im Abschnitt „Kodieren von Zuständen und Verhalten als Typen“ in Kapitel 17 ein Beispiel dafür durcharbeiten.
Wenn jemand deinen Code aufruft und Werte übergibt, die keinen Sinn ergeben,
ist es am besten, einen Fehler zurückzugeben, damit der Benutzer der Bibliothek
entscheiden kann, was er in diesem Fall tun möchte. In Fällen, in denen eine
Fortsetzung unsicher oder schädlich sein könnte, ist es jedoch am besten,
panic!
aufzurufen und die Person, die deine Bibliothek verwendet, auf den
Fehler in ihrem Code hinzuweisen, damit sie ihn während der Entwicklung beheben
kann. In ähnlicher Weise ist panic!
oft angebracht, wenn du externen Code
aufrufst, der sich deiner Kontrolle entzieht und einen ungültigen Zustand
zurückgibt, den du nicht beheben kannst.
Wenn jedoch ein Fehler erwartet wird, ist es sinnvoller, ein Result
zurückzugeben, als panic!
aufzurufen. Beispiele hierfür sind ein Parser, dem
fehlerhafte Daten übergeben werden, oder eine HTTP-Anfrage, die einen Status
zurückgibt, der anzeigt, dass du ein Aufruflimit erreicht hast. In diesen
Fällen zeigt der Rückgabetyp Result
an, dass ein Fehler eine erwartete
Möglichkeit ist, bei der der aufrufende Code entscheiden muss, wie er damit
umgeht.
Wenn dein Code einen Vorgang ausführt, der einen Benutzer gefährden könnte,
wenn er mit ungültigen Werten aufgerufen wird, sollte dein Code zuerst
überprüfen, ob die Werte gültig sind, und das Programm abbrechen, wenn die
Werte nicht gültig sind. Dies geschieht hauptsächlich aus Sicherheitsgründen:
Der Versuch, mit ungültigen Daten zu operieren, kann deinen Code Schwachstellen
aussetzen. Dies ist der Hauptgrund dafür, dass die Standardbibliothek panic!
aufruft, wenn du versuchst, einen unzulässigen Speicherzugriff durchzuführen:
Der Versuch, auf Speicher zuzugreifen, der nicht zur aktuellen Datenstruktur
gehört, ist ein häufiges Sicherheitsproblem. Funktionen haben oft Verträge
(contracts): Ihr Verhalten ist nur dann garantiert, wenn die Eingaben bestimmte
Anforderungen erfüllen. Abzubrechen, wenn der Vertrag verletzt wird, ist
sinnvoll, weil eine Vertragsverletzung immer auf einen Fehler auf der
Aufruferseite hinweist und es sich nicht um eine Fehlerart handelt, die der
aufgerufende Code explizit behandeln sollte. Tatsächlich gibt es keinen
vernünftigen Weg, wie sich der aufrufende Code vom Fehler erholen kann; die
aufrufenden Programmierer müssen den Code reparieren. Verträge zu einer
Funktion sollten in der API-Dokumentation der Funktion erläutert werden,
insbesondere wenn deren Verletzung zu einem Programmabbruch führt.
Zahlreiche Fehlerprüfungen in deinen Funktionen wären jedoch langatmig und
störend. Glücklicherweise kannst du das Typsystem von Rust (und damit die
Typprüfung durch den Compiler) verwenden, um viele Prüfungen für dich zu
übernehmen. Wenn deine Funktion einen besonderen Typ als Parameter hat, kannst
du mit der Logik deines Codes fortfahren, da du weißt, dass der Compiler
bereits sichergestellt hat, dass du einen gültigen Wert hast. Wenn du zum
Beispiel einen Typ anstatt einer Option
hast, erwartet dein Programm etwas
statt nichts. Dein Code muss dann nicht zwei Fälle für die Varianten Some
und None
behandeln: Er wird nur einen Fall mit definitiv einem Wert haben.
Code, der versucht, nichts an deine Funktion zu übergeben, lässt sich nicht
einmal kompilieren, sodass deine Funktion diesen Fall zur Laufzeit nicht prüfen
muss. Ein anderes Beispiel ist die Verwendung eines vorzeichenlosen
Ganzzahl-Typs wie u32
, der sicherstellt, dass der Parameter niemals negativ
ist.
Benutzerdefinierte Typen für die Validierung erstellen
Gehen wir noch einen Schritt weiter, indem wir das Typsystem von Rust verwenden, um sicherzustellen, dass wir einen gültigen Wert haben, und betrachten wir die Erstellung eines benutzerdefinierten Typs für die Validierung. Erinnere dich an das Ratespiel in Kapitel 2, bei dem unser Code den Benutzer aufforderte, eine Zahl zwischen 1 und 100 zu erraten. Wir haben nie überprüft, ob die Schätzung des Benutzers zwischen diesen Zahlen lag, bevor wir sie mit unserer Geheimzahl verglichen haben; wir haben nur überprüft, ob die Schätzung richtig war. In diesem Fall waren die Folgen nicht sehr gravierend: Unsere Ausgabe von „zu groß“ oder „zu klein“ wäre immer noch richtig. Aber es wäre eine nützliche Erweiterung, um den Benutzer zu gültigen Rateversuchen zu führen und ein unterschiedliches Verhalten zu zeigen, wenn der Benutzer eine Zahl eingibt, die außerhalb des Bereichs liegt, als wenn der Benutzer stattdessen z.B. Buchstaben eingibt.
Eine Möglichkeit, dies zu tun, wäre, die Eingabe als i32
statt nur als u32
zu parsen, um potenziell negative Zahlen zuzulassen, und dann eine
Bereichsprüfung der Zahl zu ergänzen, etwa so:
Dateiname: src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Rate eine Zahl!");
let secret_number = rand::thread_rng().gen_range(1, 101);
loop {
// --abschneiden--
println!("Bitte gib deine Vermutung ein.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Fehler beim Lesen der Zeile");
let guess: i32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
if guess < 1 || guess > 100 {
println!("Die geheime Zahl wird zwischen 1 und 100 liegen.");
continue;
}
match guess.cmp(&secret_number) {
// --abschneiden--
Ordering::Less => println!("Zu klein!"),
Ordering::Greater => println!("Zu groß!"),
Ordering::Equal => {
println!("Du hast gewonnen!");
break;
}
}
}
}
Der if
-Ausdruck prüft, ob unser Wert außerhalb des Bereichs liegt, informiert
den Benutzer über das Problem und ruft continue
auf, um die nächste Iteration
der Schleife zu starten und um eine weitere Schätzung zu bitten. Nach dem
if
-Ausdruck können wir mit dem Vergleich zwischen guess
und der Geheimzahl
fortfahren, wobei wir wissen, dass guess
zwischen 1 und 100 liegt.
Dies ist jedoch keine ideale Lösung: Wenn es zwingend erforderlich wäre, dass das Programm nur mit Werten zwischen 1 und 100 arbeitet, und wir viele Funktionen mit dieser Anforderung haben, wäre eine solche Prüfung in jeder Funktion mühsam (und könnte die Leistung beeinträchtigen).
Stattdessen können wir einen neuen Typ erstellen und die Validierungen in eine
Funktion geben, um eine Instanz des Typs zu erzeugen, anstatt die Validierungen
überall zu wiederholen. Auf diese Weise ist es für die Funktionen sicher, den
neuen Typ in ihren Signaturen zu verwenden und die erhaltenen Werte
bedenkenlos zu nutzen. Codeblock 9-13 zeigt eine Möglichkeit, einen Typ
Guess
zu definieren, der nur dann eine Instanz von Guess
erzeugt, wenn die
Funktion new
einen Wert zwischen 1 und 100 erhält.
Dateiname: src/main.rs
#![allow(unused)] fn main() { pub struct Guess { value: i32, } impl Guess { pub fn new(value: i32) -> Guess { if value < 1 || value > 100 { panic!("Der Schätzwert muss zwischen 1 und 100 liegen, ist jedoch {value}."); } Guess { value } } pub fn value(&self) -> i32 { self.value } } }
Zuerst definieren wir eine Struktur Guess
, die ein Feld value
hat, das
einen i32
enthält. Hier wird die Nummer gespeichert.
Dann implementieren wir die zugehörige Funktion new
für Guess
, die
Instanzen von Guess
erzeugt. Die Funktion new
ist so definiert, dass sie
einen Parameter value
vom Typ i32
entgegen nimmt und eine Guess
-Instanz
zurückgibt. Der Code im Funktionsrumpf von new
testet den Wert in value
, um
sicherzustellen, dass er zwischen 1 und 100 liegt. Wenn value
diesen Test
nicht besteht, rufen wir panic!
auf, was den Programmierer des aufrufenden
Codes darauf aufmerksam macht, dass er einen Fehler hat, den er beheben muss,
denn ein Guess
mit einem Wert außerhalb dieses Bereichs zu erzeugen, würde
den Vertrag verletzen, auf den sich Guess::new
verlässt. Die Bedingungen,
unter denen Guess::new
das Programm abbricht, sollten in der öffentlich
zugänglichen API-Dokumentation genannt werden; wir werden die
Dokumentationskonventionen, die auf die Möglichkeit eines panic!
-Aufrufs
hinweisen, in der API-Dokumentation behandeln, die du in Kapitel 14 erstellst.
Wenn value
den Test besteht, erstellen wir eine neue Guess
-Instanz, deren
Feld value
den Parameterwert value
erhält, und geben die Instanz zurück.
Als nächstes implementieren wir eine Methode namens value
, die self
ausleiht, keine anderen Parameter hat und ein i32
zurückgibt. Diese
Methodenart wird manchmal als Abfragemethode (getter) bezeichnet, weil ihr
Zweck darin besteht, Daten aus ihren Feldern zurückzugeben. Diese öffentliche
Methode ist notwendig, weil das Feld value
der Struktur Guess
privat ist.
Es ist wichtig, dass das Feld value
privat ist, damit Code, der die Struktur
Guess
verwendet, value
nicht direkt setzen kann: Code außerhalb des Moduls
muss die Funktion Guess::new
verwenden, um eine Instanz von Guess
zu
erzeugen, wodurch sichergestellt wird, dass es keine Möglichkeit gibt, dass
Guess
einen Wert
hat, der nicht durch die Bedingungen in der Funktion
Guess::new
überprüft wurde.
Eine Funktion, die einen Parameter hat oder nur Zahlen zwischen 1 und 100
zurückgibt, könnte dann in ihrer Signatur angeben, dass sie ein Guess
anstelle eines i32
entgegennimmt oder zurückgibt und bräuchte dann in ihrem
Rumpf keine zusätzlichen Prüfungen durchzuführen.
Zusammenfassung
Die Fehlerbehandlungsfunktionen von Rust sollen dir helfen, robusteren Code zu
schreiben. Das Makro panic!
signalisiert, dass sich dein Programm in einem
Zustand befindet, mit dem es nicht umgehen kann, und ermöglicht es dir, den
Prozess anzuhalten, anstatt zu versuchen, mit ungültigen oder falschen Werten
fortzufahren. Die Aufzählung Result
verwendet das Typsystem von Rust, um
anzuzeigen, dass Operationen so fehlschlagen könnten, dass dein Code sich davon
wieder erholen könnte. Du kannst Result
verwenden, um dem Code, der deinen
Code aufruft, mitzuteilen, dass er auch mit potentiellem Erfolg und Misserfolg
umgehen muss. Das Verwenden von panic!
und Result
in den entsprechenden
Situationen wird deinen Code angesichts unvermeidlicher Probleme zuverlässiger
machen.
Nachdem du nun nützliche Möglichkeiten gesehen hast, wie die Standardbibliothek
generische Datentypen mit den Enums Option
und Result
verwendet, werden wir
darüber sprechen, wie generische Datentypen funktionieren und wie du sie in
deinem Code verwenden kannst.