Refaktorierung um die Modularität und Fehlerbehandlung zu verbessern

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, schwieriger sie zu testen und schwieriger 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 angehen, indem wir unser Projekt refaktorieren.

Trennen der Zuständigkeiten bei 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 run in lib.rs
  • Behandeln des Fehlers, wenn run einen 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. Codeblock 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)
}

Codeblock 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 main-Funktion 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.

Codeblock 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 }
}

Codeblock 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 Zeichenkettenanteilstypen (string slices) zurückgegeben haben, die auf String-Werte in args referenzieren, definieren wir Config jetzt so, dass es aneigenbare (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 Ausleihen verletzen würden, wenn Config versucht, die Eigentümerschaft für die Werte in args zu nehmen.

Wir könnten die String-Daten auf verschiedene Weise verwalten, aber der einfachste, wenn auch etwas ineffiziente Weg ist es, die clone-Methode 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 Zeichenkettendaten. 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 clone

Viele Rust-Entwickler neigen dazu, das Verwenden von clone zur Lösung von Eigentümerschaftsproblemen 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 Zeichenketten zu kopieren, um weiter voranzukommen, da du diese Kopien nur einmal erstellen wirst und dein Dateipfad und deine Suchzeichenkette 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, clone aufzurufen.

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 parse_config-Funktion zurückgeben zu können.

Da nun der Zweck der parse_config-Funktion 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. Codeblock 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 }
    }
}

Codeblock 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 new-Funktion 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 zum Absturz bringt, 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 [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 Codeblock 12-8 fügen wir eine Prüfung in der Funktion new hinzu, die überprüft, ob der Anteilstyp lang genug ist, bevor auf Index 1 und Index 2 zugegriffen wird. Wenn der Anteilstyp nicht lang genug ist, stürzt 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 }
    }
}

Codeblock 12-8: Hinzufügen einer Prüfung für die Anzahl der Argumente

Dieser Code ähnelt der Funktion Guess::new, die wir in Codeblock 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 zu beenden.

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 [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 Codeblock 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.

Codeblock 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 Codeblock 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 })
    }
}

Codeblock 12-9: Rückgabe eines Result von Config::build

Unsere Funktion build liefert ein Result mit einer Config-Instanz im Erfolgsfall und ein Zeichenkettenliteral im Fehlerfall. Unsere Fehlerwerte werden immer Zeichenketten-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 build-Funktion 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 Codeblock 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 })
    }
}

Codeblock 12-10: Beenden mit einem Fehlercode, wenn das Erstellen einer Config fehlschlägt

In diesem Codeblock 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 Funktionsabschluss (closure) auf, die eine anonyme Funktion ist, die wir definieren und als Argument an unwrap_or_else übergeben. Auf Funktionsabschlüsse gehen wir ausführlicher in Kapitel 13 ein. Im Moment musst du nur wissen, dass unwrap_or_else den inneren Wert des Err, in diesem Fall die statische Zeichenkette Nicht genügend Argumente, die wir in Codeblock 12-9 hinzugefügt haben, an unseren Funktionsabschluss im Argument err, das zwischen den senkrechten Strichen erscheint, weitergibt. Der Code im Funktionsabschluss kann dann den err-Wert verwenden, wenn sie 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 Funktionsabschluss, 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 Codeblock 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 [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 Refaktorieren des Konfigurations-Parsers nun fertig sind, wollen wir uns der Logik des Programms zuwenden. Wie wir in „Trennen der Zuständigkeiten bei 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.

Codeblock 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 })
    }
}

Codeblock 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.

Rückgabe von Fehlern aus der Funktion run

Wenn die verbleibende Programmlogik in die Funktion run separiert wird, können wir die Fehlerbehandlung verbessern, wie wir es mit Config::build in Codeblock 12-9 getan haben. Anstatt das Programm durch den Aufruf von expect abstürzen 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. Codeblock 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 })
    }
}

Codeblock 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 Merkmalsobjekt (trait object) Box<dyn Error> verwendet (und wir haben std::error::Error mit einer use-Anweisung am Anfang des Gültigkeitsbereichs eingebunden). Wir werden Merkmalsobjekte in Kapitel 17 behandeln. Für den Moment solltest du nur wissen, dass Box<dyn Error> bedeutet, dass die Funktion einen Typ zurückgibt, der das Merkmal 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 [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 Codeblock 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 Bibliothekskiste 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.

Lass uns den ganzen Code, der nicht in der Funktion main ist, von src/main.rs nach src/lib.rs verschieben:

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

Der Inhalt von src/lib.rs sollte die in Codeblock 12-13 gezeigten Signaturen haben (wir haben die Rümpfe der Funktionen der Kürze halber weggelassen). Beachte, dass dies nicht kompiliert werden kann, bis wir src/main.rs in Codeblock 12-14 modifiziert haben.

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> {
        // --abschneiden--
        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 })
    }
}

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

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

    Ok(())
}

Codeblock 12-13: Verschieben von Config und run in src/lib.rs

Wir haben das Schlüsselwort pub großzügig verwendet: Bei Config, bei seinen Feldern und seiner Methode build und bei der Funktion run. Wir haben jetzt eine Bibliothekskiste, die eine öffentliche API hat, die wir testen können!

Jetzt müssen wir den Code, den wir nach src/lib.rs verschoben haben, in den Gültigkeitsbereich der Binärkiste in src/main.rs bringen, wie in Codeblock 12-14 gezeigt.

Dateiname: src/main.rs

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

use minigrep::Config;

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) = minigrep::run(config) {
        // --abschneiden--
        println!("Anwendungsfehler: {e}");
        process::exit(1);
    }
}

Codeblock 12-14: Verwenden der minigrep-Bibliothekskiste in src/main.rs

Wir fügen eine Zeile use minigrep::Config hinzu, um den Typ Config aus der Bibliothekskiste in den Gültigkeitsbereich der Binärkiste zu bringen, und wir stellen der Funktion run unseren Kistennamen voran. Nun sollte die gesamte Funktionalität verbunden sein und funktionieren. Starte das Programm mit cargo run und stelle 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, 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!