Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Refaktoryzacja w celu poprawy modułowości i obsługi błędów

Aby ulepszyć nasz program, naprawimy cztery problemy związane ze strukturą programu i sposobem obsługi potencjalnych błędów. Po pierwsze, nasza funkcja main wykonuje teraz dwa zadania: parsuje argumenty i odczytuje pliki. W miarę rozwoju programu liczba oddzielnych zadań, które obsługuje funkcja main, będzie rosła. Gdy funkcja zyskuje nowe obowiązki, staje się trudniejsza do zrozumienia, trudniejsza do testowania i trudniejsza do zmiany bez uszkodzenia jednej z jej części. Najlepiej jest oddzielić funkcjonalność, tak aby każda funkcja była odpowiedzialna za jedno zadanie.

Ten problem wiąże się również z drugim problemem: chociaż query i file_path są zmiennymi konfiguracyjnymi naszego programu, zmienne takie jak contents są używane do wykonywania logiki programu. Im dłuższy staje się main, tym więcej zmiennych będziemy musieli wprowadzić do zakresu; im więcej zmiennych mamy w zakresie, tym trudniej będzie śledzić cel każdej z nich. Najlepiej jest pogrupować zmienne konfiguracyjne w jedną strukturę, aby ich cel był jasny.

Trzeci problem polega na tym, że użyliśmy expect do wyświetlenia komunikatu o błędzie, gdy odczyt pliku nie powiódł się, ale komunikat o błędzie po prostu wyświetla Should have been able to read the file. Odczyt pliku może zakończyć się niepowodzeniem na wiele sposobów: na przykład plik może brakować lub możemy nie mieć uprawnień do jego otwarcia. Obecnie, niezależnie od sytuacji, wyświetlalibyśmy ten sam komunikat o błędzie dla wszystkiego, co nie dostarczyłoby użytkownikowi żadnych informacji!

Po czwarte, używamy expect do obsługi błędu, a jeśli użytkownik uruchomi nasz program bez podania wystarczającej liczby argumentów, otrzyma błąd index out of bounds z Rusta, który nie wyjaśnia jasno problemu. Byłoby najlepiej, gdyby cały kod obsługi błędów znajdował się w jednym miejscu, tak aby przyszli utrzymujący mieli tylko jedno miejsce do konsultowania kodu, jeśli logika obsługi błędów wymagałaby zmiany. Posiadanie całego kodu obsługi błędów w jednym miejscu zapewni również, że wyświetlamy komunikaty, które będą zrozumiałe dla naszych użytkowników końcowych.

Rozwiążemy te cztery problemy, refaktoryzując nasz projekt.

Rozdzielanie odpowiedzialności w projektach binarnych

Problem organizacyjny polegający na przypisywaniu funkcji main odpowiedzialności za wiele zadań jest powszechny w wielu projektach binarnych. W rezultacie wielu programistów Rust uważa za przydatne rozdzielenie różnych aspektów programu binarnego, gdy funkcja main staje się zbyt duża. Proces ten obejmuje następujące kroki:

  • Podziel program na pliki main.rs i lib.rs i przenieś logikę programu do lib.rs.
  • Dopóki logika parsowania wiersza poleceń jest mała, może pozostać w funkcji main.
  • Kiedy logika parsowania wiersza poleceń zaczyna się komplikować, wyodrębnij ją z funkcji main do innych funkcji lub typów.

Obowiązki, które pozostają w funkcji main po tym procesie, powinny być ograniczone do następujących:

  • Wywołanie logiki parsowania wiersza poleceń z wartościami argumentów
  • Ustawienie wszelkich innych konfiguracji
  • Wywołanie funkcji run w lib.rs
  • Obsługa błędu, jeśli run zwróci błąd

Ten wzorzec polega na rozdzieleniu odpowiedzialności: main.rs zajmuje się uruchamianiem programu, a lib.rs obsługuje całą logikę bieżącego zadania. Ponieważ nie można bezpośrednio testować funkcji main, ta struktura pozwala testować całą logikę programu, przenosząc ją poza funkcję main. Kod, który pozostaje w funkcji main, będzie wystarczająco mały, aby zweryfikować jego poprawność poprzez odczytanie. Przeróbmy nasz program, postępując zgodnie z tym procesem.

Wyodrębnianie parsera argumentów

Wyodrębnimy funkcjonalność parsowania argumentów do funkcji, którą wywoła main. Listing 12-5 pokazuje nowy początek funkcji main, która wywołuje nową funkcję parse_config, którą zdefiniujemy w src/main.rs.

Nazwa pliku: 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);

    // --snip--

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

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

    println!("With 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: Wyodrębnianie funkcji parse_config z main

Nadal zbieramy argumenty wiersza poleceń do wektora, ale zamiast przypisywać wartość argumentu pod indeksem 1 do zmiennej query i wartość argumentu pod indeksem 2 do zmiennej file_path w funkcji main, przekazujemy cały wektor do funkcji parse_config. Funkcja parse_config zawiera następnie logikę, która określa, który argument trafia do której zmiennej i przekazuje wartości z powrotem do main. Nadal tworzymy zmienne query i file_path w main, ale main nie jest już odpowiedzialny za określanie, jak argumenty wiersza poleceń i zmienne odpowiadają sobie.

Ta przeróbka może wydawać się przesadna dla naszego małego programu, ale refaktoryzujemy ją małymi, przyrostowymi krokami. Po dokonaniu tej zmiany, uruchom program ponownie, aby sprawdzić, czy parsowanie argumentów nadal działa. Dobrze jest często sprawdzać postępy, aby pomóc zidentyfikować przyczynę problemów, gdy się pojawią.

Grupowanie wartości konfiguracyjnych

Możemy podjąć kolejny mały krok, aby jeszcze bardziej ulepszyć funkcję parse_config. W tej chwili zwracamy krotkę, ale natychmiast rozbijamy tę krotkę ponownie na pojedyncze części. Jest to znak, że być może nie mamy jeszcze odpowiedniej abstrakcji.

Inny wskaźnik, który pokazuje, że jest miejsce na ulepszenia, to część config w parse_config, co implikuje, że dwie zwracane wartości są powiązane i obie są częścią jednej wartości konfiguracyjnej. Obecnie nie przekazujemy tego znaczenia w strukturze danych inaczej niż poprzez grupowanie dwóch wartości w krotkę; zamiast tego umieścimy dwie wartości w jednej strukturze i nadamy każdemu z pól struktury znaczącą nazwę. Zrobienie tego ułatwi przyszłym utrzymującym ten kod zrozumienie, jak różne wartości są ze sobą powiązane i jaki jest ich cel.

Listing 12-6 pokazuje ulepszenia funkcji parse_config.

Nazwa pliku: src/main.rs
use std::env;
use std::fs;

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

    let config = parse_config(&args);

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

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

    // --snip--

    println!("With 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: Refaktoryzacja parse_config w celu zwrócenia instancji struktury Config

Dodaliśmy strukturę o nazwie Config z polami query i file_path. Sygnatura parse_config wskazuje teraz, że zwraca wartość Config. W ciele parse_config, gdzie wcześniej zwracaliśmy wycinki stringów, które odwoływały się do wartości String w args, teraz definiujemy Config tak, aby zawierał własne wartości String. Zmienna args w main jest właścicielem wartości argumentów i tylko pozwala funkcji parse_config je pożyczyć, co oznacza, że naruszylibyśmy zasady pożyczania Rusta, gdyby Config próbowało przejąć własność wartości w args.

Istnieje wiele sposobów zarządzania danymi String; najłatwiejsza, choć nieco nieefektywna, droga to wywołanie metody clone na wartościach. Spowoduje to pełną kopię danych dla instancji Config, co zajmuje więcej czasu i pamięci niż przechowywanie referencji do danych ciągu znaków. Jednak klonowanie danych również sprawia, że nasz kod jest bardzo prosty, ponieważ nie musimy zarządzać czasami życia referencji; w tych okolicznościach, rezygnacja z nieco wydajności na rzecz prostoty jest opłacalnym kompromisem.

Kompromisy związane z używaniem clone

Wielu programistów Rusta ma tendencję do unikania używania clone do naprawiania problemów z własnością ze względu na jego koszt wykonawczy. W Rozdziale 13 dowiesz się, jak używać bardziej efektywnych metod w tego typu sytuacjach. Ale na razie w porządku jest kopiowanie kilku ciągów znaków, aby kontynuować postęp, ponieważ te kopie zrobisz tylko raz, a ścieżka pliku i ciąg zapytania są bardzo małe. Lepiej mieć działający program, który jest nieco nieefektywny, niż próbować hiperoptymalizować kod za pierwszym razem. W miarę zdobywania doświadczenia z Rustem, łatwiej będzie zacząć od najbardziej efektywnego rozwiązania, ale na razie, używanie clone jest całkowicie akceptowalne.

Zaktualizowaliśmy main tak, aby umieszczał instancję Config zwróconą przez parse_config w zmiennej o nazwie config, i zaktualizowaliśmy kod, który wcześniej używał oddzielnych zmiennych query i file_path, tak aby teraz używał pól struktury Config.

Teraz nasz kod jaśniej przekazuje, że query i file_path są ze sobą powiązane i że ich celem jest konfigurowanie działania programu. Każdy kod, który używa tych wartości, wie, że znajdzie je w instancji config w polach nazwanych zgodnie z ich przeznaczeniem.

Tworzenie konstruktora dla Config

Do tej pory wyodrębniliśmy logikę odpowiedzialną za parsowanie argumentów wiersza poleceń z main i umieściliśmy ją w funkcji parse_config. Dzięki temu zauważyliśmy, że wartości query i file_path były ze sobą powiązane, i ten związek powinien być przekazany w naszym kodzie. Następnie dodaliśmy strukturę Config, aby nazwać powiązane przeznaczenie query i file_path oraz móc zwracać nazwy wartości jako nazwy pól struktury z funkcji parse_config.

Skoro teraz celem funkcji parse_config jest stworzenie instancji Config, możemy zmienić parse_config z prostej funkcji na funkcję o nazwie new, która jest skojarzona ze strukturą Config. Ta zmiana sprawi, że kod będzie bardziej idiomatyczny. Możemy tworzyć instancje typów w standardowej bibliotece, takich jak String, wywołując String::new. Podobnie, zmieniając parse_config na funkcję new skojarzoną z Config, będziemy mogli tworzyć instancje Config, wywołując Config::new. Listing 12-7 pokazuje zmiany, które musimy wprowadzić.

Nazwa pliku: src/main.rs
use std::env;
use std::fs;

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

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

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

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

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

    // --snip--
}

// --snip--

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: Zmiana parse_config na Config::new

Zaktualizowaliśmy main, gdzie wywoływaliśmy parse_config, aby zamiast tego wywoływać Config::new. Zmieniliśmy nazwę parse_config na new i przenieśliśmy ją do bloku impl, co wiąże funkcję new z Config. Spróbuj ponownie skompilować ten kod, aby upewnić się, że działa.

Naprawa obsługi błędów

Teraz zajmiemy się naprawą obsługi błędów. Przypomnijmy, że próba uzyskania dostępu do wartości w wektorze args pod indeksem 1 lub 2 spowoduje panikę programu, jeśli wektor zawiera mniej niż trzy elementy. Spróbuj uruchomić program bez żadnych argumentów; będzie to wyglądało tak:

$ 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

Linia index out of bounds: the len is 1 but the index is 1 to komunikat o błędzie przeznaczony dla programistów. Nie pomoże on naszym użytkownikom końcowym zrozumieć, co powinni zrobić zamiast tego. Naprawmy to teraz.

Ulepszanie komunikatu o błędzie

W Listingu 12-8 dodajemy w funkcji new sprawdzenie, czy wycinek jest wystarczająco długi, zanim uzyska dostęp do indeksu 1 i 2. Jeśli wycinek nie jest wystarczająco długi, program panikuje i wyświetla lepszy komunikat o błędzie.

Nazwa pliku: src/main.rs
use std::env;
use std::fs;

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

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

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

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

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

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

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

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

        Config { query, file_path }
    }
}
Listing 12-8: Dodawanie sprawdzenia liczby argumentów

Ten kod jest podobny do funkcji Guess::new, którą napisaliśmy w Listingu 9-13, gdzie wywołaliśmy panic!, gdy argument value był poza zakresem prawidłowych wartości. Zamiast sprawdzać zakres wartości tutaj, sprawdzamy, czy długość args wynosi co najmniej 3, a reszta funkcji może działać, zakładając, że ten warunek został spełniony. Jeśli args ma mniej niż trzy elementy, ten warunek będzie true i wywołamy makro panic!, aby natychmiast zakończyć program.

Z tymi dodatkowymi kilkoma liniami kodu w new, uruchommy program ponownie bez żadnych argumentów, aby zobaczyć, jak teraz wygląda błąd:

$ 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:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Ten wynik jest lepszy: mamy teraz rozsądny komunikat o błędzie. Mamy jednak także zbędne informacje, których nie chcemy przekazywać naszym użytkownikom. Być może technika, której użyliśmy w Listingu 9-13, nie jest tutaj najlepsza: wywołanie panic! jest bardziej odpowiednie dla problemu programistycznego niż problemu z użyciem, jak omówiono w Rozdziale 9. Zamiast tego, użyjemy innej techniki, o której dowiedziałeś się w Rozdziale 9 – zwracania Result, który wskazuje na sukces lub błąd.

Zwracanie Result zamiast wywoływania panic!

Zamiast tego możemy zwrócić wartość Result, która będzie zawierała instancję Config w przypadku sukcesu i będzie opisywać problem w przypadku błędu. Zmienimy również nazwę funkcji z new na build, ponieważ wielu programistów oczekuje, że funkcje new nigdy nie zawiodą. Kiedy Config::build komunikuje się z main, możemy użyć typu Result, aby zasygnalizować, że wystąpił problem. Następnie możemy zmienić main, aby przekształcić wariant Err w bardziej praktyczny błąd dla naszych użytkowników, bez otaczającego tekstu o thread 'main' i RUST_BACKTRACE, które powoduje wywołanie panic!.

Listing 12-9 pokazuje zmiany, które musimy wprowadzić w wartości zwracanej funkcji, którą teraz nazywamy Config::build, oraz w ciele funkcji potrzebnym do zwrócenia Result. Zauważ, że to się nie skompiluje, dopóki nie zaktualizujemy również main, co zrobimy w następnym listingu.

Nazwa pliku: src/main.rs
use std::env;
use std::fs;

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

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

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

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

    println!("With 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("not enough arguments");
        }

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

        Ok(Config { query, file_path })
    }
}
Listing 12-9: Zwracanie Result z Config::build

Nasza funkcja build zwraca Result z instancją Config w przypadku pomyślności i literałem ciągu znaków w przypadku błędu. Nasze wartości błędów będą zawsze literałami ciągu znaków, które mają czas życia 'static.

Dokonaliśmy dwóch zmian w ciele funkcji: zamiast wywoływać panic! gdy użytkownik nie przekaże wystarczającej liczby argumentów, teraz zwracamy wartość Err, a wartość zwracaną Config opakowaliśmy w Ok. Te zmiany sprawiają, że funkcja jest zgodna z nowym podpisem typu.

Zwracanie wartości Err z Config::build pozwala funkcji main obsłużyć wartość Result zwróconą z funkcji build i czysto zakończyć proces w przypadku błędu.

Wywoływanie Config::build i obsługa błędów

Aby obsłużyć przypadek błędu i wyświetlić komunikat zrozumiały dla użytkownika, musimy zaktualizować main w celu obsługi Result zwracanego przez Config::build, jak pokazano w Listingu 12-10. Przejmiemy również odpowiedzialność za zakończenie narzędzia wiersza poleceń z niezerowym kodem błędu zamiast panic! i zaimplementujemy to ręcznie. Niezerowy status wyjścia jest konwencją sygnalizującą procesowi, który wywołał nasz program, że program zakończył działanie ze stanem błędu.

Nazwa pliku: 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!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

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

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

    println!("With 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("not enough arguments");
        }

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

        Ok(Config { query, file_path })
    }
}
Listing 12-10: Wyjście z kodem błędu, jeśli budowanie Config zawiedzie

W tym listingu użyliśmy metody, której jeszcze szczegółowo nie omówiliśmy: unwrap_or_else, która jest zdefiniowana w Result<T, E> przez standardową bibliotekę. Użycie unwrap_or_else pozwala nam zdefiniować niestandardową, nie-panic! obsługę błędów. Jeśli Result jest wartością Ok, zachowanie tej metody jest podobne do unwrap: zwraca wewnętrzną wartość, którą opakowuje Ok. Jednak jeśli wartość jest wartością Err, ta metoda wywołuje kod w zamknięciu, które jest anonimową funkcją, którą definiujemy i przekazujemy jako argument do unwrap_or_else. Zamknięcia omówimy bardziej szczegółowo w Rozdziale 13. Na razie wystarczy wiedzieć, że unwrap_or_else przekaże wewnętrzną wartość Err, która w tym przypadku jest statycznym ciągiem znaków "not enough arguments", który dodaliśmy w Listingu 12-9, do naszego zamknięcia w argumencie err, który pojawia się między pionowymi kreskami. Kod w zamknięciu może następnie użyć wartości err, gdy będzie działał.

Dodaliśmy nową linię use, aby wprowadzić process ze standardowej biblioteki do zakresu. Kod w zamknięciu, który zostanie uruchomiony w przypadku błędu, składa się tylko z dwóch linii: wyświetlamy err, a następnie wywołujemy process::exit. Funkcja process::exit natychmiast zatrzyma program i zwróci liczbę, która została przekazana jako kod statusu wyjścia. Jest to podobne do obsługi opartej na panic!, której użyliśmy w Listingu 12-8, ale nie otrzymujemy już wszystkich dodatkowych danych wyjściowych. Spróbujmy:

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

Świetnie! Ten wynik jest znacznie bardziej przyjazny dla naszych użytkowników.

Wyodrębnianie logiki z funkcji main

Teraz, gdy zakończyliśmy refaktoryzację parsowania konfiguracji, zajmijmy się logiką programu. Jak stwierdziliśmy w sekcji „Rozdzielanie odpowiedzialności w projektach binarnych”, wyodrębnimy funkcję o nazwie run, która będzie zawierała całą logikę znajdującą się obecnie w funkcji main, niezwiązaną z konfigurowaniem ani obsługą błędów. Po zakończeniu funkcja main będzie zwięzła i łatwa do weryfikacji poprzez inspekcję, a my będziemy mogli pisać testy dla całej pPozostałej logiki.

Listing 12-11 pokazuje niewielką, przyrostową poprawę w wyodrębnianiu funkcji run.

Nazwa pliku: src/main.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    // --snip--

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

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

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

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

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-11: Wyodrębnianie funkcji run zawierającej resztę logiki programu

Funkcja run zawiera teraz całą pozostałą logikę z main, poczynając od 樑odczytu pliku. Funkcja run przyjmuje instancję Config jako argument.

Zwracanie błędów z funkcji run

Po oddzieleniu pozostałej logiki programu do funkcji run, możemy poprawić obsługę błędów, tak jak to zrobiliśmy z Config::build w Listingu 12-9. Zamiast pozwalać programowi na panikę poprzez wywołanie expect, funkcja run będzie zwracać Result<T, E>, gdy coś pójdzie nie tak. Pozwoli to nam dalej skonsolidować logikę obsługi błędów w main w sposób przyjazny dla użytkownika. Listing 12-12 pokazuje zmiany, które musimy wprowadzić w sygnaturze i ciele run.

Nazwa pliku: src/main.rs
use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --snip--


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

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

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

    run(config);
}

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

    println!("With 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("not enough arguments");
        }

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

        Ok(Config { query, file_path })
    }
}
Listing 12-12: Zmiana funkcji run na zwracającą Result

Wprowadziliśmy tu trzy istotne zmiany. Po pierwsze, zmieniliśmy typ zwracany funkcji run na Result<(), Box<dyn Error>>. Ta funkcja wcześniej zwracała typ jednostkowy (), i zachowujemy to jako wartość zwracaną w przypadku Ok.

Jako typ błędu użyliśmy obiektu cechy Box<dyn Error> (i wprowadziliśmy std::error::Error do zakresu za pomocą instrukcji use na górze). Obiekty cech omówimy w Rozdziale 18. Na razie wystarczy wiedzieć, że Box<dyn Error> oznacza, że funkcja zwróci typ, który implementuje cechę Error, ale nie musimy określać, jaki konkretny typ będzie miała wartość zwracana. Daje nam to elastyczność w zwracaniu wartości błędów, które mogą być różnych typów w różnych przypadkach błędów. Słowo kluczowe dyn to skrót od dynamiczny.

Po drugie, usunęliśmy wywołanie expect na rzecz operatora ?, o czym mówiliśmy w Rozdziale 9. Zamiast panikować w przypadku błędu, ? zwróci wartość błędu z bieżącej funkcji, aby wywołujący mógł ją obsłużyć.

Po trzecie, funkcja run zwraca teraz wartość Ok w przypadku sukcesu. Zadeklarowaliśmy typ sukcesu funkcji run jako () w sygnaturze, co oznacza, że musimy opakować wartość typu jednostkowego w wartość Ok. Ta składnia Ok(()) może na początku wydawać się nieco dziwna. Ale użycie () w ten sposób jest idiomatycznym sposobem wskazania, że wywołujemy run tylko dla jego efektów ubocznych; nie zwraca on wartości, której potrzebujemy.

Kiedy uruchomisz ten kod, skompiluje się, ale wyświetli ostrzeżenie:

$ 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`
Searching for the
In file poem.txt
With 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 informuje nas, że nasz kod zignorował wartość Result i że wartość Result może wskazywać na wystąpienie błędu. Ale nie sprawdzamy, czy wystąpił błąd, a kompilator przypomina nam, że prawdopodobnie zamierzaliśmy umieścić tutaj jakiś kod do obsługi błędów! Naprawmy ten problem teraz.

Obsługa błędów zwracanych przez run w main

Sprawdzimy błędy i obsłużymy je za pomocą techniki podobnej do tej, której użyliśmy z Config::build w Listingu 12-10, ale z niewielką różnicą:

Nazwa pliku: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --snip--

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

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

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

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

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

    println!("With 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("not enough arguments");
        }

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

        Ok(Config { query, file_path })
    }
}

Używamy if let zamiast unwrap_or_else, aby sprawdzić, czy run zwraca wartość Err, i aby wywołać process::exit(1), jeśli tak. Funkcja run nie zwraca wartości, którą chcemy unwrap w taki sam sposób, w jaki Config::build zwraca instancję Config. Ponieważ run zwraca () w przypadku sukcesu, zależy nam tylko na wykryciu błędu, więc nie potrzebujemy unwrap_or_else, aby zwrócić rozpakowaną wartość, która byłaby tylko ().

Ciała funkcji if let i unwrap_or_else są w obu przypadkach takie same: wyświetlamy błąd i wychodzimy.

Podział kodu na bibliotekę typu “crate”

Nasz projekt minigrep wygląda do tej pory dobrze! Teraz podzielimy plik src/main.rs i umieścimy część kodu w pliku src/lib.rs. W ten sposób możemy testować kod i mieć plik src/main.rs z mniejszą odpowiedzialnością.

Zdefiniujmy kod odpowiedzialny za wyszukiwanie tekstu w src/lib.rs zamiast w src/main.rs, co pozwoli nam (lub każdemu innemu, kto używa naszej biblioteki minigrep) wywołać funkcję wyszukiwania z większej liczby kontekstów niż nasz plik binarny minigrep.

Najpierw zdefiniujmy sygnaturę funkcji search w src/lib.rs, jak pokazano w Listingu 12-13, z ciałem, które wywołuje makro unimplemented!. Wyjaśnimy sygnaturę bardziej szczegółowo, gdy wypełnimy implementację.

Nazwa pliku: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}
Listing 12-13: Definiowanie funkcji search w src/lib.rs

Użyliśmy słowa kluczowego pub w definicji funkcji, aby oznaczyć search jako część publicznego API naszego pakietu bibliotecznego. Mamy teraz pakiet biblioteczny, którego możemy używać z naszego pakietu binarnego i który możemy testować!

Teraz musimy wprowadzić kod zdefiniowany w src/lib.rs do zakresu pakietu binarnego w src/main.rs i wywołać go, jak pokazano w Listingu 12-14.

Nazwa pliku: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

// --snip--
use minigrep::search;

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

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

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

// --snip--


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

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

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: Użycie funkcji search z biblioteki minigrep w src/main.rs

Dodajemy linię use minigrep::search, aby wprowadzić funkcję search z pakietu bibliotecznego do zakresu pakietu binarnego. Następnie, w funkcji run, zamiast wypisywać zawartość pliku, wywołujemy funkcję search i przekazujemy wartość config.query oraz contents jako argumenty. Następnie run użyje pętli for do wypisania każdej linii zwróconej przez search, która dopasowała zapytanie. To również dobry moment na usunięcie wywołań println! w funkcji main, które wyświetlały zapytanie i ścieżkę pliku, tak aby nasz program wyświetlał tylko wyniki wyszukiwania (jeśli nie wystąpiły błędy).

Zauważ, że funkcja wyszukiwania będzie zbierać wszystkie wyniki do zwracanego wektora, zanim rozpocznie się jakiekolwiek drukowanie. Ta implementacja może być powolna w wyświetlaniu wyników podczas wyszukiwania w dużych plikach, ponieważ wyniki nie są drukowane w miarę ich znajdowania; omówimy możliwy sposób rozwiązania tego problemu za pomocą iteratorów w Rozdziale 13.

Uff! To była ciężka praca, ale przygotowaliśmy się na przyszłość. Teraz o wiele łatwiej jest obsługiwać błędy, a kod uczyniliśmy bardziej modułowym. Prawie cała nasza praca będzie wykonywana w src/lib.rs od teraz.

Wykorzystajmy tę nowo odkrytą modułowość, robiąc coś, co byłoby trudne ze starym kodem, ale jest łatwe z nowym: napiszemy kilka testów!