Co to jest i jak czytać StackTrace?

Wiesz już, czym są wyjątki w Java, wiesz też jak je rzucać… ale czy wiesz jak je interpretować? W szczególności jak czytać StackTrace, który zawsze towarzyszy wyjątkowi?

Wśród programistów krąży powiedzenie, że kod jest czytany więcej razy niż pisany (dlatego musi być napisany w prosty i zrozumiały sposób). Podobnie jest z wyjątkami, częściej będziesz je czytał niż, rzucał w swojej aplikacji. Dlatego też warto nauczyć się je czytać zawczasu i nie domyślać się “co tak naprawdę to znaczy”.

Zanim zapoznasz się z samym StackTrace’em warto, żebyś wiedział, że stuktura danych, na której on bazuje, zwana jest stosem (ang. stack). Opisałem ją dokładnie w tym wpisie.

Można powiedzieć, że StackTrace to “ścieżka”, którą podążało wykonanie programu do momentu powstania błędu. Więc w StackTrace znajduje się lista wszystkich metod, które są „aktualnie” wykonywane i w takiej kolejności doprowadziły do wyjątku.

Mówiąc jeszcze inaczej, patrząc na StackTrace, będziesz widział listę metod wywołanych na poszczególnych poziomach aplikacji. Wywołania te doprowadziły do wystąpienia tego wyjątku. Co ważne, nie zobaczysz tutaj wartości zmiennych, oraz wartości atrybutów (pól) klasy.

Samo pojęcie StackTrace związane jest ze stosem wykonania, jednak w Java potocznie rozumie się przez to sam stos wykonania wraz z wyjątkiem (lub wyjątkami), w jaki jest on “opakowany”.

Javowy StackTrace, zaczyna się od nazwy wyjątku, który go spowodował. W tej samej linii znajduje się też dodatkowa informacja dołączona przez programistę. “Zazwyczaj”, gdyż sporo osób nie dołącza do wyjątku żadnej “wiadomości”. Przyśpiesza to i ułatwia programowanie, bo nie trzeba się zastanawiać co tam napisać. Jednak jest to jedyny sposób, w jaki możemy sobie sami pomóc, kiedy to przyjdzie nam analizować ten wyjątek. W tej wiadomości możemy dodać informacje o wartości lokalnych zmiennych, które wpłynęły na wystąpienie właśnie tego wyjątku. Dlatego zawsze dodawaj “wiadomość” do każdego wyjątku, jaki rzucasz.

Każda linia zaczynająca się od “at” podaje:

  • w pełni kwalifikowaną nazwę metody (czyli nazwę pakietu, klasy oraz metody),
  • nazwę pliku źródłowego (w nawiasach okrągłych) oraz,
  • po dwukropku, numer linii.

Taki zapis pozwala jednoznacznie określić miejsce w aplikacji (lub bibliotece zewnętrznej), które było aktualnie wykonywane.

Na samym dole znajduje się tzw. metoda wejściowa, w prostych aplikacjach konsolowych, będzie to zawsze metoda main(String []). Natomiast na samym końcu znajduje się metoda, w której to wystąpił wyjątek.

Zazwyczaj nazwa pakietu Twojej aplikacji będzie znajdowała się na samej górze… gdyż zazwyczaj błędy popełniamy my, a nie autorzy bibliotek, których używamy. Czasami jednak nasza nazwa pakietu znajduje się gdzieś w “środku” StackTrace, wtedy zazwyczaj oznacza to, że przekazaliśmy złe parametry do wywołania metody z danej biblioteki. Baaaardzo rzadko oznacza to błąd w bibliotece.

Przyjrzyjmy się przykładowemu wyjątkowi:

Widzimy tutaj NullPointerException w 16 linii klasy Book z pakietu pl.kursprogramisty w metodzie getTitle(). Metoda ta została wywołana przez Author.getBookTitles(), która znajduje się w pliku Author.java w linii 25. Z kolei metoda getBookTitles() wołana jest z Bootstrap.main(), z pliku Bootstrap.java w linii 14.

Jak to bywa zazwyczaj z wyjątkami typu NullPoinerException, pewnie jedno z pól (właściwości) klasy Book bądź nie zostało zainicjalizowane, bądź zostało zainicjalizowane wartością null (czyli, tak jak by w ogóle nie zostało zainicjalizowane). Takie zachowanie skutkuje właśnie takim wyjątkiem w momencie próby wykonania jakiejkolwiek metody, na której zmiennej wartość jest null.

Często widzi się w kodzie, że w bloku catch, złapany wyjątek opakowywany jest w inny typ, który jest następnie rzucany. Poniżej znajduje się przykład takiego kodu:

W takim przypadku należy rozumieć, że wyjątek MyProjectServletException został spowodowany przez (ang. caused by) ConstraintViolationException. Wtedy w StackTrace zobaczymy linię zaczynającą się od “Caused by: ConstraintViolationException”. Co może skutkować takim przykładowym StackTrace:

Przełóżmy to na trochę prostszy przykład. Załóżmy, że piszesz prosty kalkulator:

W powyższym przykładzie CalculationException spowodowany został przez AdditionException, gdyż próbujemy dodać dwie liczby, z których jedna jest spoza karesu przyjętego przez nas za obsługiwany.

To tyle, jeżeli chodzi o czytanie wyjątków ze zrozumieniem. Jeżeli coś jest jeszcze nie jasne, zapraszam do komentarzy.

Wyjątki w Java: NullPointerException

NullPointerExcepion to chyba najczęściej spotykany wyjątek w Java, co więcej wynika on zazwyczaj z błędu programisty. Oznacza on przeważnie wywołanie metody na obiekcie, który ma wartość null.

Często błąd ten występuje, kiedy przez pomyłkę jeden z parametrów wykonywanej metody ma wartość null. OK, ale co to właściwie znaczy? Przyjrzyjmy się prostemu przykładowi:

Żeby nadmiernie nie komplikować kodu, opakowujemy standardową metodę concat z klasy String w naszą prywatną metodę concatenate, która przyjmuje dwa parametry typu String.

Jeżeli uruchomimy, aplikację dostaniemy następujący rezultat:

Oto jest i on, słynny NullPointerException! Zacznijmy od ostatniej linii tj.: at pl.naukajava.NullPointerExceptionExample.main(NullPointerExceptionExample.java:7), która to wskazuje, że błąd występuje w 7 linii naszej aplikacji tj.:

Kolejna linia w stack trace wskazuje nam na naszą metodę concatenate(String, String). Natomiast tutaj: at java.lang.String.concat(String.java:2027) mamy odwołanie do standardowej metody concat z klasy String.

Co z tego wszystkiego wynika? Otóż metoda concat nie działa jeżeli przekażemy jej wartość null jako parametr. Jak to poprawić? Jest wiele możliwych rozwiązań:

  1. zamienić wartość null na pusty String
  2. zamienić wartość null na String „null”
  3. w metodzie concatenate dodać warunek if (b == null) i wtedy podmienić tą wartość na pusty String lub String „null”
  4. w metodzie concatenate dodać warunek if (b == null) i rzucić wyjątek IllegalArgumentException

Oczywiście dobre rozwiązanie będzie się różnić od tego, jaki efekt chcemy osiągnąć. Ja, osobiście składniam się do 3 propozycji i zamiany wartości null na String „null”.

Nowa wersja metody concatenate będzie wyglądała następująco:

Jeżeli teraz uruchomimy ponownie tą aplikację, to znowu otrzymamy NullPointerException, tym razem w linii 8… no i tym razem sami jesteśmy sobie winni. Gdyż nie sprawdzamy wartości zmiennej a tylko bezpośrednio wywołujemy na niej metodę concat, co w rezultacie daje nam NPE (akronim od NullPointerException).

Ponownie wypadało by się zabezpieczyć przed tym błędem. Najlepiej w sposób konsystentny z tym w jaki poprawiliśmy pierwszy błąd tj. zamienić null na String „null”. Mamy już kod który wykonuje dokładnie taką operację dla zmiennej b, więc albo możemy go skopiować (źle, źle, źle, źle..,), albo wyciągnąć go do nowej metody np. nullToString.

Musimy teraz zmienić metodę concatenate, żeby korzystała z nowo utworzonej metody nullToString:

Teraz nasza mała aplikacja demonstracyjna będzie działa tak bez problemów.

W ten oto sposób pozbywamy się z aplikacji błędów typu NullPointerException.

Multi catch – czyli łapanie wielu wyjątków jednocześnie

Wiesz już czym są wyjątki w Java, jak je rzucać oraz łapać, znasz składnie try-with-resources. Z tą wiedzą można stworzyć już sporą aplikację w Java. Jednak czasem kod tej aplikacji może wyglądać następująco:

Mimo, że pokazany wyżej kod działa poprawnie oraz spełnia założenia aplikacji (przynajmniej tak teraz zakładamy teraz). To nie jest to kod najlepszej jakości. Przyjrzyjmy się jak można go przepisać oraz przy okazji wykorzystać tzw. multi catch z Java 7.

Zacznijmy najpierw od tego jak takie problemy rozwiązywało się przed Java 7. Jakie problemy? możesz zapytać. Chodzi o niepotrzebną duplikację kodu, wszystkie bloki catch robią to samo (wypisują identyczny komunikat). Lepszym rozwiązaniem jest stworzenie prywatnej metody którą będziemy wołać z każdego bloku catch. W takim przypadku jeżeli przyjdzie nam zmienić komunikat, będziemy musieli zrobić to tylko w jednym miejscu, a nie jak to jest teraz w pięciu. Oto powyższy kod po modyfikacjach:

Innym możliwym rozwiązaniem jest po prostu złapanie wszystkich wyjątków typu Exception

tj.:

Chociaż wygląda to jak najlepsze rozwiązanie z punktu widzenia ilości kodu, to wcale takie nie jest. Głównym powodem jest to, że możemy przez nieuwagę złapać wyjątek który powinien zostać obsłużony w inny sposób. Powód ten bazuje na założeniu, że aplikacja jest ciągle rozwijana. Przez to właśnie mogą dojść nowe wyjątki wymagające innej obsługi. W takiej sytuacji poprzednie rozwiązanie (z kilkoma blokami catch) spowoduje błąd kompilacji (wynikający z nie obsłużenia zdeklarowanego wyjątku), a tym samy wymusi zastanowienie się nad tym co z tym wyjątkiem w tym miejscu należy zrobić.

W Java 7 oprócz try-with-resources pojawił się również multi catch. Pozwala on złapać kilka różnych, niezwiązanych ze sobą wyjątków w jednym bloku catch. W tym celu należy wymienić typy łapanych wyjątków po znaku „|” (ang. pipe). Z wykorzystaniem tej konstrukcji przykładowy kod będzie wyglądał następująco:

Nie musimy tworzyć prywatnej metody, nie trzeba też łapać wyjątków klasy Exception, wystarczy tylko wymienić te które chcemy obsłużyć w tym pojedynczym bloku catch. Dzięki temu rozwiązaniu również w sytuacji kiedy dojdzie nowy wyjątek w bloku try, kompilator poinformuje nas o konieczności jego obsłużenia.

Obsługa wyjątków w Java

Skoro już wiesz czym są wyjątki w Java, najwyższy czas nauczyć się je obsługiwać (przez pojęcie „obsługi wyjątku” rozumiemy w Java reakcję na zaistnienie sytuacji wyjątkowej/nie spodziewanej).

Java wymusza na nas programistach obsługę wyjątków! Jeżeli metoda deklaruje, że rzuca jakiś wyjątek (używając słowa kluczowego: throws) musimy ten wyjątek obsłużyć. Jeżeli tego nie zrobimy nasz kod się nie skompiluje.

Do obsługi wyjątków w Java używana jest konstrukcja trycatch. Składa się ona z bloku try („spróbuj”) oraz bloków catch („złap”) dla dowolnej ilości wyjątków które chcemy obsłużyć. Blok trycatch może wyglądać następująco:

Omówmy najpierw pierwszy blok kodu tj. try { … }. W tym miejscu znajduje się nasz „główny” kod aplikacji który może rzucić jeden lub więcej wyjątków. Dla przykładu załóżmy, że chcemy zapisać fragment tekstu do pliku. W tym celu musimy utworzyć obiekt pliku, następnie utworzyć obiekt typu java.io.PrintWriter, który umożliwi nam zapis do pliku. Później zapisać tekst do pliku oraz na końcu zwolnić zasoby zamykając obiekt PrintWriter. Przykładowy fragment kodu znajduje się poniżej:

Jeżeli spróbujemy skompilować powyższy kod kompilator poinformuje nas o tym, że użyty konstruktor obiektu PrintWriter rzuca wyjątek typu java.io.FileNotFoundException wymiuszając w ten sposób na nas użycie bloku try { … } catch w tym miejscu.

W sekcji try { … }, musimy umieścić wszystkie odwołania do zmiennej writer gdyż blok try { … } wyznacza „blok kodu” (więcej informacji o blokach kodu). Dlatego finalna wersja naszej mikro aplikacji będzie wyglądała następująco:

W sekcji catch (linie 7 do 9) łapiemy wyjątek FileNotFoundException i w przypadku jego wystąpienia wypiszemy na konsoli komunikat: Wskazany plik nie istnieje.

Program napisany w ten sposób, zawsze będzie wypisywał komunikat z sekcji catch { … }, gdyż w nazwie pliku podajemy niepoprawną ścieżkę. Usunięcie znaku „/” w pierzej linii listingu automatycznie spowoduje, że aplikacja zacznie działać poprawnie tj. utworzy wskazany plik i zapisze w nim „Hello world”.

Warto również zauważyć, że program ten może zostać napisany znacznie lepiej korzystając z instrukcji try-with-resources dostępniej od Java7. W takim przypadku nie musimy sami „ręcznie” zamykać obiektu PrintWriter, zrobi to za nas JVM. Poniżej znajduje się optymalny kod aplikacji:

Wyjątki w Java – wstęp

W programowaniu, jak w życiu codziennym zdarzają się sytuacje wyjątkowe, ot chociażby takie jak brak wody pod prysznicem kiedy to właśnie zacząłeś myć twarz… lub brak miejsca na dysku gdy program właśnie chciał na nim zapisać wynik kilkugodzinnych obliczeń.

Z jednej strony jako programiści jesteśmy niepoprawnymi optymistami, zakładamy że nigdy się nam nie skończy miejsce na dysku, że nie zabraknie nam pamięci RAM oraz, że zawsze będziemy mieli dostęp do internetu.

Z drugiej jednak strony sytuacje wyjątkowe zdarzają się… i to zdarzają się zawsze! Więc musimy być na nie przygotowani.

Jeżeli przed każdym zapisem danych na dysk mielibyśmy sprawdzać czy jest na nim odpowiednia ilość wolnego miejsca lub przed każdą alokacją zmiennej mielibyśmy sprawdzać czy jest na nią wystarczająca ilość pamięci. Kod stał by się bardzo nie czytelny, więcej było w nim „zapytań” o aktualny stan „świata zewnętrznego” niż właściwego kodu. Co więcej zamiast rozwiązywać nasze zadanie, zajmowalibyśmy się ciągłym sprawdzaniem czy możemy daną operację wykonać. Dodatkowo, ułamek sekundy po sprawdzeniu danego stanu (czy to wolnego miejsca na dysku czy wolnej pamięci) może okazać się, że nie jest on już aktualny gdyż inny program zajął „nasze” miejsce w pamięci bądź na dysku.

W Java sytuacje które zazwyczaj się nie zdarzają, bądź ich możliwość wystąpienia jest niewielka, są reprezentowane za pomocą wyjątków (ang. exception). Przykładami takich sytuacji są:

  • brak wolnej pamięci.
  • brak wolnego miejsca na dysku,
  • zły format danych,
  • brak referencji do obiektu,
  • a nawet brak połączenia z internetem.

Zacznijmy od teorii związanej z wyjątkami w Java.

„Operacje” na wyjątkach

W Java wyjątki się:

  • deklaruje możliwość wystąpienia (ang. throws) – konstruktory oraz metody w Java mogą „powiedzieć” (zdeklarować), że mogą rzucić dany typ wyjątku, osoba używająca takiej metody lub konstruktora jest zmuszona do obsługi tego wyjątku,
  • rzuca (ang. throw) – kiedy zachodzi sytuacja wyjątkowa, po prostu „rzucamy wyjątkiem w ciemno”, w ten sposób przenosimy odpowiedzialność za zachowanie się aplikacji w sytuacji wyjątkowej na osobę która będzie korzystała z naszego kodu,
  • łapie (ang. catch) – „żadna” sytuacja wyjątkowa w Java nie może zostać pominięta, jesteśmy wręcz zmuszeni to „łapania” zdeklarowanych wyjątków oraz ich obsłużenia.

Jeżeli metoda lub konstruktor deklaruje wyjątek jesteś jako programista zmuszony go obsłużyć, inaczej kompilator nie zaakceptuje twojego kodu (czyt. będziesz miał błędy kompilacji). Zanim przejdziemy do tego jak się deklaruje oraz obsługuje wyjątki w Java warto poznać ich strukturę oraz typy.

Struktura wyjątków w Java

Klasą bazową dla wszystkich rodzajów „sytuacji wyjątkowych” w Java jest klasa Throwable („rzutliwy”, nie wiem jak to lepiej przetłumaczyć). Klasa ta nigdy nie powinna znaleść się w Twoim kodzie, nie powinieneś z niej dziedziczyć,  jej”rzucać” ani „łapać”. Z racji tego, że Java jest językiem obiektowym jest potrzebna jedna klasa z której różne rodzaje sytuacji wyjątkowych będą dziedziczyć.

Z klasy Throwable dziedziczą dwie inne klasy:

  • Error – „amba fatima”, wyjątki tego typu oznaczają nieodwracalny błąd, zazwyczaj związany z błędem maszyny wirtualnej (JVM) lub systemu operacyjnego. Dwoma najczęściej spotykanymi błędami są OutOfMemoryError oraz StackOverflowError,
  • Exception – błąd z którego (teoretycznie) da się wyjść. Zazwyczaj są to błędy programistyczne (NullPointerException), wejścia/wyjścia (IOException) oraz inne (w tym RuntimeExcpetion).

Tak samo jak w przypadku klasy Throwable, z klasy Error również nie dziedziczymy. Można za to łapać zarówno sam Error jak i klasy z niej dziedziczące w celu poprawnej obsługi zdarzeń krytycznych JVM. W tym wypadku będzie to „ratowanie się” w sytuacji kiedy to niebo wali nam się na głowę. Takie ratownie może polegać na zamknięciu otwartych połączeń sieciowych (np. baz danych) czy też zapisaniu (częściowych) wyników obliczeń itp.

To co nas jako programistów najbardziej interesuje to wszystkie klasy dziedziczące z Exception. Dlaczego? Gdyż głównie z takimi wyjątkami będziemy mieli doczynienia. Właśnie tego typu wyjątki są rzucane przez konstruktory i metody. Z klasy Exception (oraz klas pochodnych) możemy śmiało dziedziczyć żeby tworzyć swoje własne typy wyjątków.

Tyle kwestią w stępu teoretycznego do wyjątków w Java. W następnym wpise omówimy instrukcję throws, throw, try, catch i finally; czyli deklaracje, rzucenie wyjątkiem oraz jego złapanie.