Wyjątki w Javie

Każda aplikacja zawiera błędy. Tak samo w każdej aplikacji pewne ścieżki są nieprzewidziane. Zadaniem zespołu tworzącego aplikację jest znaleźć te miejsca w kodzie i odpowiednio je obsłużyć. W ten sposób można płynnie dojść do javowego mechanizmu wyjątków, który pomaga programistom w obsłużeniu błędnych ścieżek w algorytmach.

Wyjątki to specyficznego rodzaju obiekty, które prócz standardowego zachowania obiektowego mogą zostać wyrzucone, od angielskiego słowa kluczowego throw. Wyrzucenie wyjątka powoduje przerwanie działającego wątku, który dane wyrzucenie spowodował. Przerywane są kolejne wywołania “w górę” stosu, aż do miejsca, które zostało odpowiednio oprogramowane do obsługi wyjątku. Takie działanie umożliwia dziedziczenie po jednej z dwóch klas: Exception (tzw. wyjątki oznaczone, checked exceptions) lub RuntimeException (wyjątki nieoznaczone, unchecked exceptions).

Wyjątkowa hierarchia

W powyższym akapicie było wspomniane, że wyjątki mogą zostać rzucone. Takie działanie może zostać wykonane z każdą klasą, która implementuje interfejs Throwable. Bezpośrednio ten interfejs implementują Error oraz Exception.

Error oznacza błąd, jest rzucana bezpośrednio przez maszynę wirtualną i w oznacza poważny błąd, powodujący natychmiastowe zamknięcie aplikacji. Samo rzucenie Errora jest już tylko informacją dla programisty co się stało, że aplikacja została zamknięta. Istnieje możliwość złapania i obsługi errorów, jednakże jest to zła praktyka i nie powinna być stosowana (chyba, że doskonale zdajesz sobie sprawę z tego, co się właśnie wydarzyło i wiesz co robisz).

Exception to z kolei wyjątek – zachowanie niestandardowe, z którym aplikacja powinna sobie poradzić. Innymi słowy – aplikacja powinna obsłużyć wyjątek. Takim zachowaniem może być na przykład utrata połączenia z serwerem, nieprawidłowy odczyt/zapis pliku, czy nieprawidłowe rzutowanie klas. Exceptiony dzielą się na te dziedziczące tylko po Exception oraz na te, które dziedziczą po RuntimeException – klasie potomnej do Exception. Daje to kilka dodatkowych możliwości, o których później.

Wyjątkowa rzucanka

Wszystko, co można rzucić, można także złapać. Podobnie jest z wyjątkami. Te rzucone – powinny zostać złapane. Nie jest to obligatoryjne, ale jest to dobra praktyka. Poprawna obsługa wyjątków to, oprócz złapania wyjątku, także oprogramowanie zaistniałego błędu tak, aby aplikacja nadal mogła działać poprawnie. Można to porównać do paliwa w samochodzie. Gdy paliwo się kończy – samochód przestaje jechać. Jest to alegoria rzuconego wyjątku (NotEnoughFuelException). Należy wtedy taki wyjątek obsłużyć – wlać paliwo do baku. Następnie można kontynuować podróż.

Podobnie rzecz ma się z programowaniem. Jeśli chcemy odczytać plik z dysku twardego, a dany plik nie istnieje – zostanie rzucony wyjątek FileNotFoundException. Nieobsłużony wyjątek zamknie aplikację, natomiast obsługa takiego wyjątku może oznaczać wyświetlenie użytkownikowi stosownego komunikatu i propozycję otwarcia innego pliku, zamiast tego nieistniejącego.

Checked / unchecked exception

Wspomniane było wyżej, że wyjątki dzielą się na dwie główne grupy – te dziedziczące po Exception (checked) i te, dziedziczące po RuntimeException (unchecked). Wyjątki checked to są takie które muszą być obsłużone. Możliwości zatem są dwie – przechwycenie wyjątku, albo wyrzucenie go dalej, wgłąb stosu wywołań.

Wyjątki unchecked to te, które obsłużone być nie muszą, ponieważ ich wystąpienie nie powinno naruszyć stabilności aplikacji. Tworząc własne wyjątki warto pamiętać o tym, żeby nie używać wyjątków unchecked w krytycznych błędach.

Łapanie wyjątków – try, catch, rethrow, throws, finally

Wspomniane zostało łapanie wyjątków. Jest to konstrukcja umożliwiająca przechwycenie wyrzuconego wyjątka, oraz poprawne obsłużenie go w kodzie aplikacji. Na przykład wyświetlenie stosownego komunikatu na GUI.

Łapać można zarówno jeden wyjątek, jak i kilka. W przypadku łapania kilku wyjątków możemy obsłużyć każdy z nich w unikalny sposób, lub wybrać jeden wspólny zestaw operacji. A jak się to wszytko robi?

Mając kod, który może skutkować wyrzuceniem wyjątku umieszczamy go w tak zwanym bloku try. Wewnątrz tego bloku staramy się umieścić możliwie najmniej kodu, zachowując sensowność rozwiązania. Obligatoryjnie do bloku try występuje następny blok – catch. W tym bloku zawierana jest obsługa danego wyjątku, przekazanego jako argument po słowie kluczowym. Wygląda to mniej więcej tak:

...
try {
    openFileAndReadLineByLine("file.txt");
} catch (FileNotFoundException e){
    LOG.error("Nie odnaleziono pliku", e);
} catch (IOException e) {
    LOG.error("Nie udało się otworzyć pliku", e);
} catch (UserNotAuthorizedException | UserInWrongRoleException e){
    LOG.error("Nie masz uprawnień do wykonania tej akcji", e);
}
...

W tym wypadku widzimy trzy możliwości – pierwsza, to nie znaleziony plik. W tym wypadku przechwycony zostaje FileNotFoundException, a do logów zostaje wysłany stosowny komunikat. IOException to ogólny wyjątek dotyczacy strumienia wejścia-wyjścia, a trzeci blok catch dotyczy wspólnej obsługi dwóch wyjątków – braku autoryzacji użytkownika, lub sytuacji, w której użytkownik jest w roli niepozwalającej wykonać mu odczytu pliku.

Oprócz catch – istnieje także blok finally, który umieszczany jest na końcu, i który wykonywany jest zawsze, niezależnie od tego, czy wyjątek został przechwycony, czy też nie.

Osobną kategorią jest tzw. rethrow wyjątku. Czyli wyrzucenie go dalej, mimo przechwycenia. Czasem może to być użyteczne. Wykonuje się to poprzez rzucenie wyjątku wewnątrz bloku catch – np. po obsłudze. Niestety, potrafi to być wykorzystywane przez programistów w celu ominięcia obligatoryjnej obsługi wyjątków checked – po przechwyceniu robią rethrow wyjątka, jednakże tym razem już unchecked, którym nie muszą się przejmować.

Wyjątki typu checked muszą być zawsze obsłużone, co zostało już wspomniane. Oznacza to, że jeśli nie umieścimy kodu, który może spowodować wyrzucenie wyjątku checked w bloku try – dostaniemy bład kompilacji. Istnieje jednak pewna sztuczka, mogąca opóźnić obsługę błędu. Jest to słowo kluczowe throws, które, dodane do kontraktu metody, informuje, że wywołanie tej metody może skutkować wyjątkiem typu checked. Można to wykorzystać w ten sposób, że między GUI a backendem w naszej aplikacji pracować będą filtry, których zadaniem będzie przechwycanie wyjątków nieobsłużonych i wyświetlanie stosownych komunikatów.

...

public void authorizeUser(String username, String password) throws UserNotAuthorizedException {
...
}
...

public boolean login(String username, String passwrod){

    try {
        authorizeUser(username, password);
    } catch (UserNotAuthorizedException e){
        return false;
    }

    return true;
}

public List<Properties> readUserProps(String username, String password) throws UserNotAuthorizedException {
    
    authorizeUser(username, password);
    File props = openPropertiesFile(username);

    return loadPropertiesFromFileToList(props);
}

Jak widać, metoda authorizeUser wyrzuca wyjątek UserNotAuthorizedException. W metodzie login obsługujemy ten wyjątek poprzez zwrócenie false, natomiast w metodzie readUserProps – po wystąpieniu takiego wyjątka, praca jest przerywana a wyjątek przekazywany dalej – najwidoczniej metoda, która wywołuje readUserProps obsłuży ten wyjątek (lub metoda, która jest jeszcze dalej).

Podsuma

Temat wyjątków jest bardzo ciekawy. Prawidłowo skonfigurowane potrafią ułatwić nie tylko pracę podczas pisania aplikacji, ale także debugowanie błedów, czy nawet samo czytanie logów. Najgorsze, co można zrobić to tzw. połykanie wyjątków, nad którym bardziej pochylę się we wpisie o tym jak poprawnie czytać logi.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *