Zagadnienia

10. Obsługa wyjątków

Obsługa wyjątków

10.1. Obsługa wyjątków - wstęp

10.1.1. Wprowadzenie do obsługi wyjątków

Sposoby reagowania na błędne sytuacje:

  • komunikat i przerwanie działania programu,

  • kod błędu jako wynik,

  • globalna zmienna z kodem błędu,

  • parametr - funkcja obsługi błędów.

Bardzo często zdarza się, że pisząc jakąś operację (funkcję), zauważamy, że ta operacja nie zawsze musi się dać poprawnie wykonać. Nasza funkcja powinna jakoś zareagować w takiej sytuacji, kłopot polega na tym, że nie wiemy jak. Może:

  • Wypisać komunikat i przerwać działanie całego programu.
    Bardzo brutalne.

  • Przekazać wartość oznaczającą błąd.
    Nie zawsze jest wykonalne (może nie być wartości, która nie może być poprawną wartością funkcji). Poza tym zwykle jest bardzo niewygodne, bo wymaga sprawdzania wartości funkcji po każdym jej wywołaniu. Program konsekwentnie wykonujący takie sprawdzenia staje zupełnie nieczytelny, jeśli zaś sprawdzanie nie jest konsekwentnie stosowane to jest warte tyle samo, co gdyby go w ogóle nie było.

  • Przekazać jakąś poprawną wartość, natomiast ustawić jakąś zmienną (zmienne) w programie sygnalizujące zaistnienie błędnej sytuacji.
    To już jest zupełnie złe rozwiązanie: tak samo jak poprzednie wymaga ciągłego sprawdzania czy nie nastąpił błąd, jest też bardzo prawdopodobne, że używający takiej operacji w ogóle nie będzie świadom tego, że błędy w ogóle są sygnalizowane.

  • Wywołać funkcję dostarczoną przez użytkownika (np. jako parametr), obsługującą błędne sytuacje.
    Najlepsze z dotąd przedstawionych rozwiązanie. Jego wadą jest to, że każde wywołanie funkcji trzeba obciążyć dodatkowym parametrem.

  • Celem jest przezwyciężenie problemów z wcześniejszych rozwiązań.

  • Wyjątek rozumiany jako błąd.

  • Funkcja zgłasza wyjątek.

  • Kod obsługi wyjątku może być w zupełnie innym miejscu programu.

  • Szukanie obsługi wyjątku z ”paleniem mostów”.

  • Nie ma mechanizmu powrotu z obsługi wyjątku do miejsca jego zgłoszenia.

W celu rozwiązania takich problemów włączono do języka C++ mechanizm obsługi wyjątków. W C++ wyjątek oznacza błąd, zaś obsługa wyjątków oznacza reakcję programu na błędy wykryte podczas działania programu. Idea obsługi wyjątków polega na tym, że funkcja, która napotkała problem, z którym nie potrafi sobie poradzić zgłasza wyjątek. Wyjątek jest przesyłany do miejsca wywołania funkcji. Tam może być wyłapany i obsłużony lub może być przesłany dalej (wyżej). Podczas tego przechodzenia, przy wychodzeniu z funkcji i bloków następuje automatyczne usuwanie automatycznych obiektów stworzonych w tych funkcjach i blokach (to bardzo ważne). W C++ nie możliwości powrotu z obsługi wyjątku, do miejsca jego wystąpienia, w celu ponownego wykonania akcji, która spowodowała błąd.

Uwaga: Mechanizm obsługi wyjątków w innych językach może być zrealizowany zupełnie inaczej (np. wyjątek nie musi być utożsamiany z błędem, może być możliwe wznowienie wykonywania programu w miejscu wystąpienia wyjątku itp.). W szczególności nie ma ogólnej zgody czym powinien być wyjątek.

Składnia instrukcji związanych z obsługą wyjątków:

	try{
		<instrukcje>
	}
	catch (<parametr 1>){
		<obsługa wyjątku 1>
	}
	// ...
	catch (<parametr n>){
		<obsługa wyjątku n>
	}
 

Zgłoszenie wyjątku:

	throw <wyrażenie>;
 

  • Zgłoszenie wyjątku rozpoczyna wyszukiwanie obsługi

  • Klauzule catch są przeglądane w kolejności ich deklaracji

  • Trzy możliwości

    • instrukcje nie zgłosiły wyjątku

    • klauzula wyjątku znaleziona - być może w innym bloku - i wykonana

    • brak pasującej klauzuli

Semantyka: Jeśli jakaś z <instrukcji> zgłosiła wyjątek, to przerywamy wykonywanie tego ciągu instrukcji i szukamy instrukcji catch z odpowiednim parametrem. Jeśli znajdziemy, to wykonujemy obsługę tego wyjątku. Klauzule catch są przeglądane w kolejności ich deklaracji. Po zakończeniu obsługi wyjątku (o ile obsługa wyjątku jawnie nie spowodowała przejścia do innej części programu) wykonuje się pierwszą instrukcję stojącą po instrukcjach catch. Jeśli zaś nie znaleziono obsługi stosownego wyjątku, to następuje przejście do wywołującej funkcji, połączone z usunięciem obiektów automatycznych i tam znów rozpoczyna się poszukiwanie obsługi wyjątku. Jeśli takie poszukiwanie nie zakończy się sukcesem, to wykonywanie programu zostanie przerwane.

Przykład:

class Wektor{
 int *p;
 int rozm;
public:
 class Zakres{};	// Wyjątek: wyjście poza zakres
 class Rozmiar{};	// Wyjątek: zły rozmiar wektora
 Wektor(int r);
 int& operator[](int i);
 // ...
};

Wektor::Wektor(int r){
 if (r<=0)
  throw Rozmiar();
  // ...
}

int& Wektor::operator[](int i){
 if (0<=i && i < rozm)
  return p[i];
 else
  throw Zakres();	// Na razie nie przekazujemy wartości błędnego indeksu
}

 void f(){
  try{
   // używanie wektorów
  }
  catch (Wektor::Zakres){
   // obsługa błędu przekroczenia zakresu
  }
  catch (Wektor::Rozmiar){
   // obsługa błędu polegającego na błędnym podaniu rozmiaru
  }
  // Sterowanie do chodzi tutaj, gdy:
  // a) nie było zgłoszenia wyjątku, lub
  // b) zgłoszono wyjątek Zakres lub Rozmiar i obsługa tego
  //    wyjątku nie zawierała instrukcji powodujących wyjście
  //    z funkcji f
}

Obsługa wyjątków może być podzielona między wiele funkcji:

 void f1(){
  try{ f2(w); }
  catch (Wektor::Rozmiar) { /* ... */ }
 }

 void f2(Wektor& w){
  try{ /* używanie wektora w */ }
  catch (Wektor::Zakres) { /* ... */ }
 }
  • Obsługa wyjątku może zgłosić kolejny.

  • Wyjątek jest uważany za obsłużony z momentem wejścia do klauzuli obsługującej go.

  • Można zagnieżdżać wyjątki.

W instrukcjach obsługujących wyjątek może się pojawić instrukcja throw. W szczególności może się też pojawić instrukcja throw zgłaszająca taki sam wyjątek, jak ten właśnie wywoływany. Nie spowoduje to zapętlenia ani nie będzie błędem. Z punktu widzenia języka C++ wyjątek jest obsłużony z chwilą wejścia do procedury obsługi wyjątku, zaś wyjątki zgłaszane w procedurach obsługi są obsługiwane przez funkcje wywołujące blok try. Można także zagnieżdżać bloki try-catch w instrukcjach catch (nie wydaje się to jednak celowe).

10.1.2. Przekazywanie informacji wraz z wyjątkiem

  • Wyjątek jest obiektem.

  • Obiekt może mieć swój stan.

Instrukcja zgłaszająca wyjątek (throw), zgłasza obiekt. Taki obiekt może posiadać składowe i dzięki nim przenosić informację z miejsca zgłoszenia do miejsca obsługi.

 class Wektor{ // ...
  public:
   class Zakres{
    public:
     int indeks;
     Zakres(int i): indeks(i) {}
   };
   int& operator[] (int i);
   // ...
 };
 
  int& Wektor::operator[](int i){
   if (0<=i && i<rozm) return p[i];
    throw Zakres(i);
  }
  // ...

  void f(Wektor& w){
   // ...
   try{ /* używanie w */ }
   catch (Wektor::Zakres z){
    cerr << "Zły indeks" << z.indeks << "\n";
    // ...
   }
  }
 

10.1.3. Hierarchie wyjątków

  • Klasy wyjątków można łączyć w hierarchie.

  • Pozwala to specjalizować obsługę wyjątków.

Ponieważ wyjątki są obiektami klas, możemy tworzyć hierarchie klas wyjątków. Co to daje? Możemy, w zależności od sytuacji, pisać wyspecjalizowane procedury obsługi wyjątków, lub jedną obsługującą wszystkie wyspecjalizowane wyjątki:

 class BłądMatemat {};
 class Nadmiar: public BłądMatemat {};
 class Niedomiar: public BłądMatemat {};
 class DzielPrzezZero: public BłądMatemat {};
 // ...
 try { /* ... */ }
 catch (Nadmiar) { /* Obsługa nadmiaru */ }
 catch (BłądMatemat) { /* Obsługa pozostałych bł. mat. */ }
 

10.1.4. Dodatkowe własności wyjątków

  • Wznawianie wyjątku throw

  • Obsługa dowolnego wyjątku

W procedurze obsługi wyjątku można ponownie zgłosić ten sam wyjątek pisząc throw bez argumentu (jak już poprzednio zaznaczyliśmy, nie spowoduje to zapętlenia).

Obsługa dowolnego wyjątku catch (….) oznacza wyłapywanie dowolnego wyjątku. Można je zastosować razem z throw:

 void f(){
  try { /* ... */
  }
  catch (...) {
   /* Instrukcje, które muszą się zawsze wykonać na koniec
      procedury f, jeśli nastąpił błąd. */
  throw; // Ponowne zgłoszenie złapanego wyjątku
 }
 

10.1.5. Zdobywanie zasobów

Częstym problemem związanym z obsługą wyjątków, jest zwalnianie zasobów, które funkcja zdążyła już sobie przydzielić zanim nastąpił błąd. Dzięki wyjątkom możemy bardzo prosto rozwiązać ten problem, obudowując zasoby obiektami (pamiętajmy, że procesowi szukania procedury obsługi błędu towarzyszy usuwanie obiektów lokalnych).

  • Problem - zwalnianie zasobów.

  • Pomysł:

    • opakowanie zasobów obiektami

    • wykorzystanie mechanizmu wyjątków.

Rozwiązanie 1 (tradycyjne - niewygodne):

  void używanie_pliku(const char* np){
   FILE* p = fopen(np, "w");
   try { /* coś z plikiem p */ }
   catch (...){
    fclose(p);
    throw;
   }
   fclose(p);
  }
 

Rozwiązanie 2 (eleganckie i ogólne):

  class Wsk_do_pliku{
   FILE* p;
  public:
   Wsk_do_pliku(const char* n, const char * a)
    {p = fopen(n,a); }
   ~Wsk_do_pliku() {fclose(p);}
   operator FILE*() {return p;}
   // Jeśli będę potrzebował  wskaźnika do struktury FILE
  };

  void używanie_pliku(const char* np){
   Wsk_do_pliku p(np, "w");
   /* coś z plikiem p */
  }

 

Teraz nasza funkcja daje się już ładnie zapisać, nie ma w niej ani jednej dodatkowej instrukcji, nie ma nawet operacji fclose!

  • Zdobywanie zasobów jest inicjacją (RAII - Resource Acquisition Is Initialization)

  • Bardzo skuteczna technika w C++

  • W innych językach:

    • Java: finilize

    • C#: instrukcja using

Tę technikę nazywa się zwykle ”zdobywanie zasobów jest inicjacją”.

Zastanówmy się teraz nad następującym problemem. Obiekt uważa się za skonstruowany, dopiero po zakończeniu wykonywania jego konstruktora. Dopiero wtedy porządki wykonywane wraz z szukaniem procedury obsługi błędu usuną obiekt z pamięci (i zwolnią zajmowane przez niego zasoby). Pojawia się więc naturalny problem: co ma robić konstruktor, gdy wykryje błąd? Powinien zwrócić te zasoby, które już sobie przydzielił. Stosując powyższą technikę jesteśmy w stanie bardzo łatwo to zagwarantować. Oto przykład, konstruktor przydzielający dwa zasoby: plik i pamięć:

 class X{
  Wsk_do_pliku p;
  Wsk_do_pamięci<int> pam;
  // ...
  X(const char*x, int y): p(x, "w"), pam(r) { /* inicjacja */ }
  // ...
 };

 class Za_mało_pamięci{};

 template<class T> class Wsk_do_pamięci{
 public:
  T* p;
  Wsk_do_pamięci(size_t);
  ~Wsk_do_pamięci() {delete[] p;}
  operator T*() {return p;}
 };

 template<class T>
  Wsk_do_pamięci::Wsk_do_pamięci(size_t r){
   p = new T[t];
  }

Teraz to już implementacja dba o to, by wywołać destruktory dla tych obiektów, które zostały skonstruowane (i tylko dla nich).

10.1.6. Specyfikowanie wyjątków w interfejsie funkcji

  • Wyjątki są istotną cechą specyfikacji funkcji.

  • C++ nie wymusza ich specyfikowania.

  • Jeśli są wyspecyfikowane ich sprawdzenie odbywa się podczas wykonania programu.

  • Specyfikowanie braku wyjątków i ich niezgłaszania.

Jeśli mamy wyjątki, to interfejs funkcji staje się o nie bogatszy. Język C++ pozwala (nie zmusza) do ich wyspecyfikowania:

	void f() throw (x1, x2, x3) {  /* treść f() */  }
 

Jest to równoważne napisaniu:

  void f(){
   try { /* treść f() */ }
   catch (x1) { throw; }
   catch (x2) { throw; }
   catch (x3) { throw; }
   catch (...) { unexpected(); }
  }

Czyli f() może zgłosić wyjątki x1, x2 i x3 oraz pochodne. Domyślnym znaczeniem unexpected() jest zakończenie działania programu. Zwróćmy uwagę, że sprawdzanie, czy zgłoszony wyjątek jest we specyfikacji, następuje dopiero podczas wykonywania programu (a nie podczas kompilacji). Funkcja dla której nie wyspecyfikowano żadnego wyjątku, może zgłosić każdy wyjątek. Jeśli funkcja ma nie zgłaszać żadnych wyjątków, to piszemy:

	void g() throw();

Treść automatycznie generowana z plików źródłowych LaTeXa za pomocą oprogramowania wykorzystującego LaTeXML.

Projekt współfinansowany przez Unię Europejską w ramach Europejskiego Funduszu Społecznego.

Projekt współfinansowany przez Ministerstwo Nauki i Szkolnictwa Wyższego i przez Uniwersytet Warszawski.