Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Panikować czy nie panikować!

Jak więc zdecydować, kiedy należy wywołać panic!, a kiedy zwrócić Result? Kiedy kod panikuje, nie ma sposobu na odzyskanie. Możesz wywołać panic! w każdej sytuacji błędu, niezależnie od tego, czy istnieje możliwość odzyskania, czy nie, ale wtedy podejmujesz decyzję, że sytuacja jest nie do odzyskania w imieniu kodu wywołującego. Kiedy zdecydujesz się zwrócić wartość Result, dajesz kodowi wywołującemu opcje. Kod wywołujący może spróbować odzyskać się w sposób odpowiedni dla swojej sytuacji, lub może zdecydować, że wartość Err w tym przypadku jest nie do odzyskania, więc może wywołać panic! i zmienić Twój odzyskiwalny błąd w błąd nie do odzyskania. Dlatego zwracanie Result jest dobrym domyślnym wyborem, gdy definiujesz funkcję, która może zawieść.

W sytuacjach takich jak przykłady, kod prototypowy i testy, bardziej odpowiednie jest pisanie kodu, który panikuje zamiast zwracać Result. Zbadajmy dlaczego, a następnie omówmy sytuacje, w których kompilator nie może stwierdzić, że awaria jest niemożliwa, ale Ty jako człowiek możesz. Rozdział zakończy się ogólnymi wytycznymi dotyczącymi tego, jak zdecydować, czy panikować w kodzie biblioteki.

Przykłady, kod prototypowy i testy

Kiedy piszesz przykład, aby zilustrować jakąś koncepcję, również uwzględnianie solidnego kodu do obsługi błędów może uczynić przykład mniej jasnym. W przykładach rozumie się, że wywołanie metody, takiej jak unwrap, która może spowodować panikę, ma być jedynie symulatorem sposobu, w jaki Twoja aplikacja obsługiwałaby błędy, co może się różnić w zależności od tego, co robi reszta Twojego kodu.

Podobnie, metody unwrap i expect są bardzo przydatne, gdy prototypujesz i nie jesteś jeszcze gotowy, aby zdecydować, jak obsługiwać błędy. Pozostawiają one jasne znaczniki w kodzie, na wypadek gdy będziesz gotowy, aby uczynić swój program bardziej niezawodnym.

Jeśli wywołanie metody zakończy się niepowodzeniem w teście, chciałbyś, aby cały test zakończył się niepowodzeniem, nawet jeśli ta metoda nie jest testowaną funkcjonalnością. Ponieważ panic! oznacza, że test zakończył się niepowodzeniem, wywołanie unwrap lub expect jest dokładnie tym, co powinno się stać.

Kiedy masz więcej informacji niż kompilator

Odpowiednie byłoby również wywołanie expect, gdy masz inną logikę, która gwarantuje, że Result będzie miał wartość Ok, ale logika nie jest czymś, co kompilator rozumie. Nadal będziesz mieć wartość Result, którą musisz obsłużyć: każda operacja, którą wywołujesz, nadal ma możliwość ogólnego niepowodzenia, nawet jeśli jest to logicznie niemożliwe w Twojej konkretnej sytuacji. Jeśli możesz upewnić się, ręcznie sprawdzając kod, że nigdy nie będziesz mieć wariantu Err, jest całkowicie dopuszczalne wywołanie expect i udokumentowanie powodu, dla którego uważasz, że nigdy nie będziesz mieć wariantu Err w tekście argumentu. Oto przykład:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Zakodowany na stałe adres IP powinien być prawidłowy");
}

Tworzymy instancję IpAddr poprzez parsowanie zakodowanego na stałe ciągu znaków. Widzimy, że 127.0.0.1 jest prawidłowym adresem IP, więc w tym przypadku użycie expect jest dopuszczalne. Jednak posiadanie zakodowanego na stałe, prawidłowego ciągu znaków nie zmienia typu zwracanego przez metodę parse: nadal otrzymujemy wartość Result, a kompilator nadal będzie nas zmuszał do obsługi Result, tak jakby wariant Err był możliwy, ponieważ kompilator nie jest wystarczająco sprytny, aby zobaczyć, że ten ciąg znaków jest zawsze prawidłowym adresem IP. Gdyby ciąg znaków adresu IP pochodził od użytkownika, a nie był zakodowany na stałe w programie i dlatego miał możliwość awarii, z pewnością chcielibyśmy obsłużyć Result w bardziej niezawodny sposób. Wspomnienie o założeniu, że ten adres IP jest zakodowany na stałe, skłoni nas do zmiany expect na lepszy kod obsługi błędów, jeśli w przyszłości będziemy musieli uzyskać adres IP z innego źródła.

Wytyczne dotyczące obsługi błędów

Zaleca się, aby kod panikował, gdy możliwe jest, że kod może znaleźć się w złym stanie. W tym kontekście zły stan to sytuacja, w której zostało naruszone jakieś założenie, gwarancja, kontrakt lub niezmiennik, na przykład, gdy do kodu przekazywane są nieprawidłowe wartości, wartości sprzeczne lub brakujące wartości — plus jeden lub więcej z poniższych:

  • Zły stan jest czymś nieoczekiwanym, w przeciwieństwie do czegoś, co prawdopodobnie będzie się sporadycznie zdarzać, jak na przykład użytkownik wprowadzający dane w niewłaściwym formacie.
  • Twój kod po tym punkcie musi polegać na tym, że nie jest w tym złym stanie, zamiast sprawdzać problem na każdym kroku.
  • Nie ma dobrego sposobu na zakodowanie tych informacji w używanych typach. Omówimy przykład tego, co mamy na myśli, w sekcji „Kodowanie stanów i zachowań jako typy” w Rozdziale 18.

Jeśli ktoś wywoła Twój kod i przekaże wartości, które nie mają sensu, najlepiej jest zwrócić błąd, jeśli to możliwe, aby użytkownik biblioteki mógł zdecydować, co chce zrobić w takim przypadku. Jednak w przypadkach, gdy kontynuowanie mogłoby być niebezpieczne lub szkodliwe, najlepszym wyborem może być wywołanie panic! i ostrzeżenie osoby używającej Twojej biblioteki o błędzie w jej kodzie, aby mogła go naprawić podczas developmentu. Podobnie, panic! jest często odpowiednie, jeśli wywołujesz kod zewnętrzny, który jest poza Twoją kontrolą i zwraca nieprawidłowy stan, którego nie masz jak naprawić.

Jednakże, gdy awaria jest oczekiwana, bardziej odpowiednie jest zwrócenie Result niż wywołanie panic!. Przykłady obejmują parser, który otrzymuje źle sformatowane dane, lub żądanie HTTP zwracające status wskazujący, że osiągnięto limit szybkości. W takich przypadkach zwrócenie Result wskazuje, że awaria jest oczekiwaną możliwością, którą kod wywołujący musi zdecydować, jak obsłużyć.

Kiedy Twój kod wykonuje operację, która mogłaby narazić użytkownika na ryzyko, jeśli zostanie wywołana z nieprawidłowymi wartościami, Twój kod powinien najpierw zweryfikować, czy wartości są prawidłowe, i wywołać panikę, jeśli wartości są nieprawidłowe. Jest to głównie ze względów bezpieczeństwa: próba operowania na nieprawidłowych danych może narazić Twój kod na luki. To główny powód, dla którego standardowa biblioteka wywoła panic!, jeśli spróbujesz uzyskać dostęp do pamięci poza jej granicami: próba dostępu do pamięci, która nie należy do bieżącej struktury danych, jest częstym problemem bezpieczeństwa. Funkcje często mają kontrakty: ich zachowanie jest gwarantowane tylko wtedy, gdy dane wejściowe spełniają określone wymagania. Panika, gdy kontrakt jest naruszony, ma sens, ponieważ naruszenie kontraktu zawsze wskazuje na błąd po stronie wywołującego, i nie jest to rodzaj błędu, który kod wywołujący powinien jawnie obsługiwać. W rzeczywistości nie ma rozsądnego sposobu, aby kod wywołujący się odzyskał; programiści wywołujący muszą naprawić kod. Kontrakty funkcji, zwłaszcza gdy naruszenie spowoduje panikę, powinny być wyjaśnione w dokumentacji API funkcji.

Jednakże, posiadanie wielu kontroli błędów we wszystkich funkcjach byłoby rozbudowane i uciążliwe. Na szczęście, możesz użyć systemu typów Rusta (a co za tym idzie, sprawdzania typów wykonywanego przez kompilator), aby wykonać wiele kontroli za Ciebie. Jeśli Twoja funkcja ma określony typ jako parametr, możesz kontynuować logikę kodu, wiedząc, że kompilator już zapewnił, że masz prawidłową wartość. Na przykład, jeśli masz typ zamiast Option, Twój program oczekuje czegoś zamiast niczego. Twój kod nie musi wtedy obsługiwać dwóch przypadków dla wariantów Some i None: będzie miał tylko jeden przypadek dla zdecydowanego posiadania wartości. Kod próbujący przekazać nic do Twojej funkcji nawet się nie skompiluje, więc Twoja funkcja nie musi sprawdzać tego przypadku w czasie wykonywania. Innym przykładem jest użycie typu liczby całkowitej bez znaku, takiego jak u32, co zapewnia, że parametr nigdy nie jest ujemny.

Niestandardowe typy do walidacji

Rozwińmy ideę używania systemu typów Rusta do zapewnienia, że mamy prawidłową wartość, idąc o krok dalej i przyjrzyjmy się tworzeniu niestandardowego typu do walidacji. Przypomnij sobie grę zgadywanek z Rozdziału 2, w której nasz kod prosił użytkownika o odgadnięcie liczby od 1 do 100. Nigdy nie walidowaliśmy, czy odgadnięta przez użytkownika liczba mieściła się w tym zakresie, zanim porównaliśmy ją z naszą tajną liczbą; walidowaliśmy tylko, czy odgadnięta liczba była dodatnia. W tym przypadku konsekwencje nie były zbyt poważne: nasze komunikaty „Za mała” lub „Za duża” nadal byłyby poprawne. Ale przydatnym ulepszeniem byłoby pokierowanie użytkownika w stronę prawidłowych zgadywanek i zapewnienie innego zachowania, gdy użytkownik odgadnie liczbę spoza zakresu, niż gdy użytkownik wpisze na przykład litery.

Jednym ze sposobów na to byłoby parsowanie odgadniętej liczby jako i32 zamiast tylko u32, aby umożliwić potencjalnie ujemne liczby, a następnie dodanie sprawdzenia, czy liczba mieści się w zakresie, tak jak poniżej:

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Odgadnij liczbę!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        // --snip--

        println!("Wprowadź swoje odgadnięcie.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Nie udało się odczytać linii");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("Tajny numer będzie między 1 a 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Za mała!"),
            Ordering::Greater => println!("Za duża!"),
            Ordering::Equal => {
                println!("Wygrałeś!");
                break;
            }
        }
    }
}

Wyrażenie if sprawdza, czy nasza wartość jest poza zakresem, informuje użytkownika o problemie i wywołuje continue, aby rozpocząć kolejną iterację pętli i poprosić o kolejne odgadnięcie. Po wyrażeniu if możemy kontynuować porównania między guess a tajną liczbą, wiedząc, że guess znajduje się między 1 a 100.

Jednak to nie jest idealne rozwiązanie: gdyby absolutnie kluczowe było to, aby program operował tylko na wartościach od 1 do 100, i miał wiele funkcji z tym wymogiem, posiadanie takiego sprawdzenia w każdej funkcji byłoby uciążliwe (i mogłoby wpłynąć na wydajność).

Zamiast tego, możemy utworzyć nowy typ w dedykowanym module i umieścić walidacje w funkcji, aby utworzyć instancję tego typu, zamiast powtarzać walidacje wszędzie. W ten sposób funkcje będą mogły bezpiecznie używać nowego typu w swoich sygnaturach i śmiało używać otrzymanych wartości. Listing 9-13 pokazuje jeden ze sposobów definiowania typu Guess, który utworzy instancję Guess tylko wtedy, gdy funkcja new otrzyma wartość z zakresu od 1 do 100.

#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Wartość odgadnięcia musi być między 1 a 100, otrzymano {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

Zauważ, że ten kod w src/guessing_game.rs zależy od dodania deklaracji modułu mod guessing_game; w src/lib.rs, której tutaj nie pokazaliśmy. W pliku tego nowego modułu definiujemy strukturę o nazwie Guess, która ma pole o nazwie value, przechowujące i32. Tutaj będzie przechowywana liczba.

Następnie implementujemy funkcję skojarzoną o nazwie new dla Guess, która tworzy instancje wartości Guess. Funkcja new jest zdefiniowana tak, aby miała jeden parametr o nazwie value typu i32 i zwracała Guess. Kod w treści funkcji new testuje value, aby upewnić się, że mieści się ono w zakresie od 1 do 100. Jeśli value nie przejdzie tego testu, wywołujemy panic!, co ostrzeże programistę piszącego kod wywołujący, że ma błąd do naprawienia, ponieważ utworzenie Guess z value spoza tego zakresu naruszyłoby kontrakt, na którym polega Guess::new. Warunki, w których Guess::new może panikować, powinny być omówione w jego publicznie dostępnej dokumentacji API; konwencje dokumentacji wskazujące na możliwość paniki w dokumentacji API, którą tworzysz, omówimy w rozdziale 14. Jeśli value przejdzie test, tworzymy nowy Guess z jego polem value ustawionym na parametr value i zwracamy Guess.

Następnie implementujemy metodę o nazwie value, która pożycza self, nie ma żadnych innych parametrów i zwraca i32. Taki rodzaj metody jest czasami nazywany getterem, ponieważ jego celem jest pobranie danych z pól i zwrócenie ich. Ta publiczna metoda jest niezbędna, ponieważ pole value struktury Guess jest prywatne. Ważne jest, aby pole value było prywatne, aby kod używający struktury Guess nie mógł bezpośrednio ustawiać value: kod spoza modułu guessing_game musi używać funkcji Guess::new do tworzenia instancji Guess, zapewniając w ten sposób, że nie ma możliwości, aby Guess miało value, które nie zostało sprawdzone przez warunki w funkcji Guess::new.

Funkcja, która ma parametr lub zwraca tylko liczby z zakresu od 1 do 100, mogłaby następnie zadeklarować w swojej sygnaturze, że przyjmuje lub zwraca Guess zamiast i32 i nie musiałaby wykonywać żadnych dodatkowych sprawdzeń w swoim ciele.

Podsumowanie

Funkcje obsługi błędów w Rust są zaprojektowane tak, aby pomóc Ci pisać bardziej niezawodny kod. Makro panic! sygnalizuje, że Twój program jest w stanie, którego nie jest w stanie obsłużyć, i pozwala Ci nakazać procesowi zatrzymanie się, zamiast próbować kontynuować z nieprawidłowymi lub błędnymi wartościami. Enum Result używa systemu typów Rusta, aby wskazać, że operacje mogą zakończyć się niepowodzeniem w sposób, który Twój kod mógłby odzyskać. Możesz użyć Result, aby powiedzieć kodowi, który wywołuje Twój kod, że musi on również obsłużyć potencjalny sukces lub niepowodzenie. Użycie panic! i Result w odpowiednich sytuacjach sprawi, że Twój kod będzie bardziej niezawodny w obliczu nieuniknionych problemów.

Teraz, gdy widziałeś przydatne sposoby, w jakie standardowa biblioteka używa generyków z enumami Option i Result, porozmawiamy o tym, jak działają generyki i jak możesz ich używać w swoim kodzie.