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 Resul
t 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.