Obsługa błędów w języku Rust, cz. 2: Result

W poprzedniej części tego artykułu pisałem, jak można zasygnalizować wystąpienie błędu w Ruscie, używając do tego typu Option. Niestety typ ten posiadał zasadniczą wadę - nie pozwalał na przekazanie informacji o tym, co faktycznie poszło nie tak. Na samym końcu wspomniałem też o nieco bardziej złożonym typie Result, który taką możliwość daje. Dzisiaj postaram się przyjrzeć Result bliżej.
Typ Result<T, E>
Na dobrą sprawę wiedząc, jak zaimplementować typ, Option, zaproponowanie implementacji dla typu Result nie powinno stanowić wyzwania:
enum Result<T, E> {
Ok(T),
Err(E),
}
Typ Result przyjmuje znowu jedną z dwóch wartości - Result::Ok, lub Result::Err, tym razem jednak z oboma wariantami powiązana jest dodatkowa wartość, przy czym ta wartość może być inna dla wariantu Ok i dla wariantu Err (są one reprezentowane przez dwa różne typy generyczne - T i E). Podobnie, jak w przypadku Option, warianty typu Result są w Ruscie słowami kluczowymi, możemy więc pisać po prostu Ok, lub Err.
Mając nowy typ, spróbujmy go w jakiś sposób użyć:
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err("Nie dziel przez 0!".to_string())
} else {
Ok(a / b)
}
}
W tym przypadku zdecydowałem się użyć go jako typu błędu z informacją o zaistniałym problemie. Nie jest to najlepsza praktyka, ale wystarczająca na potrzeby tego przykładu.
Z typem Result można zrobić większość z tych rzeczy, które można było zrobić z typem Option: mamy funkcję Result::unwrap, działają funkcje Result::unwrap_or, Result::unwrap_or_else, Result::unwrap_or_default (choć ta druga ma nieco inną sygnaturę). Możemy użyć match, lub if let do dopasowania zmiennej typu Result do wzorca. Dodatkowo mamy wcześniej wspomnianą funkcję Result::ok, która oznacza mniej więcej: nie interesuje mnie, co poszło nie tak, interesuje mnie tylko czy operacja się udała (zamienia więc wynik na Option, porzucając całkowicie typ błędu). Działa też operator ?!
fn div_and_square(a: i32, b: i32) -> Result<i32, String> {
let c = divide(a, b)?;
Ok(c * c)
}
Co jednak, jeśli typ błędu z zawołanej funkcji różni się od błędu zwracanego z funkcji? Wtedy z pomocą przychodzi Result::map_err.
Metoda Result::map_err
Metoda Result::map_err pozwala na przekształcenie błędu w inny, na przykład przed spropagowaniem go wyżej. Spróbujmy napisać funkcję read_numbers z poprzedniego artykułu tak, żeby używała typu Result:
fn read_numbers(path: &str) -> Result<Vec<i32>, ???> {
let file = File::open(path)?;
let mut results = vec![];
let reader = BufReader::new(file);
for line in reader.lines() {
let number = line?.trim().parse()?;
results.push(number);
}
Ok(results)
}
Zauważ ???, które pojawiają się w sygnaturze mojej funkcji - nie jest to poprawna składnia Rusta, niestety w ciele naszej funkcji pojawiają się dwa różne typy błędów - File::open i BufReader::lines zwracają błędy typu std::io::Error, podczas gdy str::parse zwraca w tym przypadku błąd typu std::num::ParseIntError.
Rozwiązaniem - dosyć typowym - może być przygotowanie nowego typu błędu w formie poznanego poprzednio enum`a:
enum ReadNumbersError {
BadFile(std::io::Error),
LineReadingFailure(std::io::Error),
FormatInvalid(std::num::ParseIntError),
}
fn read_number(path: &str) -> Result<Vec<i32>, ReadNumbersError> {
let file = File::open(path).map_err(ReadNumbersError::BadFile)?;
let mut results = vec![];
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line.map_err(ReadNumbersError::LineReadingFailure)?;
let number = line.trim().parse().map_err(ReadNumbersError::FormatInvalid)?;
results.push(number);
}
Ok(results)
}
Teraz wszystko działa. Warto wiedzieć, że istnieje komplementarna metoda Result::map (a także Option::map), która mapuje wartość Ok (lub Some). Fani języków funkcyjnych mogą myśleć o typach Option i Result jako o funktorach, gdzie funkcja map to odpowiednik fmap z Haskella. Idąc dalej tym tokiem myślenia, Option byłby też monadą, zupełnie jak haskellowy Maybe - odpowiednikiem >>= będzie tu metoda Option::and_then, a odpowiednikiem >> funkcja Option::then (obie funkcje są też dostępne dla typu Result).
Zwróć uwagę, że funkcja Result::map_err, przyjmuje jako argument funkcję o jednym argumencie, którym jest błąd oryginalnego result. Funkcja ta powinna zwrócić nowy typ błędu. Pytanie brzmi - gdzie tam jest jakakolwiek funkcja? Otóż w Ruscie etykiety w enum`ach mogą pełnić rolę funkcji, niejako konstruktorów typów - dzięki temu, możemy ich bardzo łatwo używać właśnie w ten sposób!
Zgodzę się jednak z każdym, kto powie, że wszechobecny Result::map_err wprowadza nieco chaosu - spróbujmy się więc go pozbyć.
Trait Into
W Ruscie wszystkie konwersje muszą dokonywać się jawnie. Nie znaczy to jednak, że sposób konwersji musi być za każdym razem implementowany od nowa - czasem pewne typy są uogólnieniem innych typów - dla takich przypadków biblioteka standardowa Rusta dostarcza specjalny trait Into i jego brata From. Wprawdzie sam system traitów jest dobrym materiałem na osobny artykuł (albo i cały cykl), ale nie sposób nie wspomnieć o From/Into w kontekście obsługi błędów. Spróbujmy więc zaimplementować jeden z tych traitów dla nieco zmodyfikowanego ReadNumbersError:
enum ReadNumbersError {
IO(std::io::Error),
FormatInvalid(std::num::ParseIntError),
}
impl From<std::io::Error> for ReadNumbersError {
fn from(e: std::io::Error) -> Self {
Self::IO(e)
}
}
impl From<std::num::ParseIntError> for ReadNumbersError {
fn from(e: std::num::ParseIntError) -> Self {
Self::FormatInvalid(e)
}
}
W tym miejscu powiedzieliśmy kompilatorowi, że do naszego typu można w jednolity sposób skonwertować typy std::io::Error i std::num::ParseIntError - dokonaliśmy tego implementując w odpowiedni sposób dwie specjalizacje generycznego traita From. Teraz możemy użyć metody ReadNumbersError::from do konwersji typu:
fn read_numbers(path: &str) -> Result<Vec<i32>, ReadNumbersError> {
let file = File::open(path).map_err(ReadNumbersError::from)?;
// ...
}
Najważniejsze jest jednak, że operator ? sam dba o to, żeby dokonać konwersji, jeśli jest ona możliwa - nasza funkcja upraszcza się więc do wcześniej widzianej postaci:
fn read_numbers(path: &str) -> Result<Vec<i32>, ReadNumbersError> {
let file = File::open(path)?;
let mut results = vec![];
let reader = BufReader::new(file);
for line in reader.lines() {
let number = line?.trim().parse()?;
results.push(number);
}
Ok(results)
}
Result i #[must_use]
Żeby być do końca precyzyjnym, należałoby uzupełnić naszą poprzednią definicję typu Result o dodatkowy atrybut:
#[must_use]
enum Result<T, E> {
Ok(T),
Err(E),
}
Co oznacza #[must_use]? Mniej więcej tyle, że wyniku tego typu nie powinno się ignorować - trzeba go przynajmniej przypisać do zmiennej, w przeciwnym przypadku otrzymamy ostrzeżenie czasu kompilacji. Jest to bardzo użyteczne - dzięki temu, jeśli wynik funkcji nie jest dla nas istotny, ale funkcja mogłaby zwrócić błąd, nie przeoczymy tego. Wyobraźmy sobie, że chcemy zapisać coś do pliku:
use std::fs::File;
use std::io::Write;
fn main() {
let mut file = File::create("./nickname").unwrap();
file.write(b"hashed");
}
Oczywiście zapis do pliku, podobnie jak jego otwarcie, może zakończyć się błędem - jednak w tym przypadku kompilator ostrzeże nas przed przeoczeniem:
warning: unused `std::result::Result` that must be used
--> src/main.rs:6:5
|
6 | file.write(b"hashed");
| ^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: this `Result` may be an `Err` variant, which should be handled
Czy to znaczy, że kod w Ruscie zwykł kompilować się z litanią ostrzeżeń tylko dla tego, że w niektórych sytuacjach zignorowanie jest najlepszą obsługą błędu? Otóż nie! Jest wiele sposobów na poinformowanie kompilatora, że błąd jest zignorowany celowo. Moim ulubionym, jest po prostu konwersja wyniku do typu Option:
use std::fs::File;
use std::io::Write;
fn main() {
let mut file = File::create("./nickname").unwrap();
file.write(b"hashed").ok();
}
Prawda, że to ok() jest bardzo wymowne? Innymi sposobami jest przypisanie wyniku do zmiennej, z nazwą zaczynającą się od _ (jeśli nazwa zmiennej nie zacznie się od _, otrzymamy ostrzeżenie o nieużywanej zmiennej) lub adnotacja wyniku atrybutem #[allow(unused_must_use)] - chociaż osobiście nie przepadam za tymi rozwiązaniami.
Konwersja z Option do Result
W poprzedniej części przedstawiłem metodę Option::ok, która pozwala uprościć typ Result do Option. Co jednak, jeśli mamy sytuację odwrotną - zawołana funkcja zwróciła nam Option, a my potrzebujemy ubrać ją w ładny typ błędu? Nic prostszego! Służy do tego funkcja Option::ok_or, lub jej leniwy odpowiednik - Option::ok_or_else. Spójrzmy na przykład użycia:
enum ReadNumbersError {
IO(std::io::Error),
FormatInvalid(std::num::ParseIntError),
DivByZero,
}
fn read_numbers(path: &str) -> Result<Vec<i32>, ReadNumbersError> { ... }
fn divide(a: i32, b: i32) -> Option<i32> { ... }
fn read_and_div(path: &str, a: i32) -> Result<Vec<i32>, ReadNumbersError> {
let mut numbers = read_numbers(path)?;
for number in numbers.iter_mut() {
*number = divide(a, *number).ok_or(ReadNumbersError::DivByZero)?;
}
Ok(numbers)
}
Pominąłem tu implementację traita From i funkcji pomocniczych, ponieważ są one identyczne jak w poprzednich przykładach. Jak widać funkcja Option::ok_or zamienia wartość None na wartość Err, dołączając przekazaną w argumencie wartość - bardzo wygodne.
Przy okazji warto wspomnieć o metodach Option::transpose i Result::transpose. Służą one do zamiany zmiennej typu Option<Result<T, E>> na zmienną typu Result<Option<T>, E> - i odwrotnie. Ilustruje to przykład:
fn main() {
let opt1: Option<Result<_, String>> = Some(Ok(5));
let e1 = opt1.transpose(); // e1 = Ok(Some(5))
let opt2 = e1.transpose(); // opt2 = Some(Ok(5))
let e2: Result<Option<i32>, _> = Err("Błąd");
let opt3 = e2.transpose(); // opt3 = Some(Err("Błąd"))
let e3 = opt3.transpose(); // e3 = Err("Błąd");
let opt4: Option<Result<i32, String>> = None;
let e4 = opt4.transpose(); // e4 = Ok(None)
let opt5 = e4.transpose(); // opt5 = None
}
Zwróć uwagę, że przy podawaniu typu, muszę kompilatorowi wskazać tylko te typy generyczne, których sam nie mógłby wywnioskować z kontekstu - w miejsce pozostałych, mogę wstawić specjalny symbol _.
Biblioteki
Choć do obsługi błędów na dobrą sprawę nie trzeba zaprzęgać dodatkowych bibliotek, to ich pomoc może spowodować, że kod będzie nie tylko czytelniejszy, ale i bardziej funkcjonalny. Pierwszą z bibliotek, o której chciałbym wspomnieć, jest thiserror. Z jej pomocą nasz typ ReadNumbersError mógłby wyglądać np. tak:
#[derive(Debug, thiserror::Error)]
enum ReadNumbersError {
#[error("Error while performing I/O operation: {0:?}")]
IO(#[from] std::io::Error),
#[error("Error while parsing file line as number: {0:?}")]
FormatInvalid(#[from] std::num::ParseIntError),
}
Biblioteka thiserror nie tylko zadba o poprawną implementację traita From, ale także dostarczy nam implementację traita std::error::Error, który pozwoli ładnie raportować przyczynę błędu. Dodatkowo thiserror wymaga, aby nasz typ błędu implementował traita Debug pozwalającego użyć naszego błędu w kontekstach debugowych - jest np. wymagany, aby użyć funkcji Result::unwrap z naszym typem błędu.
Kolejną biblioteką, o której chciałbym wspomnieć, jest anyhow - tego samego autora co thiserror. Pozwala nam ona ujednolicić wszystkie błędy w naszej aplikacji (jednak nie tracąc samej informacji o błędzie) - dzięki niej sygnatura naszej funkcji read_number mogłaby wyglądać tak:
fn read_number(path: &str) -> Result<Vec<i32>, anyhow::Error> {
// ...
}
Dalej moglibyśmy używać ? do propagacji błędów, bez wcześniejszego ich mapowania - typ anyhow::Error implementuje trait From dla wszystkich typów spełniających pewne założenia (spełniają je wszystkie błędy biblioteki standardowej i te generowane przez thiserror).
Biblioteki thiserror najlepiej używać wtedy, gdy tworzymy bibliotekę - dzięki temu możemy dostarczyć wygodne do użycia i rozróżnienia typy błędów dla naszego API, podczas kiedy anyhow lepiej sprawdzi się w aplikacjach - pozwoli nam on ujednolicić typy błędów w większości przypadków, bez dodatkowego kodu.
Warto wiedzieć jednak, że o ile sam rdzeń obsługi błędów Rusta - typy Option i Result - nie zmieniają się drastycznie, o tyle biblioteki mogą tracić i zyskiwać na popularności - jakiś czas temu, popularne było używanie biblioteki failure, od której dzisiaj się raczej odchodzi.
