PO Wyjątki

From Studia Informatyczne

<<< Powrót do przedmiotu Programowanie obiektowe

Spis treści

Obsługa sytuacji wyjątkowych

Nawet w poprawnie napisanych programach należy się liczyć z możliwością wystąpienia sytuacji wyjątkowych. Nie można przykładowo przewidzieć, że podczas wpisywania do pliku skończy się miejsce na dysku (bo zajął je inny proces), lub że podczas przesyłania danych zerwie się połączenie. Nie da się również zagwarantować, że inni będą poprawnie używali naszych bibliotek. Nie uchroni przed tym nawet wzorowa dokumentacja techniczna, gdyż nie wszyscy do niej zaglądają. Typowym przykładem takiej sytuacji jest przekazanie metodzie niepoprawnych danych, np. wartości null, gdy spodziewała się referencji do obiektu lub krótszej niż oczekiwała tablicy.

Dobrze napisany kod powinien zakładać możliwość wystąpienia takich sytuacji wyjątkowych. Zazwyczaj jednak nie da się podjąć żadnych działań naprawczych w miejscu ich wykrycia. Trzeba przerwać normalny tok działania programu i przekazać informacje o błędzie "na zewnątrz", do szerszego kontekstu (do metody, która wywołała daną metodę lub jeszcze dalej), gdzie mogą być podjęte działania naprawcze. Starsze języki programowania jak C nie zawierały żadnych specjalnych mechanizmów ułatwiających radzenie sobie w takich sytuacjach. Istniało natomiast kilka ogólnie przyjętych schematów postępowania. Można dawać jako wynik specjalną wartość oznaczającą błąd lub ustawiać globalnie dostępną flagę, którą następnie trzeba skontrolować. Z czasem okazało się jednak, że programiści nie byli konsekwentni w przestrzeganiu tych konwencji. Zakładali, że błędy zdarzają się jedynie w kodzie innych, a w ich nie. Często byli też po prostu leniwi, gdyż obsługa wszystkich nietypowych sytuacji, np. sprawdzanie, czy błąd nie wystąpił po wywołaniu każdej kolejnej metody, skutkowałaby wielokrotnym napęcznieniem kodu oraz drastycznie utrudniła jego stworzenie i zrozumienie. W efekcie sytuacje wyjątkowe nie były prawidłowo rozpoznawane i program wykonywał się dalej powodując wystąpienie jeszcze większych problemów. Brak specjalnych mechanizmów wymuszających obsługę sytuacji wyjątkowych był ważnym ograniczeniem przy tworzeniu dużych, solidnych i łatwych w pielęgnowaniu programów.

Mechanizm obsługi wyjątków

Nowoczesne obiektowe języki programowania mają wbudowany specjalny mechanizm ułatwiający i upraszczający radzenie sobie z obsługą systuacji wyjątkowych. W chwili wykrycia takiej sytuacji można stworzyć specjalny obiekt nazywany wyjątkiem (ang. exception), zawrzeć w nim wszystkie informacje na temat tego, co się stało i przy pomocy specjalnej instrukcji throw (w niektórych językach raise) zgłosić ten wyjątek do obsłużenia. Zgłoszenie wyjątku wymusza przerwanie normalnego trybu wykonywania programu i rozwinięcie stosu wywołań, aż do napotkania kontekstu zawierającego kod obsługi dla wyjątków tego rodzaju. Jeżeli cały stos zostanie rozwinięty, a wyjątku nie obsłużono, program jest przerywany.

Nieobsłużone wyjątki

Metoda głębiej() w pokazanej poniżej klasie NieobsługiwanyWyjątek zgłasza wyjątek klasy Exception (podstawowa klasa reprezentująca wyjątki w Javie), gdy przekazany jej parametr ma wartość null.

public class NieobsługiwanyWyjątek {
  void głębiej(String s) throws Exception {
    System.out.println("początek głębiej");
    if (s == null) throw new Exception();
    System.out.println("koniec głębiej");
  }

  void głęboko(String s) throws Exception {
    System.out.println("początek głęboko");
    głębiej(s);
    System.out.println("koniec głęboko");
  }
  
  public static void main(String[] args) throws Exception {
    NieobsługiwanyWyjątek ue = new NieobsługiwanyWyjątek();
    System.out.println("przed głęboko");
    ue.głęboko(null);
    System.out.println("po głęboko");
  }
}

Z powodu zgłoszenia wyjątku stos wywołań jest rozwijany, a ponieważ żadna z kolejno znajdujących się tam metod głębiej(), głęboko() i main() nie zawiera kodu obsługi wyjątku, program zostaje przerwany. W takiej sytuacji Java wypisuje na standardowe wyjście błędu informacje o wyjątku, który spowodował przerwanie programu. Ich poziom szczegółowości zależy od ustawień kompilatora, ale przy ustawieniach domyślnych podawany jest ślad stosu wywołań z chwili wystąpienia wyjątku oraz numery linii w kodzie źródłowym odpowiadające kolejnym jego pozycjom.

przed głęboko
początek głęboko
początek głębiej
Exception in thread "main" java.lang.Exception
  at test.NieobsługiwanyWyjątek.głębiej(NieobsługiwanyWyjątek.java:4)
  at test.NieobsługiwanyWyjątek.głęboko(NieobsługiwanyWyjątek.java:10)
  at test.NieobsługiwanyWyjątek.main(NieobsługiwanyWyjątek.java:17)

Kontrolowanie obsługi wyjątków przez kompilator

Warto zwrócić uwagę, że w deklaracjach wszystkich metod klasy NieobsługiwanyWyjątek występuje klauzula throws, która informuje, jakich wyjątków można się spodziewać w wyniku wywołania danej metody. W tym wypadku klauzula ta jest wymuszona przez kompilator, gdyż z metody mogą wydostać sie nieobsłużone wyjątki. Dzięki temu programista nie przeoczy żadnego wyjątku i jeżeli nie chce wszystkich obsłużyć musi świadomie wymienić je (bądź ich nadklasy) w deklaracji metody. Jeżeli wymienianych jest kilka klas, ich nazwy oddziela się przecinkiem.

W klauzuli throws można również wymienić wyjątki, które w aktualnej wersji metody nie mogą wystąpić. W ten sposób można zawczasu wymusić na innych programistach, aby ich kod używający naszej metody był na te wyjątki przygotowany. Gdy w przyszłej wersji będą już się mogły pojawić, nie spowoduje to żadnych problemów.

Obsługa wyjątków

try {
  //kod który może zgłosić wyjątki
} catch (Typ1 w) {
  //obsługa wyjątków Typ1
} catch (Typ2 w) {
  //obsługa wyjątków Typ2
} catch (Typ3 w) {
  //obsługa wyjątków Typ3
}

Do obsługi wyjątków służy instrukcja try-catch. Po try podaje się blok instrukcji, których wyjątki chcemy obsługiwać. Następnie podawana jest lista bloków catch, które przypominają deklaracje metod. Każda z nich obsługuje wyjątki określonego typu. Typ wyjątku wraz z nazwą zmiennej, na którą zostanie przypisany złapany egzemplarz, podaje się między nawiasami bezpośrednio po słowie kluczowym catch. Do tej zmiennej można się odwoływać w kodzie obsługi wyjątku. Do obsługi wyjątku wybierany jest zawsze pierwszy pasujący blok catch. Ponieważ wyjątki są obiektami "pasowanie" oznacza tu po prostu możliwość przypisania wyjątku na zmienną zadeklarowaną po słowie kluczowym catch. Wszystkie dalsze bloki catch są pomijane, nawet jeżeli pasowały. Jeżeli wyjątek nie pasuje do żadnego bloku catch, nie zostaje obsłużony i stos wywołań jest nadal rozwijany. W pokazanej poniżej klasie ObsługiwanyWyjątek, która jest modyfikacją poprzedniego przykładu, wyjątek Exception jest już obsługiwany i nie powoduje przerwania programu.

public class ObsługiwanyWyjątek {
  void głębiej(String s) throws Exception {
    System.out.println("początek głębiej");
    if (s == null) throw new Exception();
    System.out.println("koniec głębiej");
  }

  void głęboko(String s) {
    try {
      System.out.println("początek głęboko");
      głębiej(s);
      System.out.println("koniec głęboko");
    } catch (Exception e) {
      System.out.println("obsługa wyjątku");
      e.printStackTrace(System.out);
    }
    System.out.println("po obsłużeniu wyjątku");
  }

  public static void main(String[] args) {
    ObsługiwanyWyjątek ep = new ObsługiwanyWyjątek();
    System.out.println("przed głęboko");
    ep.głęboko(null);
    System.out.println("po głęboko");
  }
}

Warto zwrócić uwagę, że metody głęboko() i main() nie muszą już posiadać klauzuli throws.

Na konsolę nadal wypisywane są informacje o wyjątku, ale jest to spowodowane użyciem w kodzie obsługi wyjątku metody printStackTrace() z parametrem System.out. Metoda ta jest odziedziczona z klasy Throwable, po której muszą dziedziczyć wszystkie wyjątki.

przed głęboko
początek głęboko
początek głębiej
obsługa wyjątku
java.lang.Exception
  at test.ObsługiwanyWyjątek.głębiej(ObsługiwanyWyjątek.java:4)
  at test.ObsługiwanyWyjątek.głęboko(ObsługiwanyWyjątek.java:11)
  at test.ObsługiwanyWyjątek.main(ObsługiwanyWyjątek.java:23)
po obsłużeniu wyjątku
po głęboko

Rozwijanie stosu

Poniższa prezentacja pokazuje jak rozwijany jest stos podczas zgłaszania wyjątku.



Hierarchia wyjątków w Javie

W trakcie wykonywania instrukcji występujących po słowie kluczowym try mogą zajść różne sytuacje wyjątkowe. Być może na każdą z nich należy zareagować w inny sposób. W obiekcie zgłaszanym jako wyjątek można zawrzeć dodatkowe informacje pozwalające zdecydować jakie działania naprawcze podjąć. Zazwyczaj jednak używa się wyjątków będących egzemplarzami różnych klas i stosuje dla każdej z nich oddzielny blok catch. W Javie zdefiniowanych jest wiele typowych wyjątków (najważniejsze są w pakiecie java.lang). Są używane przez klasy ze standardowych bibliotek. Różnią się jedynie nazwą, która pozwala zorientować się co dany wyjątek oznacza. W swoich aplikacjach można korzystać z tych klas lub zdefiniować nowe, pasujące do konkretnego zastosowania. Wszystkie klasy, których egzemplarze mogą być zgłoszone jako wyjątek muszą dziedziczyć po klasie Throwable. Tej klasy jednak bezpośrednio się nie rozszerza. Hierarchia wyjątków w Javie jest pokazana na diagramie poniżej.

Hierarchia wyjątków w Javie

Klasa Error

Jedyne dwie bezpośrednie podklasy Throwable to Error i Exception. Wyjątki dziedziczące po Error reprezentują poważne problemy, których aplikacja nie będzie w stanie rozwiązać. Przykładową podklasą jest VirtualMachineError. Wystąpienie takiego wyjątku oznacza, że maszyna wirtualna nie może dalej kontynuować pracy, np. z powodu wyczerpania się zasobów systemowych. Wyjątków rozszerzających Error nie należy przechwytywać, gdyż nie ma możliwości zaradzenia sytuacjom wyjątkowym, które je spowodowały. Z założenia te wyjątki mogą wystąpić praktycznie w każdej instrukcji kodu i nie muszą być wymieniane w klauzulach throws. Inną ciekawą podklasą Error jest AssertionError. Wyjątki tego typu są zgłaszane, jeżeli niezmiennik – w Javie określany jako asercja – umieszczony przez programistę w kodzie okazuje się po wyliczeniu nieprawdziwy.

Klasa Exception

Wyjątki dziedziczące po Exception reprezentują sytuacje, na które dobrze napisana aplikacja powinna być przygotowana. To właśnie tę klasę rozszerza się tworząc własne rodzaje wyjątków. Jej przykładowe podklasy to:

  • IOException, która reprezentuje sytuacje wyjątkowe związane z wejściem/wyjściem,
  • ClassNotFoundException, która wskazuje, że maszyna wirtualna nie odnalazła klasy o nazwie podanej jako napis,
  • SQLException, która reprezentuje sytuacje wyjątkowe związane z dostępem do bazy danych oraz
  • SAXParseException, która wskazuje, że podczas parsowania dokumentu XML wystąpił błąd.

Klasa RuntimeException

Bardzo ciekawą podklasą Exception jest RuntimeException, która sama posiada wiele podklas. Wyjątki rozszerzające RuntimeException mogą wystąpić podczas typowych operacji, jak rzutowanie zmiennej, odwołanie się do elementu tablicy lub odwołanie się do składowej obiektu. Ich wystąpienie zazwyczaj oznacza, że programista popełnił błąd w swoim kodzie lub nieumiejętnie korzystał z kodu napisanego przez innych. Maszyna wirtualna wykrywa wystąpienie takich błędów w trakcie działania programu i informuje o tym, zgłaszając odpowiedni wyjątek. Przykładowymi podklasami RuntimeException są:

  • ClassCastException, która oznacza próbę rzutowania zmiennej na niepasujący typ,
  • IndexOutOfBoundsException, która oznacza odwołanie się do indeksu z poza zakresu oraz
  • NullPointerException, która oznacza że zamiast referencji wskazującej na obiekt pojawiła się wartość null.

Mimo, że takie wyjątki też można obsługiwać, zdecydowanie lepiej jest zadbać żeby się nie pojawiały. Ze względu na swoją wszechobecność wymienianie ich w klauzulach throws praktycznie wszystkich metod mijałoby się z celem, więc tak samo jak w przypadku wyjątków rozszerzających Error nie ma takiego obowiązku.

Rozszerzanie klas Exception i RuntimeException

Wyjątki kontrolowane/niekontrolowane

Mimo, że często używa się wyjątków ze standardowych bibliotek Javy, tworzenie nowych wyjątków nie jest niczym nadzwyczajnym. Tworząc nowe wyjątki zazwyczaj rozszerza się albo bezpośrednio klasę Exception albo RuntimeException. Wyjątki rozszerzające Exception, ale nie RuntimeException, nazywane są wyjątkami kontrolowanymi (ang. checked exceptions), gdyż kompilator pilnuje, aby programista ich nie przegapił i albo obsłużył albo wymienił w klauzuli throws. Wyjątki rozszerzające RuntimeException to wyjątki niekontrolowane (ang. unchecked exceptions) i ich kontrola ze strony kompilatora nie dotyczy. Oba rodzaje są potrzebne i nie należy o nich zapominać.

Składowe nowych wyjątków

W większości przypadków nowe wyjątki nie zawierają definicji żadnych atrybutów ani metod. Już sama nazwa klasy wystarcza, aby się zorientować, co jest nie tak i jakie działania naprawcze podjąć.

class ZaMałoŚrodkówNaKoncie extends Exception {}

public class NowyWyjątek1 {
  public static void main(String[] args) {
    try {
      throw new ZaMałoŚrodkówNaKoncie();
    } catch (ZaMałoŚrodkówNaKoncie w) {
      System.out.println("Wiwat twórca wyjątków!");
    }
  }
}

Jeżeli jednak wyjątek przenosi już jakieś dane, to najczęściej są to napisy. Gdy na przykład nie daje się odnaleźć lub otworzyć wskazanego pliku, to warto do wyjątku dołączyć jego nazwę. Kod obsługi wyjątku może ją wyświetlić użytkownikowi, który być może pomylił się podczas wprowadzania. Dla wygody większość wyjątków ze standardowych bibliotek Javy (w tym Exception) posiada dodatkowy konstruktor przyjmujący jako parametr napis przenoszony przez wyjątek. Oprócz tego, nadklasa wszystkich wyjątków Throwable, definiuje metodę getMessage() przy pomocy której można ten napis z wyjątku wydobyć. Żeby w swoich wyjątkach móc korzystać z tego mechanizmu, trzeba dodać konstruktor przyjmujący napis i przekazać parametr do konstruktora nadklasy.

class PlikNieIstnieje extends Exception {
  PlikNieIstnieje(String s) {
    super(s);
  }

  //ponieważ definiujemy jakiś inny konstruktor,
  //kompilator sam nie doda bezparametrowego
  PlikNieIstnieje() {}
}

public class NowyWyjątek2 {
  public static void main(String[] args) {
    try {
      //napis przyda się podczas obsługi wyjątku
      throw new PlikNieIstnieje("alamakota.txt");
    } catch ( PlikNieIstnieje  w) {
      System.out.println(w.getMessage());
    }
  }
}

Zgłaszanie wyjątków w bloku catch

W reakcji na obsługiwany wyjątek, w bloku catch można zgłosić nowy wyjątek. Wyjątki zgłoszone w bloku catch nie są obsługiwane przez tę samą instrukcję try-catch.

Ponowne zgłaszanie obsługiwanego wyjątku

Czasami obsługiwany wyjątek jest zgłaszany ponownie, gdyż w tym kontekście nie wiadomo jak go obsłużyć.

void głębiej() throws Exception {
  throw new Exception();
}

void głęboko() throws Exception {
  try {
    głębiej();
  } catch (Exception e) {
    throw e;
  }
}

Ponowne zgłoszenie nie zmienia zapamiętanego śladu wywołań. Nie jest on wypełniany w chwili zgłoszenia wyjątku, ale w chwili jego utworzenia. Odpowiada za to konstruktor z nadklasy Throwable, który wywołuje metodę fillInStackTrace(). Przy pomocy tej metody można samemu podmienić na aktualny ślad stosu wywołań w ponownie zgłaszanym wyjątku.

void głębiej() throws Exception {
  throw new Exception();
}

void głęboko() throws Exception {
  try {
    głębiej();
  } catch (Exception e) {
    e.fillInStackTrace();
    throw e;
  }
}

Wyjątek powodujący zgłoszenie innego wyjątku

Od Javy 1.4 do zgłaszanego wyjątku można dołączyć wyjątek, który spowodował jego wystąpienie. Służy do tego metoda initCause(), która jako parametr przyjmuje dołączany wyjątek. Dla wygody do klas Throwable, Exception i RuntimeException dodano również po dwa nowe konstruktory. Pierwszy, przyjmujący jako jedyny parametr wyjątek do dołączenia oraz drugi, przyjmujący jako pierwszy parametr napis przenoszony przez wyjątek i jako drugi parametr wyjątek do dołączenia. Jeżeli mamy zamiar w ten sposób łączyć wyjątki w ciągi, warto dodać takie konstruktory do nowo definiowanych wyjątków.

class MójNowyWyjątek extends Exception {
  public MójNowyWyjątek() {
    super();
  }

  public MójNowyWyjątek(String message) {
    super(message);
  }

  public MójNowyWyjątek(Throwable cause) {
    super(cause);
  }

  public MójNowyWyjątek(String message, Throwable cause) {
    super(message, cause);
  }
}

public class WyjątekSpowodowanyWyjątkiem {
  public static void main(String[] args) throws MójNowyWyjątek {
    try {
      throw new Exception("To ja jestem wszystkiemu winien.");
    } catch (Exception e) {
      throw new MójNowyWyjątek("To nie moja wina.", e);
    }
  }
}

Informacje o dołączonym wyjątku można uzyskać przy pomocy metody getCause(). Informacje te są również wypisywane na standardowe wyjście błędu, jeżeli wykonanie programu zostało przerwane przez nieobsłużony wyjątek.

Exception in thread "main" test.MójNowyWyjątek: To nie moja wina.
  at test.WyjątekSpowodowanyWyjątkiem.main(WyjątekSpowodowanyWyjątkiem.java:24)
Caused by: java.lang.Exception: To ja jestem wszystkiemu winien.
  at test.WyjątekSpowodowanyWyjątkiem.main(WyjątekSpowodowanyWyjątkiem.java:22)

Wsparcie ze strony kompilatora

Wiele błędów w programie związanych z nieprawidłową obsługą wyjątków można wykryć już w chwili kompilacji. Java idzie pod tym względem bardzo daleko. Kompilator nie pozwoli użyć nieosiągalnego bloku catch. Blok catch jest nieosiągalny, jeżeli wszystkie potencjalne wyjątki do niego pasujące są obsługiwane przez wcześniejsze bloki

public class NieosiągalnyBlokCatch {
  public static void main(String[] args) {
    try {
      System.out.println("Kompilator na ratunek gapom.");
    } catch (Exception e) {
      System.out.println("Ten catch nic nie przepuszcza dla pozostałych!");
    } //catch (NullPointerException e) {
      //System.out.println("Ten kod jest tu niepotrzebny!");
      //}
  }
}

Kompilator zaprotestuje również wtedy, gdy blok catch jest po prostu niepotrzebny. Jest tak, jeżeli nigdzie w bloku try nie jest zgłaszany wyjątek do niego pasujący, ani nie jest używana żadna metoda, która by taki wyjątek miała wymieniony w swojej klauzuli throws. Wystąpienie nieosiągalnego lub niepotrzebnego bloku catch pozwala sądzić, że programista się pomylił i chciał zrobić coś innego, dlatego kompilator zwróci na to uwagę.

Kolejnym rodzajem pomyłek, przed którymi kompilator chroni nieuważnych programistów jest nieosiągalny fragment kodu znajdujący się po zgłaszanym wyjątku.

throw new Exception();
//System.out.println("Ten kod nigdy by się nie wykonał!");

Czy wymuszać obsługę wyjątków?

Warto przypomnieć, że w Javie programista może sobie zagwarantować, że wywołujący jego metody nie przegapi niektórych wyjątków. Wystarczy w tym celu używać wyjątków kontrolowanych, co zmusza do ich obsłużenia lub wymienienia w klauzuli throws. Jest to cecha niespotykana w innych powszechnie używanych językach programowania i ponieważ budzi wiele kontrowersji warto jej poświęcić więcej uwagi.

Bez cienia wątpliwości można stwierdzić, że wyjątki kontrolowane są bardzo przydatne w niektórych sytuacjach. Czasami po prostu jesteśmy pewni, że programista używający naszego kodu powinien jakoś zareagować, np. gdy przeoczenie wyjątku mogłoby spowodować, że jakieś zasoby nie zostaną zwolnione. Jeżeli przykładowo podczas komunikacji ze zdalną bazą danych wystąpił błąd i programista zapomniał go właściwie obsłużyć, to połączenie z tą bazą może nigdy nie zostać właściwie zamknięte. Stanowi to problem ponieważ liczba takich, jednocześnie aktywnych, połączeń jest zazwyczaj istotnie ograniczona (do kilkunastu lub kilkudziesięciu).

Paradoksalnie jednak nadużywanie wyjątków kontrolowanych jest niewygodne i może nawet prowadzić do ich ginięcia. Bardzo często w kodzie bezpośrednio wywołującym nasze metody nie ma jak obsłużyć wyjątku. Należy go wtedy przepuścić dalej i w najgorszym wypadku informacja o nim zostanie przekazana użytkownikowi lub zapisana w dzienniku. Niestety jeżeli wyjątek jest kontrolowany zmusi to programistów do wymieniania go "po drodze" we wszystkich klauzulach throws lub do dodania jakiegoś kodu obsługi. W pierwszym przypadku spowoduje to niepotrzebny wzrost sprzężenia oraz ponieważ występowanie danego wyjątku może zależeć od aktualnie przyjętego sposobu implementacji niejako obejdzie kapsułkowanie. W drugim przypadku zmusi programistę do przechwycenia wyjątku i zgłoszenia w zamian jakiegoś innego (kontrolowanego lub nie), ewentualnie podłączając oryginalny wyjątek jako przyczynę jego powstania. Pisanie takiego kodu jest żmudne i nudne i dlatego, bardzo często można natknąć się na następujący schemat:

try {
  zgłaszaKontrolowanyWyjątek();
} catch (KontrolowanyWyjątek w) {
  e.printStacktrace();
}

lub nawet gorzej:

try {
  zgłaszaKontrolowanyWyjątek();
} catch (KontrolowanyWyjątek w) {}

Autor zapewne nie miał od razu pomysłu co zrobić z wyjątkiem, więc otoczył niebezpieczny kod "tymczasowym" blokiem try-catch i skoro podczas testów wszystko działało, zapomniał do niego później wrócić. Częstość używania takich "tymczasowych" bloków try-catch stała się powodem do krytyki kontrolowanych wyjątków w Javie. Wydaje się jednak, że problem leży nie w samym mechanizmie, który jak opisaliśmy powyżej czasami jest bardzo przydatny, ale w jego nadużywaniu. Dlatego jeżeli nie jesteś w stu procentach pewien, że programista używający twój kod będzie wiedział jak obsłużyć twoje wyjątki używaj wyjątków niekontrolowanych, czyli rozszerzaj klasę RuntimeException.