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

Bartłomiej KurasSenior Rust DeveloperLuxoft

Obsługa błędów w języku Rust, cz. 1: Option

Sprawdź, jak działa typ Option w Rust 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. 1: Option

Obsługa błędów jest jednym z najczęściej nawracających problemów na który napotykamy wytwarzając oprogramowanie. Rust stara się zaadresować ten problem bazując na doświadczeniach innych języków, jednocześnie w sposób, który z jednej strony nie pozwoli na nadużycia (język Rust jest w swoich założeniach bezpieczny), z drugiej - nie spowoduje dodatkowego narzutu.

Typ Option<T>

W języku programowania Rust, większość błędów jest obsługiwana, poprzez wartość zwracaną funkcji. Podstawowym typem, którego możemy w tym celu użyć, jest generyczny typ Option. Samodzielnie można by go zdefiniować w taki sposób:

enum Option<T> {
    None,
    Some(T),
}


W przeciwieństwie na przykład do C++, enum nie jest w Ruscie zwykłą enumeracją - jest to coś, co w językach funkcyjnych nazywa się typem algebraicznym. W większości języków obiektowych, najbliższym odpowiednikiem jest "variant" (w C++ od wersji 17 - std::variant). W przypadku naszego typu Option - zmienna tego typu może przyjąć jedną z dwóch wartości - Option::None, albo Option::Some, w dodatku jeśli przypisaną wartością jest Option::Some, to będzie z nią powiązany jakiś typ generyczny. Co to za typ? Spójrzmy na przykłady:

fn main() {
    let opt1: Option<f32> = None; // T = f32 (liczba zmiennoprzecinkowa)
    pritnln!("{:?}", opt1); // Wyświetlone zostanie "None"

    let opt2: Option<f32> = Some(3.14); // T = f32
    pritnln!("{:?}", opt2); // Wyświetlone zostanie "Some(3.14)";

    let opt3 = Some(10); // T = i32 (32 bitowa liczba całkowita ze znakiem)
    println!("{:?}", opt3); // Wyświetlone zostanie "Some(10)";

    let opt4 = None; // T = ??? - błąd kompilacji
}


Jak widać typ generyczny T może być dowolnym typem - ważne, żeby był on zawsze ten sam dla konkretnej zmiennej. Warto również zauważyć dwie rzeczy. Po pierwsze pomimo, że wcześniej nazwałem wartości Optiona Option::None i Option::Some, w kodzie całkowicie pominąłem poprzedzanie wartości nazwą typu - jest to możliwe ponieważ typ Option jest tak powszechnie używany, że twórcy języka zdecydowali się na wbudowanie jego wartości do języka jako słów kluczowych.

Drugim wartym odnotowania faktem jest, że w przypadku zmiennej opt3 zupełnie pominąłem deklarację jej typu - o ile jest to możliwe z kontekstu, mechanizm elizji typów Rusta sam jest w stanie wybrać odpowiedni typ dla zmiennej - w tym przypadku kompilator decyduje się na Option<i32> (i32 jest domyślnym typem dla literałów całkowitych). Jest to w pewnym sensie odpowiednik słowa kluczowego auto z C++. Jak widać w przypadku opt4 Rust nie jest w stanie poradzić sobie z elizją typu, jeśli nie jest znany typ dla wariantu Some - typ Option jest wówczas niekompletny i aplikacja nie skompiluje się.

Obsługa błędów z wykorzystaniem typu Option

Spróbujmy więc napisać jakąś prostą funkcję, która do obsługi błędu wykorzysta nowo poznany typ. Dobrym przykładem będzie funkcja, która zrealizuje dzielenie dwóch liczb całkowitych:

fn divide(a: i32, b: i32) -> Option<i32> {
    if b == 0 {
        None
    } else {
        Some(a / b)
    }
}


Zauważ, że nigdzie w całej funkcji nie ma żadnego słówka return! Skąd więc Rust wie co jest wartością zwracaną z funkcji? Jest to bardzo proste - inspirując się językami funkcyjnymi, twórcy Rusta uznali, że większość bytów w języku jest wyrażeniami. Takim wyrażeniem jest na przykład instrukcja sterująca if - jej wynikiem jest ostatnia wartość pojawiająca się w każdym odgałęzieniu tej instrukcji - a więc jeśli warunek b == 0 jest spełniony, cała struktura if przyjmie wartość None - w przeciwnym wypadku, wartością jest Some(a / b).

Dodatkowo korzystamy tu z faktu, że o ile w trakcie wykonania funkcji nie zostanie napotkane słowo return (które w języku Rust się jak najbardziej pojawia), wartością zwróconą z funkcji będzie wartość ostatniego wyrażenia - w tym przypadku naszej instrukcji if.

Mamy więc funkcję, która realizuje bezpieczne dzielenie na liczbach całkowitych (pamiętajmy, że w przypadku takich liczb nie mamy specjalnych wartości typu NaN czy Inf, które mogłyby poprawnie wynik dzielenia przez zero zakodować). Warto przy okazji zauważyć, że nie musimy za każdym razem takiej funkcji pisać samemu - biblioteka standardowa dostarcza metodę checked_div dla wszystkich typów całkowitoliczbowych, która realizuje dokładnie to zadanie. Jak jednak w jakikolwiek rozsądny sposób użyć wyniku takiej funkcji?

Metoda Option::unwrap

Najprostszym narzędziem jakiego możemy użyć jest metoda Option<T>::unwrap, która zwraca T, jeśli zmienna przyjmowała wartość Some, lub spowoduje crash aplikacji w przeciwnym wypadku.

Ale jak to - crash? Czy Rust nie miał być językiem bezpiecznym? Odpowiadam więc - spokojnie, Rust dalej jest bezpieczny. Zauważmy, że crash aplikacji nie pozostawia nas w sytuacji zagrożenia - aplikacja która przestała działać nie staje się słabym punktem systemu którego można użyć do pisania exploita, nie jest także w nieokreślonym stanie który tylko czeka, aby wywołać "kernel panic".

Są nawet sytuacje, kiedy crash jest najbezpieczniejszym co można zrobić - np próba zaalokowania pamięci kiedy nie ma jej już dostępnej w systemie, lub indeksowanie tablicy zbyt dużą wartością - spotykany w tych sytuacjach SEGFAULT to dowód na skuteczne działanie systemu operacyjnego, próbującego obronić nas przed tym, przed czym nie ochronił nas używany język programowania. Zgodzę się jednak, że crashy staramy się uniknąć.

Kiedy więc użycie unwarp może mieć sens? Przede wszystkim w sytuacji, kiedy możemy mieć pewność, że dla przekazanych argumentów, funkcja zwróci wartość Some:

fn half(a: i32) -> i32 {
    divide(a, 2).unwrap()
}

Zastanówmy się jednak, jak można faktycznie obsłużyć błędną sytuację.

Instrukcje sterujące match i if let

Podstawową metodą radzenia sobie z obsługą zwróconego błędu jest próba dopasowania go do wzorca używając instrukcji match:

fn div_or_zero(a: i32, b: i32) -> i32 {
    match divide(a, b) {
        Some(res) => res,
        None => 0,
    }
}


W gruncie rzeczy match jest instrukcją bardzo podobną w założeniach do znanego z C++ switch...case - próbuje ona dopasować wyrażenie, do jednego ze wzorców. Co też jest w przytoczonym kodzie widoczne, podobnie jak w przypadku instrukcji if, match jest wyrażeniem - jego wartością jest wartość ostatniego wyrażenia każdej z jego odnóg.

Skoro już wspomniałem instrukcję if, warto przywołać jej specjalną wersję, która pozwala na próbę dekompozycji zmiennej do danego wzorca - jeśli próba się powiedzie, odpowiednia odnoga instrukcji sterującej zostanie wykonana:

fn div_or_zero(a: i32, b: i32) -> i32 {
    if let Some(res) = divide(a, b) {
        res
    } else {
        0
    }
}

Metoda Option::unwrap_or

Zdaję sobie sprawę, że unwrap może na tę chwilę wydawać się alarmujący, jednak chciałbym wspomnieć tu o bardzo użytecznej metody Option::unwrap_or - tym razem jednak obiecuję, nie będę crashował naszego programu. Okazuje się że nasza funkcja div_or_zero, może z użyciem tej metody stać się jednolinijkowcem:

fn div_or_zero(a: i32, b: i32) -> i32 {
    divide(a, b).unwrap_or(0)
}


Option::unwrap_or, podobnie jak Option::unwrap, w przypadku kiedy zmienna przyjmie wartość Some, zwróci wartość powiązaną z naszym Option. W przypadku jednak, gdyby nasza zmienna przechowywała None, unwrap_or przyjmuje w argumencie wartość domyślną - będzie ona zwrócona właśnie w takim przypadku. Na dobrą sprawę unwrap_* to cała rodzina funkcji - unwrap_or_else, unwrap_or_default - które pozwalają nam zamienić Option na prostszy typ T, zwracając jakąś domyślną wartość obliczaną leniwie - pierwsza, używając do jej policzenia przekazanego domknięcia, druga - specjalnego traita Default, będącego odpowiednikiem konstruktora domyślnego.

Propagacja błędu z użyciem '?'

Na pierwszy rzut oka wszytko wygląda pięknie, co jednak, jeśli chcielibyśmy napisać funkcję, w trakcie której błąd może pojawić się w wielu miejscach, a kiedy się pojawi - funkcja ma zostać przerwana z błędem? Czy to na prawdę musi wyglądać tak?:

use std::fs::File;
use std::io::{BufReader, BufRead};

fn read_numbers(path: &str) -> Option<Vec<i32>> {
    let file = if let Some(file) = File::open(path).ok() {
        file
    } else {
        return None;
    };

    let mut results = vec![];
    let reader = BufReader::new(file);

    for line in reader.lines() {
        let line = if let Some(line) = line.ok() {
            line
        } else {
            return None;
        };

        if let Some(number) = line.trim().parse().ok() {
            results.push(number);
        } else {
            return None;
        }
    }

    Some(results)
}


Muszę przyznać, że obsługa błędów w tym fragmencie wygląda ohydnie - gdybym musiał pisać taki kod, na pewno nie wybrałbym tej technologii. Przedstawiam więc wam operator propagacji błędu - ?. Oznacza on mniej więcej: "jeśli wartość przechowuje błąd, przerwij funkcję z błędem, w przeciwnym wypadku, zwróć poprawną wartość jako wartość wyrażenia". Spójrzmy na przykład:

fn div_and_square(a: i32, b: i32) -> Option<i32> {
    let c = divide(a, b)?;
    Some(c * c)
}


Zwróćmy uwagę na linię let c = divide(a, b)?; - jeśli funkcja divide zwróci w tej linii wartość Some, operator ? zachowa się podobnie jak metoda unwrap - zwróci wartość, która zostanie zachowana w zmiennej c. Magia dzieje się jednak w przypadku, kiedy divide zwróciłoby None - w tym przypadku, operator ? spowoduje wczesny powrót z funkcji div_and_square z wartością None. Na chwilę obecną można założyć, że wyrażenie opt?, gdzie opt jest pewną zmienną typu Option jest cukrem składniowym, rozwiązywanym przez kompilator do formy:

match opt {
    Some(data) => data,
    None => {
        return None;
    }
}


Jest to pewne uproszczenie, ale dobrze obrazujące działanie ?. Mając więc wiedzę o operatorze ?, spróbujmy więc napisać funkcję read_numbers w nieco bardziej czytelny sposób:

fn read_numbers(path: &str) -> Option<Vec<i32>> {
    let file = File::open(path).ok()?;

    let mut results = vec![];
    let reader = BufReader::new(file);

    for line in reader.lines() {
        let line = line.ok()?;
        let number = line.trim().parse().ok()?;
        results.push(number);
    }

    Some(results)
}


Prawda że lepiej? Zdaję sobie sprawę, że pojawia się w tym kodzie sporo funkcji i typów, które do zrozumienia wymagają sprawdzenia dokumentacji biblioteki standardowej, jednak wydaje mi się, że ktokolwiek kto programował w przeszłości, powinien poradzić sobie ze zrozumieniem działania tej funkcji (o ile rozumie oczywiście działanie operatora ? - ale to już za nami!). Chciałbym jednak zwrócić uwagę na wołaną w kilku miejscach dodatkową funkcję ok - co ona tam w ogóle robi?

Otóż dotychczas omawiałem typ Option, który jest jednak nieco (w kontekście obsługi błędów) upośledzony - nie pozwala bowiem zwrócić żadnej informacji o tym, jaki błąd wystąpił - mówi jedynie o tym, że wystąpił. Do w pełni poprawnej obsługi błędów, wykorzystuje się nieco bardziej złożony typ - Result, a wspomniana funkcja to metoda Result::ok, zamienia go w prostszy typ Option. Samemu typowi Result przyjrzymy się w następnej części artykułu.

<p>Loading...</p>