Sytuacja kobiet w IT w 2024 roku
2.03.20218 min
Bartłomiej Kuras

Bartłomiej KurasSenior Rust DeveloperLuxoft

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

Sprawdź, jak działa typ Result w Ruscie i dowiedz się, jak prawidłowo go wykorzystać do obsługi błędów w języku programowania Rust.

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.

<p>Loading...</p>