Refactoring zum Verbessern der Modularität und Fehlerbehandlung
Um unser Programm zu verbessern, werden wir vier Probleme beheben, die mit der
Struktur des Programms und dem Umgang mit potenziellen Fehlern zu tun haben.
Erstens erfüllt unsere Funktion main jetzt zwei Aufgaben: Sie parst Argumente
und liest Dateien. Für eine so kleine Funktion ist dies kein großes Problem.
Wenn wir jedoch unser Programm innerhalb der Funktion main weiter ausbauen,
wird die Anzahl der einzelnen Aufgaben, die die Funktion main bearbeitet,
zunehmen. In dem Maße, wie eine Funktion an Verantwortung hinzugewinnt, wird es
schwieriger, sie zu verstehen, sie zu testen und sie zu ändern, ohne dass eines
ihrer Teile kaputtgeht. Am besten ist es, die Funktionalität so aufzuteilen,
dass jede Funktion für eine Aufgabe zuständig ist.
Diese Frage hängt auch mit dem zweiten Problem zusammen: Obwohl query und
file_path Konfigurationsvariablen unseres Programms sind, werden Variablen
wie contents verwendet, um die Logik des Programms umzusetzen. Je länger
main wird, desto mehr Variablen müssen wir in den Gültigkeitsbereich bringen;
je mehr Variablen wir im Gültigkeitsbereich haben, desto schwieriger wird es,
den Zweck der einzelnen Variablen im Auge zu behalten. Es ist am besten, die
Konfigurationsvariablen in einer Struktur zu gruppieren, um ihren Zweck zu
verdeutlichen.
Das dritte Problem ist, dass wir expect benutzt haben, um eine Fehlermeldung
auszugeben, wenn das Lesen der Datei fehlschlägt, aber die Fehlermeldung gibt
nur Sollte die Datei lesen können aus. Das Lesen einer Datei kann
auf verschiedene Arten fehlschlagen: Zum Beispiel könnte die Datei fehlen oder
wir haben keine Berechtigung, sie zu öffnen. Im Moment würden wir unabhängig
von der Situation die Fehlermeldung „Etwas ging beim Lesen der Datei schief“
ausgeben, die dem Benutzer keinerlei Informationen geben würde!
Viertens verwenden wir expect erneut, um einen Fehler zu behandeln, und wenn
der Benutzer unser Programm ausführt, ohne genügend Argumente anzugeben, erhält
er einen Index out of bounds-Fehler von Rust, der das Problem nicht eindeutig
erklärt. Am besten wäre es, wenn sich der gesamte Fehlerbehandlungscode an
einer Stelle befände, sodass zukünftige Entwickler nur eine Stelle im Code
konsultieren bräuchten, falls sich die Fehlerbehandlungslogik ändern sollte.
Wenn sich der gesamte Fehlerbehandlungscode an einer Stelle befindet, wird auch
sichergestellt, dass wir Meldungen ausgeben, die für unsere Endbenutzer
aussagekräftig sind.
Lass uns diese vier Probleme mittels Refactoring angehen.
Trennen der Zuständigkeiten in Binärprojekten
Das organisatorische Problem der Zuweisung der Verantwortung für mehrere
Aufgaben an die Funktion main ist vielen Binärprojekten gemein.
Infolgedessen hat die Rust-Gemeinschaft eine Richtlinie für die Aufteilung der
einzelnen Aufgaben eines Binärprogramms entwickelt, wenn die Funktion main
groß wird. Dieser Prozess umfasst die folgenden Schritte:
- Teile dein Programm in die Dateien main.rs und lib.rs auf und verschiebe die Logik deines Programms in lib.rs.
- Solange deine Kommandozeilen-Parselogik klein ist, kann sie in main.rs bleiben.
- Wenn die Kommandozeilen-Parselogik anfängt, kompliziert zu werden, extrahiere sie aus main.rs und verschiebe sie in lib.rs.
Die Verantwortlichkeiten, die nach diesem Prozess in der Funktion main
verbleiben, sollten sich auf Folgendes beschränken:
- Aufrufen der Kommandozeilen-Parselogik mit den Argumentwerten
- Aufbauen weiterer Konfiguration
- Aufrufen einer Funktion
runin lib.rs - Behandeln des Fehlers, wenn
runeinen Fehler zurückgibt
Bei diesem Muster geht es darum, Verantwortlichkeiten zu trennen: main.rs
kümmert sich um die Ausführung des Programms und lib.rs kümmert sich um die
gesamte Logik der anstehenden Aufgabe. Da du die Funktion main nicht direkt
testen kannst, kannst du mit dieser Struktur die gesamte Logik deines Programms
testen, indem du sie in Funktionen in lib.rs verschiebst. Der Code, der in
main.rs verbleibt, wird klein genug sein, um seine Korrektheit durch Lesen zu
überprüfen. Lass uns unser Programm überarbeiten, indem wir diesem Prozess
folgen.
Extrahieren des Argument-Parsers
Wir werden die Funktionalität für das Parsen von Argumenten in eine Funktion
extrahieren, die von main aufgerufen wird, um das Verschieben der
Kommandozeilen-Parselogik nach src/lib.rs vorzubereiten. Listing 12-5 zeigt
den neuen Anfang von main, der eine neue Funktion parse_config aufruft, die
wir vorerst in src/main.rs definieren werden.
Dateiname: src/main.rs
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let (query, file_path) = parse_config(&args);
// --abschneiden--
println!("Suche nach {query}");
println!("In Datei {file_path}");
let contents = fs::read_to_string(file_path)
.expect("Etwas ging beim Lesen der Datei schief");
println!("Mit Text:\n{contents}");
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}
Listing 12-5: Extrahieren einer Funktion parse_config
aus main
Wir sammeln immer noch die Kommandozeilenargumente in einem Vektor, aber
anstatt den Argumentwert am Index 1 der Variablen query und den Argumentwert
am Index 2 der Variablen file_path innerhalb der Funktion main zuzuweisen,
übergeben wir den gesamten Vektor an die Funktion parse_config. Die Funktion
parse_config enthält dann die Logik, die bestimmt, welches Argument in welche
Variable geht und die Werte an main zurückgibt. Wir erstellen immer noch die
Variablen query und file_path in main, aber main hat nicht mehr die
Verantwortung zu bestimmen, wie die Kommandozeilenargumente und Variablen
zusammenpassen.
Dieses Überarbeiten mag für unser kleines Programm übertrieben erscheinen, aber wir führen die Refactoring-Maßnahmen in kleinen, inkrementellen Schritten durch. Nachdem du diese Änderung vorgenommen hast, führe das Programm erneut aus, um zu überprüfen, ob das Argumentparsen noch funktioniert. Es ist gut, den Fortschritt oft zu überprüfen, um die Ursache von Problemen zu erkennen, wenn sie auftreten.
Gruppieren von Konfigurationswerten
Wir können einen weiteren kleinen Schritt tun, um die Funktion parse_config
weiter zu verbessern. Im Moment geben wir ein Tupel zurück, aber dann zerlegen
wir dieses Tupel sofort wieder in einzelne Teile. Das ist ein Zeichen dafür,
dass wir vielleicht noch nicht die richtige Abstraktion haben.
Ein weiterer Indikator, der zeigt, dass es Raum für Verbesserungen gibt, ist
der config-Teil von parse_config, der impliziert, dass die beiden von uns
zurückgegebenen Werte miteinander in Beziehung stehen und beide Teil eines
Konfigurationswertes sind. Diese Bedeutung vermitteln wir derzeit nur durch die
Gruppierung der beiden Werte in einem Tupel. Geben wir daher die beiden Werte
in einer Struktur an und geben jedem der Strukturfelder einen aussagekräftigen
Namen. Auf diese Weise wird es künftigen Entwicklern dieses Codes leichter
fallen, zu verstehen, wie die verschiedenen Werte miteinander in Beziehung
stehen und was ihr Zweck ist.
Listing 12-6 zeigt die Verbesserungen der Funktion parse_config.
Dateiname: src/main.rs
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = parse_config(&args);
println!("Suche nach {}", config.query);
println!("In Datei {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Etwas ging beim Lesen der Datei schief");
// --abschneiden--
println!("Mit Text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
fn parse_config(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
Listing 12-6: Refactorieren von parse_config zur
Rückgabe einer Instanz einer Config-Struktur
Wir haben eine Struktur namens Config hinzugefügt, die so definiert ist, dass
sie Felder mit den Namen query und file_path enthält. Die Signatur von
parse_config zeigt nun an, dass sie einen Config-Wert zurückgibt. Im Rumpf
von parse_config, wo wir früher String Slices zurückgegeben haben, die auf
String-Werte in args referenzieren, definieren wir Config jetzt so, dass
es besitzende (owned) String-Werte enthält. Die args-Variable in main ist
der Eigentümer der Argumentwerte und lässt die Funktion parse_config diese nur
ausleihen, was bedeutet, dass wir Rusts Regeln für das Borrowing verletzen
würden, wenn Config versucht, das Eigentum an den Werten in args zu
übernehmen.
Wir könnten die String-Daten auf verschiedene Weise verwalten, aber der
einfachste, wenn auch etwas ineffiziente Weg ist es, die Methode clone der
Werte aufzurufen. Dadurch wird eine vollständige Kopie der Daten erstellt, die
die Config-Instanz besitzen soll, was mehr Zeit und Speicherplatz in Anspruch
nimmt als das Speichern einer Referenz auf die String-Daten. Das Klonen der
Daten macht unseren Code jedoch auch sehr unkompliziert, weil wir die
Lebensdauer der Referenzen nicht verwalten müssen; unter diesen Umständen ist es
ein lohnender Kompromiss, ein wenig Leistung aufzugeben, um Einfachheit zu
bekommen.
Die Kompromisse beim Verwenden von
cloneViele Rust-Entwickler neigen dazu, das Verwenden von
clonezur Lösung von Eigentumsproblemen wegen der Laufzeitkosten zu vermeiden. In Kapitel 13 erfährst du, wie du in solchen Situationen effizientere Methoden einsetzen kannst. Aber für den Moment ist es in Ordnung, ein paar Strings zu kopieren, um weiter voranzukommen, da du diese Kopien nur einmal erstellen wirst und dein Dateipfad und deinen Such-String sehr klein sind. Es ist besser, ein funktionierendes Programm zu haben, das ein bisschen ineffizient ist, als zu versuchen, den Code beim ersten Durchgang zu hyperoptimieren. Je mehr Erfahrung du mit Rust sammelst, desto einfacher wird es, mit der effizientesten Lösung zu beginnen, aber im Moment ist es völlig akzeptabel,cloneaufzurufen.
Wir haben main aktualisiert, sodass es die Instanz von Config, die von
parse_config zurückgegeben wird, in eine Variable namens config setzt, und
wir haben den Code aktualisiert, der vorher die separaten Variablen query und
file_path verwendet hat, sodass er jetzt stattdessen die Felder der
Config-Struktur verwendet.
Nun vermittelt unser Code deutlicher, dass query und file_path zueinander
gehören und dass ihr Zweck darin besteht, die Funktionsweise des Programms zu
konfigurieren. Jeder Code, der diese Werte verwendet, weiß, dass er sie in der
config-Instanz in den für ihren Zweck benannten Feldern findet.
Erstellen eines Konstruktors für Config
Bisher haben wir die Logik, die für das Parsen der Kommandozeilenargumente
verantwortlich ist, aus main extrahiert und in die Funktion parse_config
verschoben. Dies half uns zu erkennen, dass die Werte query und file_path
miteinander in Beziehung stehen und diese Beziehung in unserem Code vermittelt
werden sollte. Wir fügten dann eine Config-Struktur hinzu, um das
Zusammengehören von query und file_path zu benennen und um die Namen der
Werte als Feldnamen der Struktur von der Funktion parse_config zurückgeben zu
können.
Da nun der Zweck der Funktion parse_config darin besteht, eine
Config-Instanz zu erzeugen, können wir parse_config von einer einfachen
Funktion in eine Funktion namens new ändern, die mit der Config-Struktur
assoziiert ist. Durch diese Änderung wird der Code idiomatischer. Wir können
Instanzen von Typen in der Standardbibliothek erstellen, wie bei String,
indem wir String::new aufrufen. In ähnlicher Weise können wir durch Ändern
von parse_config in eine Funktion new, die mit Config assoziiert ist,
Instanzen von Config durch Aufrufen von Config::new erzeugen. Listing
12-7 zeigt die Änderungen, die wir vornehmen müssen.
Dateiname: src/main.rs
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Suche nach {}", config.query);
println!("In Datei {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Etwas ging beim Lesen der Datei schief");
println!("Mit Text:\n{contents}");
// --abschneiden--
}
// --abschneiden--
struct Config {
query: String,
file_path: String,
}
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
Listing 12-7: Ändern von parse_config in
Config::new
Wir haben main aktualisiert, wo wir parse_config aufgerufen haben, um
stattdessen Config::new aufzurufen. Wir haben den Namen von parse_config in
new geändert und ihn innerhalb eines impl-Blocks verschoben, der die
Funktion new mit Config assoziiert. Versuche, diesen Code erneut zu
kompilieren, um sicherzustellen, dass er funktioniert.
Korrigieren der Fehlerbehandlung
Jetzt werden wir daran arbeiten, unsere Fehlerbehandlung zu korrigieren.
Erinnere dich, dass der Versuch, auf die Werte im args-Vektor bei Index 1 oder
Index 2 zuzugreifen, das Programm abbrecht, wenn der Vektor weniger als drei
Elemente enthält. Versuche, das Programm ohne irgendwelche Argumente laufen zu
lassen; es wird so aussehen:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Die Zeile index out of bounds: the len is 1 but the index is 1 ist eine für
Programmierer bestimmte Fehlermeldung. Sie wird unseren Endbenutzern nicht
helfen zu verstehen, was sie stattdessen tun sollten. Lass uns das jetzt
korrigieren.
Verbessern der Fehlermeldung
In Listing 12-8 fügen wir eine Prüfung in der Funktion new hinzu, die
überprüft, ob der Slice lang genug ist, bevor auf Index 1 und Index 2
zugegriffen wird. Wenn der Slice nicht lang genug ist, bricht das Programm ab
und zeigt eine bessere Fehlermeldung an.
Dateiname: src/main.rs
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Suche nach {}", config.query);
println!("In Datei {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Etwas ging beim Lesen der Datei schief");
println!("Mit Text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
impl Config {
// --abschneiden--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("Nicht genügend Argumente");
}
// --abschneiden--
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
Listing 12-8: Hinzufügen einer Prüfung für die Anzahl der Argumente
Dieser Code ähnelt der Funktion Guess::new, die wir in Listing
9-13 geschrieben haben, wo wir panic! aufgerufen haben,
wenn das Argument value außerhalb des gültigen Wertebereichs lag. Anstatt hier
auf einen Wertebereich zu prüfen, prüfen wir, ob die Länge von args mindestens
3 beträgt und der Rest der Funktion unter der Annahme arbeiten kann, dass
diese Bedingung erfüllt ist. Wenn args weniger als drei Elemente hat, wird
diese Bedingung true und wir rufen das Makro panic! auf, um das Programm
sofort abzubrechen.
Mit diesen zusätzlichen wenigen Zeilen Code in new lassen wir das Programm
ohne Argumente erneut laufen, um zu sehen, wie der Fehler jetzt aussieht:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:26:13:
Nicht genügend Argumente
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Diese Ausgabe ist besser: Wir haben jetzt eine vernünftige Fehlermeldung. Wir
haben jedoch auch irrelevante Informationen, die wir unseren Benutzern nicht
geben wollen. Vielleicht ist die Technik, die wir in Listing 9-13 verwendet
haben, hier nicht die beste: Das Aufrufen von panic! ist für ein
Programmierproblem besser geeignet als für ein Nutzungsproblem, wie in Kapitel
9 besprochen. Stattdessen können wir die andere Technik
verwenden, über die du in Kapitel 9 gelernt hast – Rückgabe eines
Result um entweder Erfolg oder einen Fehler anzuzeigen.
Zurückgeben eines Result anstatt panic! aufzurufen
Wir können stattdessen einen Result-Wert zurückgeben, der im erfolgreichen
Fall eine Config-Instanz enthält und im Fehlerfall das Problem beschreibt.
Wir werden auch den Namen der Funktion von new in build ändern, weil viele
Programmierer erwarten, dass new-Funktionen niemals fehlschlagen. Wenn
Config::build mit main kommuniziert, können wir den Result-Typ verwenden,
um zu signalisieren, dass ein Problem aufgetreten ist. Dann können wir main
ändern, um eine Err-Variante in einen praktikableren Fehler für unsere
Benutzer umzuwandeln, ohne den umgebenden Text über thread 'main' und
RUST_BACKTRACE, den ein Aufruf von panic! verursacht.
Listing 12-9 zeigt die Änderungen, die wir am Rückgabewert der Funktion, die
nun Config::build aufruft, und am Funktionsrumpf vornehmen müssen, um ein
Result zurückzugeben. Beachte, dass dies nicht kompiliert werden kann, bis
wir auch main aktualisieren, was wir im nächsten Listing tun werden.
Dateiname: src/main.rs
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Suche nach {}", config.query);
println!("In Datei {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Etwas ging beim Lesen der Datei schief");
println!("Mit Text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("Nicht genügend Argumente");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
Listing 12-9: Rückgabe eines Result von
Config::build
Unsere Funktion build liefert ein Result mit einer Config-Instanz im
Erfolgsfall und ein String-Literal im Fehlerfall. Unsere Fehlerwerte werden
immer String-Literale sein, die eine 'static Lebensdauer haben.
Wir haben zwei Änderungen im Rumpf der Funktion vorgenommen: Anstatt panic!
aufzurufen, wenn der Benutzer nicht genug Argumente übergibt, geben wir jetzt
einen Err-Wert zurück, und wir haben den Config-Rückgabewert in ein Ok
verpackt. Diese Änderungen machen die Funktion konform mit ihrer neuen
Typsignatur.
Die Rückgabe eines Err-Wertes aus Config::build erlaubt es der Funktion
main, den von der Funktion build zurückgegebenen Result-Wert zu verarbeiten
und den Prozess im Fehlerfall sauberer zu beenden.
Aufrufen von Config::build und Behandeln von Fehlern
Um den Fehlerfall zu behandeln und eine benutzerfreundliche Meldung auszugeben,
müssen wir main aktualisieren, um das von Config::build zurückgegebene
Result zu behandeln, wie in Listing 12-10 gezeigt. Wir werden auch die
Verantwortung dafür übernehmen, das Kommandozeilenwerkzeug mit einem Fehlercode
ungleich Null wie bei panic! zu beenden und es von Hand zu implementieren.
Ein Exit-Status ungleich Null ist eine Konvention, um dem Prozess, der unser
Programm aufgerufen hat, zu signalisieren, dass das Programm mit einem
Fehlerstatus beendet wurde.
Dateiname: src/main.rs
use std::env;
use std::fs;
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Fehler beim Parsen der Argumente: {err}");
process::exit(1);
});
// --abschneiden--
println!("Suche nach {}", config.query);
println!("In Datei {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Etwas ging beim Lesen der Datei schief");
println!("Mit Text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("Nicht genügend Argumente");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
Listing 12-10: Beenden mit einem Fehlercode, wenn das
Erstellen einer Config fehlschlägt
In diesem Listing haben wir eine Methode verwendet, die wir bisher noch nicht
behandelt haben: unwrap_or_else, die in der Standardbibliothek unter
Result<T, E> definiert ist. Das Verwenden von unwrap_or_else erlaubt es uns,
eine benutzerdefinierte nicht-panic!-Fehlerbehandlung zu definieren. Wenn das
Result ein Ok-Wert ist, verhält sich diese Methode ähnlich wie unwrap: Sie
gibt den inneren Wert von Ok zurück. Wenn der Wert jedoch ein Err-Wert ist,
ruft diese Methode den Code im Closure auf, der eine anonyme Funktion ist, die
wir definieren und als Argument an unwrap_or_else übergeben. Auf Closures
gehen wir ausführlicher in Kapitel 13 ein. Im Moment musst du nur
wissen, dass unwrap_or_else den inneren Wert von Err, in diesem Fall der
statische String Nicht genügend Argumente, den wir in Listing 12-9 hinzugefügt
haben, an unseren Closure im Argument err, das zwischen den senkrechten
Strichen erscheint, weitergibt. Der Code im Closure kann dann den err-Wert
verwenden, wenn der ausgeführt wird.
Wir haben eine neue Zeile use hinzugefügt, um process aus der
Standardbibliothek in den Gültigkeitsbereich zu bringen. Der Code im Closure,
der im Fehlerfall ausgeführt wird, besteht nur aus zwei Zeilen: Wir geben den
err-Wert aus und rufen dann process::exit auf. Die Funktion process::exit
stoppt das Programm sofort und gibt die Zahl zurück, die als Exit-Statuscode
übergeben wurde. Dies ähnelt der panic!-basierten Behandlung, die wir in
Listing 12-8 verwendet haben, aber wir erhalten nicht mehr die gesamte
zusätzliche Ausgabe. Lass es uns versuchen:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Fehler beim Parsen der Argumente: Nicht genügend Argumente
Großartig! Diese Ausgabe ist viel benutzerfreundlicher.
Extrahieren von Logik aus main
Da wir mit dem Refactoring des Konfigurations-Parsers fertig sind, wollen wir
uns der Logik des Programms zuwenden. Wie wir in „Trennen der Zuständigkeiten
in Binärprojekten“ erklärt haben, werden wir eine
Funktion namens run extrahieren, die die gesamte Logik enthält, die sich
derzeit in der Funktion main befindet und nicht mit dem Aufsetzen der
Konfiguration oder dem Behandeln von Fehlern zu tun hat. Wenn wir fertig sind,
wird die Funktion main übersichtlich und leicht zu verifizieren sein. Zudem
werden wir in der Lage sein, Tests für all die andere Logik zu schreiben.
Listing 12-11 zeigt die extrahierte Funktion run. Im Moment machen wir nur
die kleine, inkrementelle Verbesserung durch Extrahieren der Funktion. Wir sind
immer noch dabei, die Funktion in src/main.rs zu definieren.
Dateiname: src/main.rs
use std::env;
use std::fs;
use std::process;
fn main() {
// --abschneiden--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Fehler beim Parsen der Argumente: {err}");
process::exit(1);
});
println!("Suche nach {}", config.query);
println!("In Datei {}", config.file_path);
run(config);
}
fn run(config: Config) {
let contents = fs::read_to_string(config.file_path)
.expect("Etwas ging beim Lesen der Datei schief");
println!("Mit Text:\n{contents}");
}
// --abschneiden--
struct Config {
query: String,
file_path: String,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("Nicht genügend Argumente");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
Listing 12-11: Extrahieren einer Funktion run, die
den Rest der Programmlogik enthält
Die Funktion run enthält nun die gesamte restliche Logik von main,
beginnend mit dem Lesen der Datei. Die Funktion run nimmt die
Config-Instanz als Argument.
Fehlerrückgabe aus run
Wenn die verbleibende Programmlogik in die Funktion run separiert wird, können
wir die Fehlerbehandlung verbessern, wie wir es mit Config::build in Listing
12-9 getan haben. Anstatt das Programm durch den Aufruf von expect abbrechen
zu lassen, gibt die Funktion run ein Result<T, E> zurück, wenn etwas schief
läuft. Auf diese Weise können wir in main die Logik rund um den Umgang mit
Fehlern auf benutzerfreundliche Weise weiter konsolidieren. Listing 12-12
zeigt die Änderungen, die wir an der Signatur und dem Rumpf von run vornehmen
müssen.
Dateiname: src/main.rs
use std::env;
use std::fs;
use std::process;
use std::error::Error;
// --abschneiden--
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Fehler beim Parsen der Argumente: {err}");
process::exit(1);
});
println!("Suche nach {}", config.query);
println!("In Datei {}", config.file_path);
run(config);
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("Mit Text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("Nicht genügend Argumente");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
Listing 12-12: Ändern der Funktion run, um ein
Result zurückzugeben
Wir haben hier drei wesentliche Änderungen vorgenommen. Erstens haben wir den
Rückgabetyp der Funktion run in Result<(), Box<dyn Error>> geändert. Diese
Funktion gab zuvor den Einheitstyp () zurück und wir behalten diesen als
Rückgabewert im Fall Ok bei.
Für den Fehlertyp haben wir das Trait-Objekt Box<dyn Error> verwendet (und wir
haben std::error::Error mit einer use-Anweisung am Anfang des
Gültigkeitsbereichs eingebunden). Wir werden Trait-Objekte in Kapitel 18
behandeln. Für den Moment solltest du nur wissen, dass Box<dyn Error>
bedeutet, dass die Funktion einen Typ zurückgibt, der das Trait Error
implementiert, aber wir müssen nicht angeben, welcher bestimmte Typ der
Rückgabewert sein wird. Das gibt uns die Flexibilität, Fehlerwerte
zurückzugeben, die in verschiedenen Fehlerfällen von unterschiedlichem Typ sein
können. Das Schlüsselwort dyn ist die Abkürzung für dynamisch.
Zweitens haben wir den Aufruf von expect zugunsten des ?-Operators
entfernt, wie wir in Kapitel 9 besprochen haben. Statt
panic! bei einem Fehler aufzurufen gibt ? den Fehlerwert aus der aktuellen
Funktion zurück, den der Aufrufer behandeln muss.
Drittens gibt die Funktion run jetzt im Erfolgsfall einen Ok-Wert zurück.
Wir haben den Erfolgstyp der Funktion run mit () in der Signatur
deklariert, was bedeutet, dass wir den Wert des Einheitstyps in den Wert Ok
einpacken müssen. Diese Syntax Ok(()) mag zunächst etwas merkwürdig
aussehen. Aber wenn wir () so verwenden, ist das der idiomatische Weg, um
anzuzeigen, dass wir run nur wegen seiner Seiteneffekte aufrufen; es gibt
keinen Wert zurück, den wir brauchen.
Wenn du diesen Code ausführst, wird er kompiliert, aber es wird eine Warnung angezeigt:
$ cargo run the poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
--> src/main.rs:19:5
|
19 | run(config);
| ^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
|
19 | let _ = run(config);
| +++++++
warning: `minigrep` (bin "minigrep") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
Running `target/debug/minigrep the poem.txt`
Suche nach the
In Datei poem.txt
Mit Text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
Rust sagt uns, dass unser Code den Result-Wert ignoriert hat, und der
Result-Wert könnte darauf hinweisen, dass ein Fehler aufgetreten ist. Aber
wir überprüfen nicht, ob ein Fehler aufgetreten ist oder nicht, und der
Compiler erinnert uns daran, dass wir wahrscheinlich gemeint haben, hier etwas
Fehlerbehandlungscode zu haben! Lass uns dieses Problem jetzt beheben.
Behandeln von Fehlern, die von run in main zurückgegeben wurden
Wir werden nach Fehlern suchen und sie mit einer Technik behandeln, die ähnlich
der Technik ist, die wir mit Config::build in Listing 12-10 verwendet
haben, aber mit einem kleinen Unterschied:
Dateiname: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
fn main() {
// --abschneiden--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Fehler beim Parsen der Argumente: {err}");
process::exit(1);
});
println!("Suche nach {}", config.query);
println!("In Datei {}", config.file_path);
if let Err(e) = run(config) {
println!("Anwendungsfehler: {e}");
process::exit(1);
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("Mit Text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("Nicht genügend Argumente");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
Wir benutzen if let statt unwrap_or_else, um zu prüfen, ob run einen
Err-Wert zurückgibt und rufen process::exit(1) auf, wenn dies der Fall ist.
Die Funktion run gibt keinen Wert zurück, den wir mit unwrap auspacken
wollen, auf die gleiche Weise, wie Config::build die Config-Instanz
zurückgibt. Da run im Erfolgsfall () zurückgibt, geht es uns nur darum,
einen Fehler zu entdecken, wir brauchen also nicht unwrap_or_else, um den
ausgepackten Wert zurückzugeben, der nur () wäre.
Die Rümpfe von if let und der unwrap_or_else-Funktionen sind in beiden
Fällen gleich: Wir geben den Fehler aus und beenden.
Code in eine Bibliotheks-Crate aufteilen
Unser minigrep-Projekt sieht soweit gut aus! Jetzt teilen wir die Datei
src/main.rs auf und fügen etwas Code in die Datei src/lib.rs ein. Auf
diese Weise können wir den Code testen und haben eine Datei src/main.rs mit
weniger Verantwortlichkeiten.
Definieren wir den Code, der für die Textsuche in src/lib.rs statt in
src/main.rs zuständig ist. Dadurch können wir (oder jeder andere, der unsere
Bibliothek minigrep verwendet) die Suchfunktion aus mehr Kontexten als nur
unserer Binärdatei minigrep aufrufen.
Zunächst definieren wir die Signatur der Funktion search in src/lib.rs, wie
in Listing 12-13 gezeigt, mit einem Rumpf, der das Makro unimplemented!
aufruft. Wir werden die Signatur genauer erklären, wenn wir die Implementierung
ausfüllen.
Dateiname: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
Listing 12-13: Definieren der Funktion search in
src/lib.rs
Wir haben das Schlüsselwort pub in der Funktionsdefinition verwendet, um
search als Teil der öffentlichen API unserer Bibliotheks-Crate zu
kennzeichnen. Wir haben nun eine Bibliotheks-Crate, die wir aus unserer binären
Crate heraus verwenden und testen können!
Jetzt müssen wir den in src/lib.rs definierten Code in den Gültigkeitsbereich der binären Crate in src/main.rs bringen und ihn aufrufen, wie in Listing 12-14 zu sehen ist.
Dateiname: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
// --abschneiden--
use minigrep::search;
fn main() {
// --abschneiden--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Fehler beim Parsen der Argumente: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
println!("Anwendungsfehler: {e}");
process::exit(1);
}
}
// --abschneiden--
struct Config {
query: String,
file_path: String,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("Nicht genügend Argumente");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
for line in search(&config.query, &contents) {
println!("{line}");
}
Ok(())
}
Listing 12-14: Verwenden der Bibliotheks-Crate
minigrep in src/main.rs
Wir fügen eine Zeile use minigrep::search hinzu, um den Typ Config aus der
Bibliotheks-Crate in den Gültigkeitsbereich der binären Crate zu bringen. Dann
rufen wir in der Funktion run anstatt den Inhalt der Datei auszugeben die
Funktion search auf und übergeben den Wert config.query und contents als
Argumente. Anschließend verwendet run eine for-Schleife, um jede von
search zurückgegebene Zeile auszugeben, die zur Abfrage passt. Dies ist auch
ein guter Zeitpunkt, um die println!-Aufrufe in der Funktion main zu
entfernen, die die Abfrage und den Dateipfad angezeigt haben, sodass unser
Programm nur die Suchergebnisse ausgibt (sofern keine Fehler auftreten).
Beachte, dass die Suchfunktion alle Ergebnisse in einem Vektor sammelt, bevor sie ausgegeben werden. Diese Implementierung kann bei der Suche in großen Dateien zu einer langsamen Anzeige der Ergebnisse führen, da die Ergebnisse nicht sofort nach dem Auffinden ausgegeben werden. In Kapitel 13 werden wir eine mögliche Lösung für dieses Problem mithilfe von Iteratoren besprechen.
Puh! Das war eine Menge Arbeit, aber wir haben uns für den Erfolg in der Zukunft gerüstet. Jetzt ist es viel einfacher, mit Fehlern umzugehen, und wir haben den Code modularer gestaltet. Fast unsere gesamte Arbeit wird von nun an in src/lib.rs durchgeführt.
Lass uns diese neu gewonnene Modularität nutzen, indem wir etwas tun, was mit dem alten Code schwierig gewesen wäre, mit dem neuen Code aber einfach ist: Wir schreiben ein paar Tests!