Otwórz w Python plik, który nie istnieje, i open() zgłasza FileNotFoundError, który wychodzi poza twoją funkcję. Zrób to samo w Go, a os.ReadFile zwraca wartość error razem z nilowym slice’em bajtów. Ten sam błąd, dwie zupełnie inne formy kodu wokół niego. Różnica nie jest kosmetyczna. Decyduje o tym, czy wywołujący może przypadkowo zignorować błąd, i czy kompilator to zauważy, gdy to się stanie.
Co exceptions i wartości błędów robią naprawdę inaczej?
Exception to przepływ sterowania, który skacze. Coś zawodzi, funkcja zgłasza wyjątek, a wykonanie opuszcza bieżący stos wywołań i trafia do najbliższego dopasowanego bloku catch albo except, niezależnie od tego, ile poziomów wyżej to jest. Sygnatura funkcji nie daje żadnej wskazówki, że to może się zdarzyć. loadConfig(path: string): Config w TypeScript wygląda jak funkcja, która nie może zawieść — aż zgłosi wyjątek przy brakującym pliku.
Wartość błędu to dane. Funkcja zwraca ją razem z wynikiem, o który poproszono, albo w jego miejsce. (Config, error) w Go, Result<Config, ConfigError> w Rust i wyszukanie, które przy braku wyniku zwraca null, to ten sam pomysł: błąd jest częścią typu zwracanego, więc wywołujący widzi go wprost w miejscu wywołania. W Rust system typów zmusza cię do obsłużenia go, zanim dotkniesz wyniku. W Go robi to konwencja.
Żadne z tych dwóch podejść nie jest nowe i żadne nie wygrało ostatecznie. C zwracało wartości sentinel, takie jak -1 i NULL; exceptions pojawiły się później, by wyprowadzić obsługę błędów z głównej logiki. Python, Java, C# i JavaScript domyślnie używają exceptions. Go i Rust domyślnie używają wartości błędów. Większość języków, które mają jeden model jako domyślny, i tak pozwala sięgnąć po drugi, kiedy lepiej się nadaje.
Jak Go zwraca błędy jako wartości?
Go nie ma exceptions dla zwykłych błędów. Funkcja, która może zawieść, zwraca error jako ostatnią wartość, a wywołujący sprawdza to natychmiast:
| |
%w owija oryginalny błąd, zamiast go odrzucać, dzięki czemu errors.Is i errors.As wyżej w stosie mogą sprawdzić, co poszło nie tak, bez porównywania tekstu komunikatu.
Słaby punkt: nic nie powstrzymuje cię od napisania data, _ := os.ReadFile(path) i odrzucenia błędu. Kompilator przyjmie to bez problemu. Linter taki jak errcheck wychwyci niesprawdzone błędy, ale tylko jeśli jest częścią twojego CI.
Jak exceptions w Python skaczą do handlera?
Ten sam loader konfiguracji w Python nie ma inline’owych sprawdzeń błędów po każdym wywołaniu. Sprawdzenie dzieje się wokół wywołania, nie wewnątrz niego:
| |
raise ... from e zachowuje oryginalny traceback przypięty do nowego ConfigError, tak jak %w w Go zachowuje dostępność oryginalnego błędu.
Różnica, która się liczy, to sygnatura. load_config nigdy nie mówi, że może zgłosić wyjątek. Wywołujący trzy poziomy wyżej, który zapomniał o try, odkrywa to, gdy program pada z tracebackiem na produkcji, bo nic w języku nie sprawdza miejsca wywołania.
Jak Result w Rust zmusza cię do obsłużenia błędu?
Rust zachowuje ideę błędu jako wartości i dodaje system typów, który nie pozwoli ci dotknąć wartości sukcesu bez uznania przypadku błędu:
| |
To wymaga serde (z feature derive) oraz serde_json w Cargo.toml.
Result<T, E> to enum z dwoma wariantami, Ok(T) i Err(E), i nie da się wydobyć z niego Config bez obsłużenia obu. Operator ? jest skrótem dla tego match: jeśli fs::read_to_string zwróci Err, load_config kończy się natychmiast tym błędem, zmapowanym na ConfigError::Io. W przeciwnym razie rozpakowuje Ok i idzie dalej.
Wszystko to można obejść przez .unwrap(), który panikuje przy Err. W Rust to jest odpowiednik nieobsłużonego wyjątku. Traktuj to jako coś do zakwestionowania w code review, nie jako skrót.
Czemu fetch() nie zgłasza wyjątku przy 404?
Podział nie przebiega tylko między językami. Jedno API może używać obu modeli dla różnych błędów. fetch() odrzuca swój promise przy błędzie sieci (DNS się nie rozwiązuje, połączenie odrzucone), ale rozwiązuje się normalnie przy 404 albo 500. Status HTTP jest wartością na odpowiedzi, którą już masz: sprawdzasz response.ok, niczego nie przechwytujesz. Obsługa Błędów Fetch API w JavaScript wyjaśnia, czemu to zaskakuje tych, którzy oczekują, że błąd HTTP zgłosi wyjątek, i jak owinąć fetch(), żeby odzyskać zachowanie w stylu exception, jeśli tego chcesz.
Kiedy używać exceptions, a kiedy zwracać błędy?
Zadaj sobie jedno pytanie o konkretny błąd: czy bezpośredni wywołujący musi na niego zareagować, czy powinien powędrować wyżej, aż jakaś granica go obsłuży?
Brak w cache albo błąd walidacji danych od użytkownika to wynik, którego twój kod powinien się spodziewać i rozgałęzić się od razu w tym miejscu. Wartość błędu się tu nadaje. Wywołujący nie może wykonać swojej pracy bez reakcji, więc typ zwracany, który czyni błąd widocznym, jest pomocą, nie formalnością.
Połączenie z bazą danych, które pada w połowie transakcji, albo asercja o stanie programu, która okazuje się fałszywa, to coś innego. Nic, co zrobi bezpośredni wywołujący, tego nie naprawi. Pozwolenie, by to propagowało jako exception albo panic do jednego miejsca, które loguje i kończy działanie porządnie, to mniej kodu i bardziej uczciwy kod niż przeciąganie tego ręcznie przez każdą ramkę.
Języki, które mają jeden model jako domyślny, już używają drugiego tam, gdzie się nadaje. Go panikuje przy indeksie slice’a poza zakresem, bo to błąd w kodzie, nie warunek, który wywołujący powinien obsłużyć. Rust panikuje przy przepełnieniu liczby całkowitej w buildach debug. dict.get(key, default) w Python zwraca wartość, zamiast zgłaszać KeyError, na przypadek, gdy brakujący klucz jest oczekiwany, a nie wyjątkowy.
Co idzie nie tak z każdą strategią w praktyce?
Przełknięte błędy. Puste except:, które nic nie robi, albo if err != nil { } w Go z pustym ciałem, wymazuje błąd. Symptom pojawia się później jako brakujące dane albo pusty ekran, bez żadnego błędu w pobliżu prawdziwej przyczyny. To ten trudny do wyśledzenia przepływ opisany w Czym Jest Spaghetti Code i Jak Go Zrefaktoryzować.
Panic w złej goroutine. recover() działa tylko wewnątrz goroutine, która spanikowała. Odroczony recover w main nie uratuje cię od panic w goroutine roboczej; cały program pada. Goroutines i Channels w Go: Przewodnik z Przykładami pokazuje, czemu błędy z pracy współbieżnej powinny wracać przez channel, jako wartości.
.unwrap() i ogólne catch zamiast decyzji. Oba pozwalają przestać myśleć o przypadku błędu, bez tego, żeby naprawdę zniknął. Napisz test, który wymusi ścieżkę błędu. Jest to dużo łatwiejsze, gdy funkcja zwraca błąd, który możesz sam skonstruować, niż gdy zgłasza go z głębi biblioteki, której nie kontrolujesz, i to powód, dla którego ścieżki błędów mają własne testy w Testy Jednostkowe vs Integracyjne vs End-to-End.
Jak wybierać per funkcja, nie per język?
Następnym razem, gdy masz napisać throw albo return err, spójrz na funkcję przed sobą. Jeśli jej wywołujący musi zareagować od razu, umieść błąd w typie zwracanym: (T, error) w Go, Result w Rust, None albo wartość domyślna w Python. Jeśli nic użytecznego nie może się zdarzyć, zanim błąd dotrze do handlera wyższego poziomu, zgłoś wyjątek albo zapanikuj i obsłuż to raz, na tej granicy. Potem dodaj test dla ścieżki błędu, którą wybrałeś.