Dlaczego wyjątki nadal są OK
Wyjątki są fajne. To eleganckie zasygnalizowanie, że coś potoczyło się nie tak, jak planowaliśmy i należy to obsłużyć poza główną logiką.
Niektórzy jednak uważają, że wyjątki to antywzorzec i nie powinny być w ogóle używane. Nie wzięto jednak pod uwagę tego, że z używania wyjątków przychodzi również wiele dobrego. Może zatem korzyści przeważają koszty?
Argumenty przeciw
Możemy mieć następujące problemy z wyjątkami:
- Użycie wyjątków sprawia, że można opóźnić zaimplementowanie obsługi błędów, a przy presji na dostarczanie pewnych funkcji, błędy zostają odkryte dopiero przez użytkownika końcowego.
- Wyjątki przenoszą wykonanie do handlera, który może być w innym miejscu w kodzie. To sprawia, że patrząc na kod z logiką, nie jest do końca jasne, jak będzie przebiegać wykonanie w przypadku pojawienia się wyjątku.
- Czasem wyjątki są używane zamiast zwracania wartości, co pogarsza czytelność i sprawia, że API jest niejasne.
- Jeśli wyjątki mają być używane w projekcie konsekwentnie, wiele decyzji trzeba podjąć wcześniej.
Zasadniczo nie ma wątpliwości, że wyjątki to wygodne narzędzie, ale obawy dotyczące długoterminowych negatywnych skutków ich używania dotyczą kodu, który rozwijamy, a nie piszemy od nowa. Przeanalizujmy każdy z powyższych punktów i zobaczmy, czy istnieje dobry powód, aby się z wyjątkami zmierzyć.
Opóźniony error handling
Ten problem zasadniczo dotyczy kolejności. Używając wyjątków, możemy najpierw uporać się z główną logiką, a potem zająć się błędami. W praktyce jednak często właściwa obsługa błędów zejdzie na drugi plan, jeżeli tylko pojawi się coś ważniejszego. Dzięki alternatywie w postaci zwracania StatusOr<T>
musielibyśmy zadbać przynajmniej o propagację błędu, równolegle z główną logiką. W takiej sytuacji łatwiej zauważyć, że zwrócony błąd nie został obsłużony, niż zobaczyć, że wyjątek nie został obsłużony.
Ciężko dyskutować z tym, że zwrócony błąd zmusza inżyniera do zajęcia się nim, podczas gdy, używając wyjątków, można nawet nie być go świadomym. Praca z wyjątkami wymaga większej dyscypliny przy obsłudze błędów.
Transfer w jedną stronę
Być może łatwo zauważyć nieobsłużony błąd zwrócony z funkcji, ale nieobsłużony wyjątek może nie być tak oczywisty. Moglibyśmy mieć jakiś wysokopoziomowy handler wyjątków, które loguje problem i sygnalizuje klientowi systemu, że problem istnieje.
To nie tylko wygodne, ale też bezpieczne. Jeżeli będziemy zwracać błędy, to polegamy na inżynierach, którzy będą zwracać uwagę na poprawną obsługę błędów, np. dzięki code review. Jeżeli jednak jakiś błąd się prześliźnie, to cała aplikacja może paść. Taka awaria aplikacji może być sygnałem, że coś jest bardzo nie tak i należy to natychmiast naprawić („fail fast”, ciekawy artykuł na ten temat), ale w tym scenariuszu dobrze jest mieć wybór — jeśli serwer obsługuje setki współbieżnych żądań, może nas nie być stać na to, aby zawieść wszystkie z nich z powodu jednego pominiętego błędu.
Poświęcamy wtedy przejrzystość — możemy nie wiedzieć, że jakaś metoda rzuci wyjątek, jednak z drugiej strony ten błąd nas dużo nie kosztuje — wszystkie działania zostałyby przerwane i przeniesione do tego wysokopoziomowego narzędzia do obsługi błędów.
Są jednak przypadki, w których taka akcja może nie być bezpieczna, na przykład, podczas gdy jesteśmy w trakcie transakcji i popełnimy błąd, przez który system wpadnie w niespójny stan. Takie coś nie jest jednak specyficzne dla wyjątków — to samo może się zdarzyć, gdy będziemy zwracać błąd.
Przepływ sterowania z wyjątkami (na niebiesko) i statusami (na zielono)
Co więcej, istnieje wiele przypadków, w których naprawdę niewiele możemy zrobić. Nie możemy kontynuować wysyłania wiadomości, jeśli konto użytkownika nie istnieje.
Jeśli zamiast poprawnej ilości pieniędzy będziemy mieli ciąg znaków „wyciągnij wszystko”, to nie możemy dokonać operacji wypłaty pieniędzy. Jedyne, co możemy zrobić, to zasygnalizować, że operacja się nie powiodła (nie zatrzymując przy tym całej aplikacji). Z wyjątkami nasze wysokopoziomowe narzędzie do obsługi błędów zajmowałaby się takimi przypadkami. Nie musimy propagować błędu przez wszystkie zagnieżdżone wywołania funkcji aż do wysokopoziomowego handlera błędów.
Część kodu schedulera z Kubernetesa
Chociaż obsługa wyjątku może wyglądać jak przeskok z jednego miejsca do drugiego, to tak naprawdę przechodzimy cały stos wywołań aż do handlera wyjątków. W rezultacie mamy ładny stacktrace, który wyraźnie pokazuje, w której linii wystąpił błąd oraz, jak do niej dotarliśmy. W przypadku zwracania statusów musimy polegać na inżynierach, którzy uwzględniają cały kontekst przy propagowaniu błędu (Go wprowadził pewne ulepszenia w tym zakresie).
Jednak transfer w jedną stronę jest mieczem obosiecznym. Jeśli nie zostanie właściwie użyty, sprawi wszystkie problemy, z którymi boryka się goto. O tym w kolejnej sekcji.
Kolejny sposób na zwrócenie wartości
„Nie używaj wyjątków przy przepływie sterowania” jest dobrze znaną zasadą, ale często ignorowaną. Na przykład, w tym wątku na StackOverflow autor sugeruje, że obsługa wyjątków wygląda lepiej z następującym kodem:
try
{
doSomething() ;
doSomethingElse() ;
doSomethingElseAgain() ;
}
catch(const SomethingException & e)
{
// react to failure of doSomething
}
catch(const SomethingElseException & e)
{
// react to failure of doSomethingElse
}
catch(const SomethingElseAgainException & e)
{
// react to failure of doSomethingElseAgain
}
Nasuwa się pytanie: „jeśli moglibyśmy zareagować na niepowodzenie w inny sposób niż zwrócenie błędu dla całego żądania — czy sprawia to, że mamy do czynienia z czymś wyjątkowym?” Powyższy kod można ponownie napisać za pomocą instrukcji if
:
StatusOr<SomeResult*> result = doSomething ();
if (result.ok())
{
// react to failure of doSomething
}
StatusOr<SomeElseResult*> result = doSomethingElse ();
if (result.ok())
{
// react to failure of doSomethingElse
}
StatusOr<SomeElseAgainResult*> result = doSomethingElseAgain ();
if (result.ok())
{
// react to failure of doSomethingElseAgain
}
Pierwszy przykład wykorzystuje wyjątki dla przepływu sterowania, a taka praktyka jest problematyczna:
- Nieprzewidywalna ścieżka wykonania. Używając wysokopoziomowego handlera do obsługi błędów, faktycznie występuje przeskok z jednego miejsca kodu w inne, ale ciężko tu powiedzieć, czy jest to nieprzewidywalne. Jeżeli używamy wyjątków do przepływu sterowania, to wykonanie faktycznie będzie nieprzewidywalne.
- API jest mniej przejrzyste. Jeśli metoda zwraca
StatusOr<T>
to wiesz, że może się niepowieść. W przypadku wyjątków trzeba polegać na dokumentacji lub to po prostu wiedzieć. - Jeżeli dopuszczasz sterowanie za pomocą wyjątków, to musisz podjąć decyzję, którego sposobu sterowania przepływem w ogóle użyć:
if
czythrow
, ta decyzja jest jednak zbędna, bo żadne z nich nie rozwiąże problemu. - Nie tylko Ty musisz tę decyzję podjąć, ale też wszyscy, którzy pracują nad kodem. Wymagany jest dodatkowy wkład, aby utrzymać spójność w projekcie.
Aby podjąć taką decyzję, można wymyślić „test”. Na przykład, „jeśli coś jest błędem, to kod powinno się napisać z wyjątkami.” Zadajmy sobie jednak pytanie, czy spodziewamy się wystąpienia określonego rodzaju błędów. Jeżeli błąd jest spodziewany jako rezultat wykonania metod, to jest to poprawna zwracana wartość. Jeżeli jednak pojawi się błąd, który wynika z niespodziewanej sytuacji, to powinien on zostać zakodowany jako wyjątek.
Ten wniosek nie działa na korzyść wyjątków. Nie powinny być one używane do przepływu sterowania, a inżynier musi znać tę zasadę (lub trzeba ją narzucić przy code review) i zdecydować, czy coś jest częścią sterowania, czy sytuacją wyjątkową.
Jeśli już jesteśmy przy decyzjach — spójrzmy na ostatni punkt artykułu.
Złożoność
Wyjątki mogą nas też obciążyć poznawczo. Oto lista pytań, na które trzeba odpowiedzieć przy zajmowaniu się błędem:
To nie jest tak, że musimy myśleć o wielu rzeczach, kiedy używamy wyjątków, ale jest tak zawsze, kiedy zajmujemy się błędami. Wyjątki jednak dolewają oliwy do ognia i trzeba zadać jeszcze jedno pytanie (dwa, jeśli programujesz w Javie), które przedyskutowaliśmy: wyjątków nie można używać przy przepływie sterowania (łatwiej powiedzieć niż zrobić).
Czy są jakieś inne korzyści?
Jest jeszcze jedna rzecz, która sprawia, że wyjątki są OK. Nie jest to coś technicznego, ale jest ważne. Mianowicie, chodzi o fakt, że wyjątki są powszechnie używane przy kodowaniu. Dla języków takich jak Java, C++ i Python całkowity ich zakaz to przesada. Dlaczego powinniśmy polegać na powszechnych wzorcach? Nie chodzi o to, że zwracanie kodu statusu jest trudne. Chodzi raczej o to, że ciężko jest wtedy zintegrować się z zewnętrznym kodem, który używa wyjątków. Nie jest to jednak jednoznaczna kwestia: jeśli jakaś biblioteka korzysta z wyjątków, nie oznacza to, że my też musimy.
Jaki jest zatem werdykt?
Wyjątki nie komplikują jednak sprawy. Sama obsługa błędów jest już mocno skomplikowana, ale zgadzam się, że trzeba stworzyć czytelne wytyczne, które odciągają od używania wyjątków przy przepływie sterowania. Używanie wyjątków to po prostu kolejna decyzja, która musi zostać podjęta i egzekwowana w spójny sposób w projekcie. Niestety dobre używanie wyjątków wcale nie jest łatwe i nietrudno tu o błędy.
Jeśli jesteśmy jednak wystarczająco dobrzy w niestosowaniu wyjątków zamiast ifów, to zamiast pogmatwanego i nieprzewidywalnego przepływu wykonania, otrzymamy przyjemny i czysty sposób na bezpieczne zatrzymanie wykonania i zgłoszenie czegoś znaczącego zarówno użytkownikowi, jak i inżynierowi. Będzie to działać podobnie jak przedwczesne zakończenie funkcji za pomocą zwrócenia wartości, ale dla całego żądania, a nie pojedynczej funkcji.
Wychodzi więc na to, że przy dobrym przemyśleniu tematu i pozwoleniu na pewne przeskoki w wykonaniu możemy uzyskać czystszy i bardziej bezpieczny kod, jeżeli użyjemy wyjątków.
Oryginał tesktu w języku angielskim przeczytasz tutaj.