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
maindo 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
runw lib.rs - Obsługa błędu, jeśli
runzwró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.
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)
}
parse_config z mainNadal 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.
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 }
}
parse_config w celu zwrócenia instancji struktury ConfigDodaliś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ć.
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 }
}
}
parse_config na Config::newZaktualizowaliś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.
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 }
}
}
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.
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 })
}
}
Result z Config::buildNasza 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.
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 })
}
}
Config zawiedzieW 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.
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 })
}
}
run zawierającej resztę logiki programuFunkcja 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.
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 })
}
}
run na zwracającą ResultWprowadziliś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ę.
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
search w src/lib.rsUż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.
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(())
}
search z biblioteki minigrep w src/main.rsDodajemy 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!