W każdej aplikacji zdarzają się sytuacje wyjątkowe, które nie mieszczą się w ‘standardowym’ działaniu aplikacji. Na takie okazje pozostaje nam obsługa za pomocą wyjątków, dzięki którym możemy przywrócić aplikację do ‘normalnej’ postaci.
Czym są wyjątki w Javie
Wyjątki w Javie to specjalne obiekty, które poza standardowymi operacjami na obiektach możemy także rzucać za pomocą słowa kluczowego throws, co powoduje natychmiastowe przerwanie działania wątku (w najprostszym przypadku — aplikacji) oraz przejście do pierwszego napotkanego miejsca, które ten wyjątek jest w stanie obsłużyć. Nieobsłużony wyjątek uśmierca bieżący wątek.
Aby wyjątek mógł być wyjątkiem, musi dziedziczyć po klasie Exception (nie jest to do końca prawda, dokładniejszy opis poniżej w sekcji o hierarchii wyjątków). Więcej przykładów jak ich używać, jakie są dostępne w samej Javie oraz jak je obsługiwać znajdziesz poniżej.
Hierarchia wyjątków
W języku Polskim to, co reprezentuje powyższa grafika nazywamy hierarchią wyjątków, choć wyjątki to tylko jej część. Niemniej taka nazwa się przyjęła i funkcjonuje, więc jeśli kiedykolwiek zostaniesz zapytana na rozmowie o hierarchię wyjątków — to właśnie powyższy diagram powinien pojawić Ci się w głowie :)
Przedstawia on podstawowe klasy, które dziedziczą po klasie Throwable. Klasa ta sama w sobie nie ma dużego zastosowania, ale jest ona znacznikiem — wszystkie klasy, które po niej dziedziczą można ‘rzucać’ używając konstrukcji throw obiekt;
Bezpośrednio po niej dziedziczą dwie klasy — Exception oraz Error. Różnica pomiędzy nimi jest logiczna — klasy dziedziczące po Error z założenia oznaczają błąd, po którym aplikacja może nie działać stabilnie lub nie działać wcale, są one rzucane przez maszynę wirtualną i aplikacja nie powinna samodzielnie ich rzucać. Występują np. kiedy aplikacji zabraknie pamięci lub dojdzie do przepełnienia stosu — w teorii w tej sytuacji aplikacja powinna zostać zamknięta, a błąd ten służy jedynie wyświetleniu komunikatu. W praktyce w niektórych sytuacjach można obsługiwać obiekty dziedziczące po Error (kilka przykładów znajdziesz np. na stackoverflow), choć jest to praktyka ogólnie niezalecana. Klasy dziedziczące po Exception sygnalizują problemy, z którymi aplikacja może i powinna sobie poradzić, przykładowo problem z odczytem pliku, brak połączenia z serwerem za pośrednictwem sieci itp. Jeśli Twoja aplikacja generuje określony rodzaj problemu w wyjątkowych sytuacjach, możesz samodzielnie zaimplementować obiekt, który dziedziczy po Exception (lub jednej z jej klas-dzieci) i przechowuje ważne dla Ciebie informacje związane z problemem. Taki wyjątek możesz potem obsługiwać tak jak każdy inny.
Checked vs unchecked exceptions
W hierarchii wyjątków mamy wyróżnioną jeszcze jedną klasę — RuntimeException. Różnica pomiędzy Exception a RuntimeException polega na tym, że te pierwsze musimy obsłużyć — tzn. jeśli metoda może rzucić wyjątek tego typu, musi to zadeklarować (poprzez dodanie throws XYZ do sygnatury), a metoda ją wywołująca musi taki wyjątek obsłużyć lub ‘świadomie’ przekazać dalej (wtedy kolejna metoda musi obsłużyć lub przekazać wyjątek dalej itd). Wynika to z faktu, że wyjątki te są naturalną i ważną częścią danego mechanizmu (np. wyjątek wejścia/wyjścia — IOException, który może zostać rzucony przy obsłudze plików). Wyjątków dziedziczących po RuntimeException z kolei nie trzeba deklarować i obsługiwać. Ta grupa wyjątków to wyjątki powstające głównie ‘z niedopatrzenia’ — np. NullPointerException oznacza że próbujemy wywołać metodę na zmiennej, która jest null’em; w gotowej aplikacji powinniśmy mieć weryfikację w odpowiednich miejscach, a wymaganie ich obsłużenia (mogą powstać właściwie w każdej linii kodu) byłoby bardzo niewygodne i zaburzające czytelność kodu. Oczywiście wyjątki takie nadal możemy łapać i przetwarzać tak jak wszystkie inne, jeśli aplikacja tego wymaga.
Najpopularniejsze wyjątki i ich zastosowanie
Poniżej znajdziesz kilka najpopularniejszych wyjątków oraz bardzo krótki opis kiedy są używane.
NullPointerException — rzucany kiedy próbujesz wywołać metodę na zmiennej, której wartość to null
IllegalArgumentException — rzucany, kiedy przekazywany argument jest z jakiegoś powodu nieprawidłowy (walidacja wewnątrz metod)
IOException (wyjątki po nim dziedziczące) — rzucany w przypadku problemów z systemem wejścia/wyjścia, czyli najogólniej rzecz ujmując, kiedy wystąpi problem przy pracy z plikami lub z transmisją danych za pośrednictwem internetu
NumberFormatException — rzucany, kiedy próbujemy zamienić na liczbę np. obiekt typu String, który zawiera nie tylko cyfry
IndexOutOfBoundException — rzucany, kiedy próbujemy się odwołać do nieistniejącego elementu tablicy lub listy
To tylko kilka z najczęściej spotykanych wyjątków. Bardziej wyczerpujące opracowanie znajdziesz np. na stronie rymden.nu .
Korzystanie z wyjątków
Słowem wstępu — wyjątki są kosztowne dla komputera! W praktyce zatrzymują cały bieg programu, po czym ‘cofają’ się na stosie szukając kodu pozwalającego je obsłużyć — to nie jest standardowa procedura, przez co jest bardzo nieefektywna. Także dlatego powinny być używane tylko do obsługi sytuacji naprawdę wyjątkowych i problematycznych, wymagających specjalnego podejścia.
Rzucanie wyjątków
Jeśli w Twojej aplikacji potrzebujesz zasygnalizować problem, warto wiedzieć jak należy rzucać wyjątki. Jest to bardzo proste i sprowadza się do trzech kroków:
1. Określ prawidłowy typ wyjątku
Wyjątek już jako klasa powinien opisywać rodzaj problemu. Być może będziesz chciała/potrzebowała stworzyć własny wyjątek (o tym przeczytasz poniżej), ale możesz wybrać spośród tych już istniejących. Najprawdopodobniej będzie to IllegalArgumentException lub podobny wyjątek w przypadku walidacji, ale może też być któryś z dziedziczących po IOException. Ważne, aby nie rzucać ‘ogólnych; wyjątków jak np. po prostu Exception czy RuntimeException — może to znacząco utrudnić ich sensowną obsługę w innych częściach systemu.
2. Dodaj deklaracje throws (tylko, jeśli wybrany wyjątek nie dziedziczy po RuntimeException)
Jeśli wybrany przez Ciebie wyjątek nie dziedziczy po RuntimeException (w przeciwnym razie możesz pominąć cały ten krok), to musisz go zadeklarować w sygnaturze metody. Jest to sposób żeby powiedzieć Javie (ale także innym programistom korzystającym z Twojego kodu), że w tej metodzie może powstać (lub zostać przekazany dalej) wyjątek danego typu. Robimy to dopisując do sygnatury metody (czyli przed klamrą otwierającą) słówko kluczowe throws oraz wymieniamy możliwe wyjątki po przecinku. Przykładowo rzucając wyjątek typu IOException metoda, która wyglądała:
public void doSomething() {
//...
}
będzie teraz wyglądać następująco:
public void doSomething() throws IOException {
//...
}
Ten krok nie jest konieczny dla wyjątków dziedziczących po RuntimeException, ale można aby podkreślić że konkretny wyjątek jest używany do sygnalizowania określonych problemów (i uwzględnić go w JavaDocs tej metody).
Uwaga! Deklaracja throws musi być obecna na poziomie interfejsu — to znaczy, że jeśli dana metoda ‘pochodzi’ z interfejsu, to w tym interfejsie także musisz dodać stosowną deklarację. Podobnie rzecz się ma z dziedziczeniem — jeśli metoda ta przesłania jakąś metodę rodzica (lub implementuje abstrakcyjną metodę rodzica), to sygnatura metody w rodzicu musi uwzględniać wyjątek, który chcesz rzucić! Inaczej mogłoby dojść do sytuacji, w której wyjątek taki jest nieobsłużony poprawnie.
3. Rzuć obiekt wyjątku
W kodzie metody najpierw stwórz obiekt wyjątku tak, jakby był to normalny obiekt, a następnie użyj słowa kluczowego throw, aby taki wyjątek rzucić:
IOException e = new IOException("Komunikacja intergalaktyczna z kocim centrum dowodzenia nie zadziałała");
throw e;
Tworząc nowy obiekt wyjątku zawsze musisz podać treść komunikatu o problemie — wynika to z tego, że pole to jest w samej klasie Exception, przez co konieczne jest jego ustawienie. Drugi, opcjonalny, argument jest typu Throwable i pozwala przekazać wyjątek, który był ‘powodem’ rzucanego wyjątku (jeśli taki wyjątek wypiszesz na konsolę, to do informacji o Twoim wyjątku będzie dodana linia ‘Caused by:’, po której opisany zostanie ten drugi wyjątek — ten przekazany jako argument). Oczywiście lista argumentów poszczególnych wyjątków może się różnić, ale te dwie informacje są wspólne i występują we wszystkich standardowych wyjątkach.
Tworzenie własnych wyjątków
Aby stworzyć własny wyjątek, wystarczy dziedziczyć po klasie Exception lub RuntimeException (lub jednej z klas po nich dziedziczących). Ponieważ klasy te nie mają konstruktorów bezargumentowych, będziesz musiała także wywołać ich konstruktory. Poniżej przykładowa implementacja wyjątku o przekroczeniu limitu konta (bardzo minimalistyczna):
public class LimitExceededException extends IOException {
private final String limitName;
private final Long limitValue;
public LimitExceededException(String message, String limitName, Long limitValue) {
super(message);
this.limitName = limitName;
this.limitValue = limitValue;
}
//...gettery, hashCode, equals itp...
}
Tworząc własne wyjątki zastanów się, czy nie istnieje już jakiś bardziej ‘ogólny’, który pasuje do problemu, jaki chcesz zasygnalizować. Często wyjątki dostępne w języku Java są wystarczające i nawet ogromne systemy deklarują maksymalnie po kilka specyficznych wyjątków. Tworząc nowy wyjątek pamiętaj o kilku zasadach:
- Wyjątek musi być względnie ogólny, czyli nadający się do użycia w różnych częściach systemu. Przykład dobrego ‘rozmiaru’ wyjątku to AccountLimitExceededException — wyjątek rzucany kiedy jakiś z limitów konta został przekroczony. Przykład źle dobranego rozmiaru to AccountLimitOfNumberOfRepositoriesExceeded — nie ma możliwości zastosowania go ponownie
- Poza wiadomością wyjątek może ‘nieść’ ze sobą także dodatkowe informacje wspierające; staraj się jednak, aby były to proste elementy jak ciągi znaków czy liczby i raczej unikaj złożonych obiektów (np. obiekt typu ‘Użytkownik’) — może to stanowić zagrożenie bezpieczeństwa aplikacji w przyszłości
- Sprawdź raz jeszcze, czy nie ma już takiego wyjątku w jakiejś standardowej bibliotece — Spring czy Hibernate oferują dodatkowe wyjątki, które mogą być tym, czego potrzebujesz
- Pomyśl o implementacji dodatkowych metod jak np. getLocalizedMessage() oraz wsparciu dla różnych konstruktorów — pozwoli to uniknąć problemów z wykorzystaniem wyjątku w innym miejscu aplikacji
Wyjątki tworzone przez Ciebie działają dokładnie tak samo, jak wyjątki standardowe — zasady, którymi się rządzą, a także sposób ich użycia i obsługi są identyczne :)
Obsługa wyjątków
Podstawą obsługi wyjątków w Javie jest konstrukcja try-catch-finally. Przykładowo może ona wyglądać następująco:
try {
//... jakiś kod, który może rzucić wyjątek
} catch (NullPointerException e) {
//... kod, który zostanie wykonany, jeśli wystąpi wyjątek
} finally {
//kod, który zostanie wykonany zawsze (nawet jeśli wewnątrz 'try' nastąpi zwrócenie jakiejś wartości z metody)
}
Pierwsza sekcja — try — jest obowiązkowa. W niej należy umieścić kod, który chcemy ‘zabezpieczyć’ na wypadek wystąpienia wyjątku. Może to być jedna linijka lub wiele, ale warto starać się ograniczyć ilość linii wewnątrz tylko do tych najbardziej potrzebnych, inaczej kod stanie się mniej czytelny i nie będzie jasne, która jego część jest ‘niebezpieczna’.
Kolejne sekcje — catch oraz finally mogą wystąpić razem, ale wymagana jest tylko jedna z nich (dowolna). Możemy mieć więc tylko sekcje finally, tylko sekcje catch, a także sekcję catch i finally razem (wtedy wymagane jest, żeby sekcja catch była przed sekcją finally). Co więcej — możemy mieć kilka sekcji catch, aby inaczej obsługiwać różne wyjątki (ale o tym za chwilę).
Podczas działania aplikacji kod z sekcji try jest wykonywany normalnie, do czasu wystąpienia wyjątku. Jeśli wyjątek nie zostanie rzucony podczas jej wykonywania, sekcja catch jest ‘pomijana’. Jeśli jednak pojawi się wyjątek obsługiwany przez jedną z sekcji catch, aktualnie wykonywany kod z sekcji ‘try’ zostanie przerwany i rozpocznie się wykonywanie kodu w odpowiedniej sekcji ‘catch’. Po jej zakończeniu maszyna wirtualna przejdzie od razu do sekcji ‘finally’ (jeśli istnieje), a następnie będzie wykonywała kod, który znajduje się za całym blokiem try-catch.
Bardzo istotna jest także sekcja finally — jeśli jest obecna, wykona się ona zawsze, niezależnie od tego czy w sekcji try pojawi się wyjątek czy nie. Co więcej, wykona się ona nawet wtedy, kiedy w sekcji try lub catch zwrócimy wartość z metody! Jedyną sytuacją, kiedy ta część konstrukcji się nie wykona jest zamknięcie maszyny wirtualnej (np. poprzez wywołanie System.exit(0) ).
Wiele sekcji catch w jednym bloku (obsługa różnych wyjątków w różny sposób)
Jak wspominaliśmy powyżej, sekcji catch może być więcej — mogą one obsługiwać różne wyjątki na różne sposoby. Przykładowo konstrukcja:
try {
//... kod, który może generować wyjątki ...
} catch (IllegalArgumentException e) {
System.out.println("Pierwsza sekcja catch");
} catch (IOException | IllegalArgumentException e) {
System.out.println("Druga sekcja catch dla 2 wyjątków");
} catch (RuntimeException e) {
System.out.println("Trzecia sekcja catch");
}
w zależności od rodzaju wyjątku wykona inny kod. Zwróć uwagę także na drugą sekcję catch — ‘łapie’ ona kilka różnych wyjątków jednocześnie, dzięki czemu możemy uniknąć duplikacji kodu. Ważne jest, że w przypadku wystąpienia wyjątku sekcje te są sprawdzane od góry do dołu, w kolejności w jakiej są umieszczone w kodzie, i wykonana zostanie wyłącznie pierwsza z nich, która odpowiada danemu wyjątkowi! Nie można zatem jako pierwszej umieścić sekcji obsługującej wyjątek bardziej ‘ogólny’ (np. RuntimeException) niż późniejszy wyjątek (np. NullPointerException).
Antywzorce — czego z wyjątkami NIE robić
Wyjątki to mechanizm do sytuacji wyjątkowych, przez co bardzo łatwo go nadużyć — poniżej znajdziesz kilka rzeczy, których należy unikać, aby aplikacja była wydajna i możliwa do rozwoju w przyszłości:
- Logika biznesowa poprzez wyjątki — wyjątki nie służą do ‘zwracania’ wartości z metody i przekazywania informacji pomiędzy warstwami! Ich zastosowanie to sytuacje wyjątkowe i problemy, a nie oczekiwane (i często występujące) zagadnienia jak np. walidacja wejścia od użytkownika (czym innym jest walidacja danych w jakiejś wewnętrznej metodzie — aplikacja może zakładać, że w tym miejscu dane są już zweryfikowane na wcześniejszym etapie, a więc wyjątek jest uzasadniony) czy pusta odpowiedź (np. w odpowiedzi na RESTowe zapytanie o nieistniejący obiekt)
- Zbyt specyficzne wyjątki — używanie wyjątków, które mają zastosowanie tylko w jednym miejscu prowadzi do sytuacji, w której aplikacja ma więcej wyjątków niż jakichkolwiek innych klas
- Zbyt ogólne wyjątki — rzucanie po prostu ‘Exception’ czy ‘RuntimeException’ utrudnia prawidłową reakcję (wewnątrz aplikacji) na powstały problem i może uniemożliwić automatyczne zaradzenie mu.
- Ignorowanie wyjątków — czego przejawem jest np. pusty blok catch lub sam log — czasem faktycznie nie można zrobić nic więcej niż zalogować, ale w większości sytuacji aplikacja powinna podjąć jakieś działanie, kiedy napotka na wyjątek
- Przepuszczanie wyjątków ‘dalej’ — np. kiedy każda metoda od kontrolera przez serwis po DAO rzuca ten sam wyjątek, w efekcie nie obsługując go nigdzie, powoduje to, że wyjątki tracą swoją rolę (umożliwienia aplikacji powrót do stabilnego działania po napotkaniu problemu)
Większość z tych zasad jest logiczna i jasna, ale pod presją czasu czasem mamy ochotę iść ‘na skróty’ — pamiętaj, że zemści się to na Tobie kilkukrotnie już chwilę później!
Podsumowanie
Wyjątki i prawidłowe obchodzenie się z nimi to jeden z ważniejszych aspektów języka. Warto się nad nim pochylić nieco bardziej nie tylko z uwagi na przygotowanie do rekrutacji, ale także aby kolejna aplikacja, którą będziesz tworzyć, była jeszcze lepsza i sprawniejsza!
Więcej o wyjątkach w Javie możesz przeczytać w tutorialu na stronie Oracle.com poświęconym temu zagadnieniu.