Refactoring zur Verbesserung von Modularität und Fehlerbehandlung

RustRustBeginner
Jetzt üben

This tutorial is from open-source community. Access the source code

💡 Dieser Artikel wurde von AI-Assistenten übersetzt. Um die englische Version anzuzeigen, können Sie hier klicken

Einführung

Willkommen zu Refactoring to Improve Modularity and Error Handling. Dieser Lab ist Teil des Rust Buchs. Du kannst deine Rust-Fähigkeiten in LabEx üben.

In diesem Lab werden wir das Programm umstrukturieren, um die Modularität und die Fehlerbehandlung zu verbessern, indem wir Aufgaben trennen, Konfigurationsvariablen gruppieren, sinnvolle Fehlermeldungen anzeigen und den Fehlerbehandlungs-Code konsolidieren.

Refactoring to Improve Modularity and Error Handling

Um unser Programm zu verbessern, werden wir vier Probleme beheben, die mit der Struktur des Programms und der Art und Weise zusammenhängen, wie es potenzielle Fehler behandelt. Erstens führt unsere main-Funktion derzeit zwei Aufgaben aus: Sie analysiert Argumente und liest Dateien. Wenn sich unser Programm erweitert, wird die Anzahl der einzelnen Aufgaben, die die main-Funktion behandelt, zunehmen. Je mehr Verantwortungen eine Funktion übernimmt, desto schwieriger wird es, über sie nachzudenken, zu testen und zu ändern, ohne einen ihrer Teile zu zerstören. Es ist am besten, die Funktionalität zu trennen, sodass jede Funktion für eine Aufgabe verantwortlich ist.

Dieses Problem hängt auch mit dem zweiten Problem zusammen: Obwohl query und file_path Konfigurationsvariablen für unser Programm sind, werden Variablen wie contents verwendet, um die Logik des Programms auszuführen. Je länger main wird, desto mehr Variablen müssen wir in den Geltungsbereich bringen; je mehr Variablen wir im Geltungsbereich haben, desto schwieriger wird es, den Zweck jeder einzelnen zu verfolgen. Es ist am besten, die Konfigurationsvariablen in eine Struktur zu gruppieren, um ihren Zweck klar zu machen.

Das dritte Problem ist, dass wir expect verwendet haben, um eine Fehlermeldung auszugeben, wenn das Lesen der Datei fehlschlägt, aber die Fehlermeldung druckt lediglich Should have been able to read the file. Ein Dateizugriff kann auf verschiedene Weise fehlschlagen: Beispielsweise kann die Datei fehlen oder wir haben möglicherweise keine Berechtigung, sie zu öffnen. Im Moment würden wir unabhängig von der Situation für alles die gleiche Fehlermeldung ausgeben, was dem Benutzer keine Informationen liefern würde!

Viertens verwenden wir expect wiederholt, um verschiedene Fehler zu behandeln, und wenn der Benutzer unser Programm ausführt, ohne genug Argumente anzugeben, erhält er einen index out of bounds-Fehler von Rust, der das Problem nicht klar erklärt. Es wäre am besten, wenn all der Fehlerbehandlungs-Code an einem Ort wäre, sodass zukünftige Wartende nur an einem Ort nachschlagen müssten, wenn die Fehlerbehandlungslogik geändert werden musste. Dass all der Fehlerbehandlungs-Code an einem Ort ist, gewährleistet auch, dass wir Nachrichten ausgeben, die für unsere Endbenutzer sinnvoll sind.

Lassen Sie uns diese vier Probleme durch Umstrukturierung unseres Projekts ansprechen.

Separation of Concerns for Binary Projects

Das organisatorische Problem, die Verantwortung für mehrere Aufgaben der main-Funktion zuzuweisen, ist vielen binären Projekten gemeinsam. Aus diesem Grund hat die Rust-Community Leitlinien entwickelt, um die verschiedenen Aspekte eines binären Programms zu trennen, wenn main zu groß wird. Dieser Prozess hat die folgenden Schritte:

  • Teilen Sie Ihr Programm in eine main.rs-Datei und eine lib.rs-Datei auf und verschieben Sie die Logik Ihres Programms in lib.rs.
  • Solange Ihre Befehlszeilenanalyse-Logik klein ist, kann sie in main.rs verbleiben.
  • Wenn die Befehlszeilenanalyse-Logik anspruchsvoller wird, extrahieren Sie sie aus main.rs und verschieben Sie sie in lib.rs.

Die Verantwortungen, die nach diesem Prozess in der main-Funktion verbleiben, sollten auf Folgendes beschränkt sein:

  • Aufrufen der Befehlszeilenanalyse-Logik mit den Argumentwerten
  • Einrichten jeder anderen Konfiguration
  • Aufrufen einer run-Funktion in lib.rs
  • Behandeln des Fehlers, wenn run einen Fehler zurückgibt

Dieses Muster geht darum, die Aspekte voneinander zu trennen: main.rs kümmert sich um das Ausführen des Programms, und lib.rs kümmert sich um alle Logiken der vorliegenden Aufgabe. Da Sie die main-Funktion nicht direkt testen können, ermöglicht diese Struktur, alle Logiken Ihres Programms zu testen, indem Sie sie in Funktionen in lib.rs verschieben. Der Code, der in main.rs verbleibt, wird klein genug sein, um seine Korrektheit durch das Lesen zu überprüfen. Lassen Sie uns unser Programm gemäß diesem Prozess umarbeiten.

Extracting the Argument Parser

Wir extrahieren die Funktionalität zum Analysieren von Argumenten in eine Funktion, die main aufrufen wird, um die Vorbereitung für das Verschieben der Befehlszeilenanalyse-Logik in src/lib.rs* zu erleichtern. Listing 12-5 zeigt den neuen Anfang von main, der eine neue Funktion parse_config aufruft, die wir momentan in src/main.rs* definieren werden.

Dateiname: src/main.rs

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, file_path) = parse_config(&args);

    --snip--
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}

Listing 12-5: Extracting a parse_config function from main

Wir sammeln immer noch die Befehlszeilenargumente in einem Vektor, aber anstatt die Argumentwerte an Index 1 der Variable query und die Argumentwerte an Index 2 der Variable file_path innerhalb der main-Funktion zuzuweisen, übergeben wir den gesamten Vektor an die parse_config-Funktion. Die parse_config-Funktion enthält dann die Logik, die bestimmt, welches Argument in welche Variable gehört, und gibt die Werte zurück an main. Wir erstellen die query- und file_path-Variablen immer noch in main, aber main hat keine Verantwortung mehr für die Bestimmung, wie die Befehlszeilenargumente und die Variablen korrespondieren.

Dieser Umbau mag für unser kleines Programm übertrieben erscheinen, aber wir refaktorisieren in kleinen, sukzessiven Schritten. Nachdem Sie diese Änderung vorgenommen haben, führen Sie das Programm erneut aus, um zu überprüfen, ob die Argumentanalyse weiterhin funktioniert. Es ist gut, Ihre Fortschritte häufig zu überprüfen, um die Ursache von Problemen zu identifizieren, wenn sie auftreten.

Grouping Configuration Values

Wir können einen weiteren kleinen Schritt unternehmen, um die parse_config-Funktion weiter zu verbessern. Momentan geben wir ein Tuple zurück, aber brechen dieses dann sofort wieder in einzelne Teile auf. Dies ist ein Anzeichen dafür, dass wir vielleicht noch nicht die richtige Abstraktion haben.

Ein weiterer Indikator, der zeigt, dass es Verbesserungspotential gibt, ist der config-Teil von parse_config, was darauf hindeutet, dass die beiden Werte, die wir zurückgeben, zusammenhängen und beide Teil eines Konfigurationswerts sind. Wir vermitteln diese Bedeutung momentan nicht in der Struktur der Daten, außer indem wir die beiden Werte in ein Tuple gruppieren; stattdessen legen wir die beiden Werte in eine Struktur und geben jedem Strukturfeld einen aussagekräftigen Namen. Dadurch wird es zukünftigen Wartenden dieses Codes einfacher, zu verstehen, wie die verschiedenen Werte miteinander zusammenhängen und was ihr Zweck ist.

Listing 12-6 zeigt die Verbesserungen an der parse_config-Funktion.

Dateiname: src/main.rs

fn main() {
    let args: Vec<String> = env::args().collect();

  1 let config = parse_config(&args);

    println!("Searching for {}", 2 config.query);
    println!("In file {}", 3 config.file_path);

    let contents = fs::read_to_string(4 config.file_path)
       .expect("Should have been able to read the file");

    --snip--
}

5 struct Config {
    query: String,
    file_path: String,
}

6 fn parse_config(args: &[String]) -> Config {
  7 let query = args[1].clone();
  8 let file_path = args[2].clone();

    Config { query, file_path }
}

Listing 12-6: Refactoring parse_config to return an instance of a Config struct

Wir haben eine Struktur namens Config hinzugefügt, die Felder namens query und file_path definiert [5]. Die Signatur von parse_config gibt jetzt an, dass sie einen Config-Wert zurückgibt [6]. Im Körper von parse_config, wo wir früher String-Slices zurückgaben, die auf String-Werte in args verweisen, definieren wir jetzt Config, um eigene String-Werte zu enthalten. Die Variable args in main ist der Besitzer der Argumentwerte und lässt die parse_config-Funktion nur darauf zugreifen, was bedeutet, dass Config die Werte in args nicht besitzen darf, um die Rust-Borrowing-Regeln nicht zu verletzen.

Es gibt mehrere Möglichkeiten, wie wir die String-Daten verwalten könnten; die einfachste, wenn auch etwas ineffiziente, Möglichkeit ist, die clone-Methode auf den Werten aufzurufen [7] [8]. Dies wird eine vollständige Kopie der Daten für die Config-Instanz erzeugen, was mehr Zeit und Speicher benötigt als das Speichern einer Referenz auf die String-Daten. Clonen der Daten macht unseren Code jedoch sehr einfach, da wir die Lebensdauer der Referenzen nicht verwalten müssen; in dieser Situation ist es eine lohnende Kompromissberechnung, etwas Leistung aufzuopfern, um die Einfachheit zu gewinnen.

The Trade-Offs of Using clone

Viele Rust-Entwickler neigen dazu, clone zu vermeiden, um Besitzprobleme zu beheben, wegen seiner Laufzeitkosten. Im Kapitel 13 lernen Sie, wie Sie in diesem Typ von Situationen effizientere Methoden verwenden können. Aber für jetzt ist es in Ordnung, ein paar Strings zu kopieren, um Fortschritte zu machen, da Sie diese Kopien nur einmal machen und Ihre Dateipfad- und Suchzeichenfolge sehr klein sind. Es ist besser, ein funktionierendes, etwas ineffizientes Programm zu haben, als zu versuchen, den Code bei der ersten Überarbeitung zu hyperoptimieren. Wenn Sie mehr Erfahrung mit Rust sammeln, wird es einfacher, mit der effizientesten Lösung zu beginnen, aber für jetzt ist es völlig in Ordnung, clone aufzurufen.

Wir haben main aktualisiert, sodass es die von parse_config zurückgegebene Config-Instanz in eine Variable namens config platziert [1], und wir haben den Code aktualisiert, der zuvor die getrennten query- und file_path-Variablen verwendete, sodass er jetzt die Felder auf der Config-Struktur verwendet [2] [3] [4].

Jetzt vermittelt unser Code deutlicher, dass query und file_path zusammenhängen und dass ihr Zweck darin besteht, die Konfiguration zu bestimmen, wie das Programm arbeiten wird. Jeder Code, der diese Werte verwendet, weiß, sie in der config-Instanz in den Feldern zu finden, die nach ihrem Zweck benannt sind.

Creating a Constructor for Config

Bisher haben wir die Logik, die für die Analyse der Befehlszeilenargumente verantwortlich ist, aus main extrahiert und in die parse_config-Funktion gelegt. Dadurch konnten wir erkennen, dass die query- und file_path-Werte zusammenhängen, und diese Beziehung sollte in unserem Code vermittelt werden. Anschließend haben wir eine Config-Struktur hinzugefügt, um den zusammenhängenden Zweck von query und file_path zu benennen und um die Werte als Strukturfeldnamen zurückgeben zu können, wenn die parse_config-Funktion aufgerufen wird.

Da der Zweck der parse_config-Funktion jetzt darin besteht, eine Config-Instanz zu erstellen, können wir parse_config von einer einfachen Funktion in eine Funktion namens new umwandeln, die mit der Config-Struktur assoziiert ist. Diese Änderung wird den Code idiomatischer machen. Wir können Instanzen von Typen in der Standardbibliothek, wie String, erstellen, indem wir String::new aufrufen. Ähnlich können wir parse_config in eine mit Config assoziierte new-Funktion umwandeln, sodass wir Instanzen von Config erstellen können, indem wir Config::new aufrufen. Listing 12-7 zeigt die erforderlichen Änderungen.

Dateiname: src/main.rs

fn main() {
    let args: Vec<String> = env::args().collect();

  1 let config = Config::new(&args);

    --snip--
}

--snip--

2 impl Config {
  3 fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

Listing 12-7: Changing parse_config into Config::new

Wir haben main aktualisiert, wo wir zuvor parse_config aufruften, um stattdessen Config::new aufzurufen [1]. Wir haben den Namen von parse_config in new geändert [3] und ihn innerhalb eines impl-Blocks verschoben [2], was die new-Funktion mit Config assoziiert. Versuchen Sie, diesen Code erneut zu kompilieren, um sicherzustellen, dass er funktioniert.

Fixing the Error Handling

Jetzt werden wir uns um die Verbesserung unserer Fehlerbehandlung kümmern. Denken Sie daran, dass das Versuchen, auf die Werte im args-Vektor an Index 1 oder Index 2 zuzugreifen, dazu führen wird, dass das Programm abstürzt, wenn der Vektor weniger als drei Elemente enthält. Versuchen Sie, das Programm ohne Argumente auszuführen; es wird so aussehen:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1 but
the index is 1', src/main.rs:27:21
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 Fehlermeldung für Programmierer. Sie wird unseren Endbenutzern nicht helfen, zu verstehen, was sie stattdessen tun sollten. Lassen Sie uns das jetzt beheben.

Improving the Error Message

In Listing 12-8 fügen wir in der new-Funktion eine Prüfung hinzu, die überprüft, ob der Slice lang genug ist, bevor wir auf Index 1 und Index 2 zugreifen. Wenn der Slice nicht lang genug ist, bricht das Programm ab und zeigt eine bessere Fehlermeldung an.

Dateiname: src/main.rs

--snip--
fn new(args: &[String]) -> Config {
    if args.len() < 3 {
        panic!("not enough arguments");
    }
    --snip--

Listing 12-8: Adding a check for the number of arguments

Dieser Code ähnelt der Guess::new-Funktion, die wir in Listing 9-13 geschrieben haben, wo wir panic! aufgerufen haben, wenn der value-Argument außerhalb des Bereichs gültiger Werte lag. Anstatt hier auf einen Bereich von Werten zu prüfen, überprüfen wir, dass die Länge von args mindestens 3 ist, und der Rest der Funktion kann unter der Annahme arbeiten, dass diese Bedingung erfüllt ist. Wenn args weniger als drei Elemente hat, wird diese Bedingung true sein, und wir rufen die panic!-Makro auf, um das Programm sofort zu beenden.

Mit diesen wenigen zusätzlichen Codezeilen in new führen wir das Programm erneut ohne Argumente aus, um zu sehen, wie die Fehlermeldung jetzt aussieht:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`
thread 'main' panicked at 'not enough arguments',
src/main.rs:26:13
note: run with `RUST_BACKTRACE=1` environment variable to display
a backtrace

Diese Ausgabe ist besser: wir haben jetzt eine vernünftige Fehlermeldung. Allerdings haben wir auch unnötige Informationen, die wir unseren Benutzern nicht geben möchten. Vielleicht ist die Technik, die wir in Listing 9-13 verwendet haben, hier nicht die beste: Ein Aufruf von panic! ist für ein Programmierproblem eher geeignet als für ein Benutzungsproblem, wie in Kapitel 9 diskutiert. Stattdessen werden wir die andere Technik verwenden, die Sie in Kapitel 9 gelernt haben - das Zurückgeben eines Result, das entweder Erfolg oder einen Fehler angibt.

Returning a Result Instead of Calling panic!

Stattdessen können wir einen Result-Wert zurückgeben, der in einem erfolgreichen Fall eine Config-Instanz enthalten wird und das Problem im Fehlerfall beschreiben wird. Wir werden auch den Funktionsnamen von new in build ändern, da viele Programmierer erwarten, dass new-Funktionen niemals fehlschlagen. Wenn Config::build mit main kommuniziert, können wir den Result-Typ verwenden, um anzuzeigen, dass ein Problem aufgetreten ist. Dann können wir main ändern, um eine Err-Variante in einen für unsere Benutzer praktikableren Fehler 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 vornehmen müssen, die wir jetzt Config::build nennen, und den Funktionskörper, um einen Result zurückzugeben. Beachten Sie, dass dies erst kompiliert, wenn wir auch main aktualisieren, was wir im nächsten Listing tun werden.

Dateiname: src/main.rs

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

Listing 12-9: Returning a Result from Config::build

Unsere build-Funktion gibt ein Result zurück, das in einem erfolgreichen Fall eine Config-Instanz und im Fehlerfall einen &'static str enthält. Unsere Fehlerwerte werden immer Stringliterale sein, die die 'static-Lebensdauer haben.

Wir haben zwei Änderungen im Funktionskörper 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 einem Ok eingeschlossen. Diese Änderungen machen die Funktion kompatibel mit ihrer neuen Typsignatur.

Das Zurückgeben eines Err-Werts von Config::build ermöglicht es der main-Funktion, den Result-Wert, der von der build-Funktion zurückgegeben wird, zu verarbeiten und im Fehlerfall den Prozess sauberer zu beenden.

Calling Config::build and Handling Errors

Um den Fehlerfall zu behandeln und eine benutzerfreundliche Nachricht auszugeben, müssen wir main aktualisieren, um das Result, das von Config::build zurückgegeben wird, zu verarbeiten, wie in Listing 12-10 gezeigt. Wir übernehmen auch die Verantwortung, das Befehlszeilentool mit einem nichtnullen Fehlercode zu beenden, weg von panic! und implementieren es stattdessen von Hand. Ein nichtnuller Exit-Status ist eine Konvention, um dem aufrufenden Prozess mitzuteilen, dass das Programm mit einem Fehlerzustand beendet wurde.

Dateiname: src/main.rs

1 use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

  2 let config = Config::build(&args).3 unwrap_or_else(|4 err| {
      5 println!("Problem parsing arguments: {err}");
      6 process::exit(1);
    });

    --snip--

Listing 12-10: Exiting with an error code if building a Config fails

In dieser Liste haben wir eine Methode verwendet, die wir noch nicht im Detail behandelt haben: unwrap_or_else, die von der Standardbibliothek auf Result<T, E> definiert ist [2]. Mit unwrap_or_else können wir eine benutzerdefinierte, nicht-panic!-Fehlerbehandlung definieren. Wenn das Result ein Ok-Wert ist, verhält sich diese Methode ähnlich wie unwrap: Sie gibt den inneren Wert zurück, den Ok umschließt. Wenn der Wert jedoch ein Err-Wert ist, ruft diese Methode den Code in der Closure auf, die eine anonyme Funktion ist, die wir definieren und als Argument an unwrap_or_else übergeben [3]. Wir werden Closures im nächsten Kapitel 13 im Detail behandeln. Für jetzt müssen Sie nur wissen, dass unwrap_or_else den inneren Wert von Err, der in diesem Fall der statische String "not enough arguments" ist, den wir in Listing 12-9 hinzugefügt haben, an unsere Closure im Argument err übergeben wird, das zwischen den vertikalen Schläuchen erscheint [4]. Der Code in der Closure kann dann den err-Wert verwenden, wenn er ausgeführt wird.

Wir haben eine neue use-Zeile hinzugefügt, um process aus der Standardbibliothek in den Gültigkeitsbereich zu bringen [1]. Der Code in der Closure, der im Fehlerfall ausgeführt wird, umfasst nur zwei Zeilen: wir drucken den err-Wert [5] und rufen dann process::exit auf [6]. Die process::exit-Funktion 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 keine zusätzlichen Ausgaben mehr. Probieren wir es aus:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

Super! Diese Ausgabe ist für unsere Benutzer viel freundlicher.

Extracting Logic from main

Jetzt, nachdem wir die Umgestaltung der Konfigurationsanalyse abgeschlossen haben, kehren wir zur Logik des Programms zurück. Wie wir in "Separation of Concerns for Binary Projects" erwähnt haben, extrahieren wir eine Funktion namens run, die alle Logik enthalten wird, die derzeit in der main-Funktion ist und die nicht mit der Einrichtung der Konfiguration oder der Fehlerbehandlung zusammenhängt. Wenn wir fertig sind, wird main prägnant und leicht durch Überprüfung zu verifizieren, und wir können Tests für alle anderen Logiken schreiben.

Listing 12-11 zeigt die extrahierte run-Funktion. Momentan machen wir nur die kleine, sukzessive Verbesserung, die Funktion zu extrahieren. Wir definieren die Funktion immer noch in src/main.rs.

Dateiname: src/main.rs

fn main() {
    --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
     .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

--snip--

Listing 12-11: Extracting a run function containing the rest of the program logic

Die run-Funktion enthält jetzt alle verbleibende Logik aus main, beginnend mit dem Lesen der Datei. Die run-Funktion nimmt die Config-Instanz als Argument entgegen.

Returning Errors from the run Function

Mit der verbleibenden Programmlogik in die run-Funktion aufgeteilt, können wir die Fehlerbehandlung verbessern, wie wir es in Listing 12-9 mit Config::build getan haben. Anstatt das Programm zu einem Absturz zu bringen, indem wir expect aufrufen, wird die run-Funktion ein Result<T, E> zurückgeben, wenn etwas schief geht. Dies wird uns ermöglichen, die Logik um die Fehlerbehandlung weiter in main auf eine benutzerfreundliche Weise zusammenzufassen. Listing 12-12 zeigt die Änderungen, die wir am Signatur und am Körper von run vornehmen müssen.

Dateiname: src/main.rs

1 use std::error::Error;

--snip--

2 fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)3?;

    println!("With text:\n{contents}");

  4 Ok(())
}

Listing 12-12: Changing the run function to return Result

Wir haben hier drei bedeutende Änderungen vorgenommen. Erstens haben wir den Rückgabetyp der run-Funktion in Result<(), Box<dyn Error>> geändert [2]. Diese Funktion hat zuvor den Einheitstyp () zurückgegeben, und wir behalten diesen als den Wert bei, der im Ok-Fall zurückgegeben wird.

Für den Fehlertyp haben wir das Trait-Objekt Box<dyn Error> verwendet (und wir haben std::error::Error mit einem use-Statement am Anfang in den Gültigkeitsbereich gebracht [1]). Wir werden Trait-Objekte im Kapitel 17 behandeln. Für jetzt wissen Sie einfach, dass Box<dyn Error> bedeutet, dass die Funktion einen Typ zurückgeben wird, der das Error-Trait implementiert, aber wir müssen nicht angeben, welchen bestimmten Typ der Rückgabewert sein wird. Dies gibt uns die Flexibilität, Fehlerwerte zurückzugeben, die in verschiedenen Fehlerfällen möglicherweise unterschiedlicher Typen sein können. Das dyn-Schlüsselwort ist die Abkürzung für dynamisch.

Zweitens haben wir den Aufruf von expect entfernt und stattdessen den ?-Operator verwendet [3], wie wir es im Kapitel 9 besprochen haben. Anstatt bei einem Fehler panic! aufzurufen, wird ? den Fehlerwert aus der aktuellen Funktion zurückgeben, damit der Aufrufer ihn behandeln kann.

Drittens gibt die run-Funktion jetzt im Erfolgfall einen Ok-Wert zurück [4]. Wir haben im Signatur den Erfolgstyp der run-Funktion als () deklariert, was bedeutet, dass wir den Einheitstyp-Wert in den Ok-Wert einpacken müssen. Diese Ok(())-Syntax mag zunächst ein wenig seltsam aussehen, aber das Verwenden von () auf diese Weise ist die übliche Methode, um anzuzeigen, dass wir run nur wegen seiner Nebeneffekte aufrufen; es gibt keinen Wert zurück, den wir benötigen.

Wenn Sie diesen Code ausführen, wird er kompilieren, aber es wird eine Warnung angezeigt:

warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^^
   |
   = note: `#[warn(unused_must_use)]` on by default
   = note: this `Result` may be an `Err` variant, which should be
handled

Rust sagt uns, dass unser Code den Result-Wert ignoriert hat und der Result-Wert möglicherweise anzeigt, dass ein Fehler aufgetreten ist. Aber wir überprüfen nicht, ob ein Fehler aufgetreten ist, und der Compiler erinnert uns daran, dass wir wahrscheinlich hier irgendeinen Fehlerbehandlungs-Code haben sollten! Lassen Sie uns dieses Problem jetzt beheben.

Handling Errors Returned from run in main

Wir werden nach Fehlern suchen und sie mit einer Technik behandeln, die ähnlich der ist, die wir in Listing 12-10 mit Config::build verwendet haben, aber mit einem kleinen Unterschied:

Dateiname: src/main.rs

fn main() {
    --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

Wir verwenden if let anstatt unwrap_or_else, um zu überprüfen, ob run einen Err-Wert zurückgibt und process::exit(1) aufzurufen, wenn dies der Fall ist. Die run-Funktion gibt keinen Wert zurück, den wir auf die gleiche Weise unwrap möchten, wie Config::build die Config-Instanz zurückgibt. Da run im Erfolgfall () zurückgibt, interessieren wir uns nur für das Entdecken eines Fehlers, daher brauchen wir nicht unwrap_or_else, um den entpackten Wert zurückzugeben, der nur () sein würde.

Der Körper der if let- und der unwrap_or_else-Funktionen ist in beiden Fällen gleich: wir drucken den Fehler und beenden das Programm.

Splitting Code into a Library Crate

Unser minigrep-Projekt sieht bisher gut aus! Jetzt werden wir die Datei src/main.rs aufteilen und einige Code in die Datei src/lib.rs verschieben. Auf diese Weise können wir den Code testen und eine src/main.rs-Datei mit weniger Verantwortungen haben.

Lassen Sie uns all den Code verschieben, der nicht in der main-Funktion von src/main.rs in src/lib.rs ist:

  • Die run-Funktionsdefinition
  • Die relevanten use-Anweisungen
  • Die Definition von Config
  • Die Config::build-Funktionsdefinition

Der Inhalt von src/lib.rs sollte die Signaturen haben, wie in Listing 12-13 gezeigt (wir haben die Körper der Funktionen aus Gründen der Kürze weggelassen). Beachten Sie, dass dies erst kompilieren wird, wenn wir src/main.rs in Listing 12-14 ändern.

Dateiname: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(
        args: &[String],
    ) -> Result<Config, &'static str> {
        --snip--
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    --snip--
}

Listing 12-13: Moving Config and run into src/lib.rs

Wir haben liberal das pub-Schlüsselwort verwendet: auf Config, auf seine Felder und seine build-Methode und auf die run-Funktion. Wir haben jetzt einen Bibliothekskasten, der eine öffentliche API hat, die wir testen können!

Jetzt müssen wir den Code, den wir in src/lib.rs verschoben haben, in den Gültigkeitsbereich des Binärkastens in src/main.rs bringen, wie in Listing 12-14 gezeigt.

Dateiname: src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    --snip--
    if let Err(e) = minigrep::run(config) {
        --snip--
    }
}

Listing 12-14: Using the minigrep library crate in src/main.rs

Wir fügen eine use minigrep::Config-Zeile hinzu, um den Config-Typ aus dem Bibliothekskasten in den Gültigkeitsbereich des Binärkastens zu bringen, und wir präfixieren die run-Funktion mit unserem Kastennamen. Jetzt sollten alle Funktionalitäten verbunden sein und funktionieren. Führen Sie das Programm mit cargo run aus und stellen Sie sicher, dass alles korrekt funktioniert.

Puh! Das war eine Menge Arbeit, aber wir haben uns für den Erfolg in der Zukunft gerüstet. Jetzt ist es viel einfacher, Fehler zu behandeln, und wir haben den Code modularer gemacht. Fast all unsere Arbeit wird von hier aus in src/lib.rs erledigt werden.

Lassen Sie uns von dieser neuen Modularität profitieren, indem wir etwas tun, was mit dem alten Code schwierig gewesen wäre, aber mit dem neuen Code einfach ist: wir werden einige Tests schreiben!

Zusammenfassung

Herzlichen Glückwunsch! Sie haben das Lab "Refactoring to Improve Modularity and Error Handling" abgeschlossen. Sie können in LabEx weitere Labs absolvieren, um Ihre Fähigkeiten zu verbessern.