Wyjątki
Wyjątki to technika ułatwiająca pobieranie informacji z miejsca wykrycia błędu i przekazywanie ich do miejsca, w którym mogą one zostać obsłużone. Funkcja nie mogąca poradzić sobie z jakimś problemem zgłasza (ang. throw) wyjątek w nadziei, że zostanie on obsłużony przez kod, który ją wywołał (bezpośrednio lub pośrednio). Funkcja mogąca obsłużyć określony rodzaj błędów zawiera odpowiednią klauzulę catch dla danego typu wyjątków:
- Składnik wywołujący określa, jakiego rodzaju problemy może rozwiązywać, poprzez wskazanie typów wyjątków w klauzulach catch bloku try.
- Jeśli składnik wywoływany nie może wykonać wyznaczonego mu zadania, zgłasza błąd w postaci wyjątku za pomocą wyrażenia throw.
Spójrz na uproszczony przykład:
{
try{
auto result = do_task();
// użycie wyniku
}
catch (Some_error) {
// niepowodzenie zadania do_task: rozwiązanie problemu
}
}
int do_task()
{
// ...
if (/* Zadanie zostało wykonane */)
return result;
else
throw Some_error{};
}
Funkcja taskmaster() prosi funkcję do_task() o wykonanie pracy. Jeśli funkcja do_task() wykona zadanie i zwróci poprawny wynik, wszystko będzie w porządku. W przeciwnym razie do_task() musi poinformować o niepowodzeniu poprzez zgłoszenie wyjątku. Funkcja taskmaster() jest przygotowana na obsługę błędu Some_error, ale zgłoszony może zostać też inny rodzaj wyjątku. Na przykład funkcja do_task() może wywoływać inne funkcje w celu zlecenia im różnych zadań pomocniczych i jedna z tych funkcji również może zgłosić wyjątek. Wyjątek inny niż Some_error oznacza niepowodzenie działania funkcji taskmaster() i musi zostać obsłużony przez kod, który wywołał tę funkcję.
Wywołana funkcja nie może ot, tak zwrócić wartości bez informowania, że wystąpił błąd. Jeżeli program ma kontynuować działanie (a nie tylko wydrukować powiadomienie o błędzie i przestać działać), zwracająca funkcja nie może wprowadzić go w nieprawidłowy stan ani gubić zasobów. Pomaga w tym sprzężenie mechanizmu obsługi wyjątków z mechanizmami konstrukcji i destrukcji oraz współbieżności. Mechanizm obsługi wyjątków:
- Jest alternatywą dla tradycyjnych technik, jeśli te są niewystarczające, nieeleganckie lub podatne na błędy.
- Jest kompletny, tzn. może być używany do obsługi wszystkich błędów wykrywanych przez zwykły kod.
- Umożliwia programiście oddzielenie kodu obsługi błędów od „zwykłego” kodu, dzięki czemu kod programu jest bardziej czytelny i przyjazny różnym narzędziom.
- Umożliwia stosowanie regularniejszego stylu obsługi błędów, pozwalając tym samym uprościć współpracę między różnymi częściami programu.
Wyjątek to obiekt reprezentujący wystąpienie błędu w programie. Może być dowolnego typu, który można kopiować, ale zaleca się używanie tylko typów zdefiniowanych przez użytkownika specjalnie do tego celu. W ten sposób minimalizuje się ryzyko, że dwie nie mające ze sobą nic wspólnego biblioteki będą wykorzystywać tę samą wartość, np. 17, do reprezentowania różnych błędów, co spowodowałoby chaos w całej obsłudze błędów programu.
Wyjątek jest przechwytywany przez kod wyrażający zainteresowanie obsługą określonego typu wyjątków (mający odpowiednią klauzulę catch). Najprostszym sposobem na zdefiniowanie wyjątku jest zdefiniowanie reprezentującej go klasy i zgłaszanie jej obiektów.Na przykład:
void f(int n)
{
if (n<0 || max //...
}
Jeśli definiowanie własnych typów jest żmudne, można skorzystać z niewielkiej hierarchii klas wyjątków dostępnej w bibliotece standardowej. Wyjątek może zawierać informacje o reprezentowanym błędzie. Jego typ określa rodzaj błędu, a zawarte w nim dane są informacjami na temat konkretnego zdarzenia. Na przykład wyjątki z biblioteki standardowej zawierają łańcuch, za pomocą którego można przekazać np. informację o miejscu wystąpienia błędu.
Tradycyjna obsługa błędów
Zastanówmy się, jakie są inne możliwości obsługi błędów w funkcji, gdy wystąpi problem, którego nie da się rozwiązać lokalnie (np. dostęp poza zakresem) i który trzeba zgłosić do wywołującego. Każde konwencjonalne rozwiązanie ma wady i żadne nie jest ogólne:
Zakończenie działania programu - jest to bardzo drastyczne rozwiązanie. Na przykład:
Większość błędów można i powinno się obsłużyć trochę lepiej. Na przykład w większości przypadków powinno się przynajmniej wydrukować wiadomość o błędzie albo zapisać błąd do dziennika przed zamknięciem programu. W szczególności biblioteka
nie znająca przeznaczenia ani ogólnej strategii działania programu, w którym jest używana, nie może tak po prostu zakończyć jego działania przy użyciu funkcji exit() czy abort(). Biblioteki bezwarunkowo zamykającej program nie można użyć w programie, który nie może ulec awarii.
Zwrócenie wartości oznaczającej błąd - to rozwiązanie nie zawsze jest wykonalne, bo często nie istnieje żadna akceptowalna „wartość oznaczająca błąd”. Na przykład:
Dla tej funkcji wejściowej możliwym wynikiem jest każda wartość typu int, a więc nie można użyć wartości całkowitoliczbowej do reprezentowania błędu wejścia. Można by było zmodyfikować tę funkcję tak, aby zwracała parę wartości. Ale nawet gdyby udało
się zaimplementować takie rozwiązanie, w większości przypadków byłoby ono niewygodne, bo w każdym wywołaniu trzeba by było sprawdzać obecność wartości błędu. W ten sposób łatwo można spowodować podwojenie rozmiaru programu. Ponadto
wywołujący często ignorują możliwość wystąpienia błędów albo zwyczajnie zapominają sprawdzić wartość zwrotną. W konsekwencji technika ta jest rzadko stosowana w systematyczny sposób do wykrywania wszystkich błędów. Na przykład funkcja
printf() zwraca ujemną wartość, gdy wystąpi błąd wyjściowy lub kodowania, ale programiści praktycznie nigdy nie wykorzystują tej informacji. Ponadto niektóre operacje w ogóle nie mają wartości zwrotnych. Doskonałym przykładem są tu konstruktory.
Zwrócenie legalnej wartości i pozostawienie programu w stanie błędu - ryzyko związane z tym podejściem jest takie, że funkcja wywołująca może nie zauważyć, że program jest w stanie błędu. Na przykład wiele standardowych funkcji języka C ustawia nielokalną zmienną errno na błąd:
W tym przypadku wartość zmiennej d jest bezsensowna, więc zmienna errno zostaje ustawiona na wartość oznaczającą, że -1.0 nie jest dozwolonym argumentem dla funkcji obliczającej pierwiastek kwadratowy dla wartości zmiennoprzecinkowych. Mimo to
w większości programów nikt nie ustawia i nie sprawdza zmiennej errno ani innych podobnych stanów w sposób systematyczny na tyle, że pozwalałoby to na uniknięcie błędów powodowanych przez wartości zwracane przez funkcje, które uległy awarii. Ponadto rejestrowanie błędów w zmiennych nielokalnych źle działa w programach współbieżnych.
Wywołanie funkcji obsługi błędów. Na przykład:
To musi być zakamuflowane jakieś inne rozwiązanie, bo od razu nasuwa się pytanie: „Co robi ta funkcja obsługi błędów?”. Jeśli funkcja obsługowa nie może całkowicie rozwiązać problemu, to musi zamknąć program, zwrócić jakąś wartość oznaczającą wystąpienie błędu albo ustawić stan błędu lub zgłosić wyjątek. Ponadto jeżeli funkcja obsługi błędów może rozwiązać problem bez pomocy kodu nadrzędnego, to czemu w ogóle to nazywać błędem?
Najczęściej w programach można spotkać bezładną mieszaninę wszystkich tych technik.
Niedbała obsługa błędów
Jednym z aspektów techniki obsługi wyjątków, który może być nowością dla niektórych programistów, jest to, że ostateczną reakcją na wystąpienie nieobsłużonego błędu (nieprzechwyconego wyjątku) jest zamknięcie programu. Tradycyjnie próbowano jakoś niezgrabnie zamieść problem pod dywan i liczono, że jakoś to będzie. Dlatego obsługa wyjątków sprawia, że program jest bardziej „kruchy”, tzn. trzeba bardziej się wysilić, aby zmusić go do działania w akceptowalny sposób. Jest to zdecydowanie lepsze niż dowiadywanie się o błędach na dalszym etapie prac albo wręcz po ich zakończeniu, gdy program trafił już do niewinnych użytkowników. Jeśli zamknięcie programu nie wchodzi w grę, można przechwycić wszystkie wyjątki. Wówczas wyjątek może spowodować zamknięcie programu, tylko jeśli pozwoli mu na to programista. Jest to zazwyczaj lepsze rozwiązanie od bezwarunkowego zamykania, które ma miejsce, gdy tradycyjne niekompletne rozwiązanie pozwala, aby błąd spowodował spustoszenie. Jeśli zamknięcie programu jest dopuszczalne, to zostanie ono spowodowane przez nieprzechwycony
wyjątek, który zamieni się w wywołanie funkcji terminate().
Ponadto można to bezpośrednio wyrazić przy użyciu specyfikatora noexcept. Niektórzy próbują zminimalizować negatywne aspekty niedbałej obsługi błędów poprzez drukowanie powiadomień o błędach, wyświetlanie okien dialogowych z prośbą o pomoc użytkownika itd. Takie podejście jest najbardziej przydatne przy debugowaniu, gdy użytkownikiem jest programista znający strukturę programu. W innych sytuacjach proszenie przez bibliotekę użytkownika lub operatora (który może być nieobecny) o pomoc jest niedopuszczalne. Dobra biblioteka tak nie robi. Jeśli trzeba już poinformować użytkownika, to procedura obsługi wyjątków może utworzyć odpowiednią wiadomość (np. po polsku dla użytkowników z Polski albo w formacie XML dla systemu rejestrującego informacje o błędach). Wyjątki są mechanizmem pozwalającym wykrywać problemy w miejscu, w którym nie można ich obsłużyć, i przekazywać je dalej do kodu, który może być w stanie coś zaradzić. Tylko ta część systemu, która
ma jakieś informacje o kontekście działania programu, ma szansę sporządzić jakąś sensowną wiadomość.
Należy również pamiętać, że obsługa błędów zawsze będzie trudna, a mechanizm obsługi wyjątków — mimo że bardziej sformalizowany od technik, które zastępuje — także ma słabą strukturę w porównaniu z elementami języka dotyczącymi lokalnej kontroli sterowania. Mechanizm obsługi wyjątków języka C++ pozwala obsługiwać błędy w najlepszym do tego miejscu, jeśli chodzi o strukturę systemu. Dzięki wyjątkom uwidoczniona jest złożoność zadania, jakim jest obsługa błędów. Mimo to wyjątki nie są powodem tej złożoności. Nie należy winić gońca za złe wiadomości.
Kiedy nie można używać wyjątków
Wyjątki są jedynym systematycznym i w pełni ogólnym mechanizmem obsługi błędów w języku C++. Trzeba jednak przyznać — acz niechętnie — że istnieją programy, które z różnych powodów, czy to praktycznych, czy historycznych, nie wykorzystują wyjątków. Na przykład:
- Składnik systemu wbudowanego, który musi zakończyć swoje działanie w ściśle określonym czasie. Jeśli nie są dostępne narzędzia pozwalające precyzyjnie oszacować maksymalną ilość czasu potrzebną na propagację wyjątku z klauzuli throw do catch, to jedynym wyjściem jest skorzystanie z metod obsługi błędów.
- Duży stary program, w którym zarządzanie zasobami jest pogrążone w chaosie (np. pamięć wolna jest arbitralnie „zarządzana” przy użyciu „nagich” wskaźników oraz operatorów new i delete), zamiast być wykonywane w systematyczny sposób przy użyciu np. uchwytów do zasobów (np. string i vector).
W takich przypadkach jesteśmy zmuszeni do posługiwania się technikami tradycyjnymi (z czasów przed powstaniem wyjątków). Jako że programy tego rodzaju są osadzone w rozmaitych kontekstach historycznych oraz są obarczone różnymi ograniczeniami, nie mogę przedstawić ogólnych zaleceń, jak sobie z nimi radzić. Mogę jedynie wskazać dwa popularne rozwiązania:
Imitowanie przestrzegania zasady RAII: w każdej klasie zawierającej konstruktor zdefiniuj operację o nazwie invalid() zwracającą jakiś kod błędu, np. często kod 0 oznacza powodzenie. Jeżeli konstruktorowi nie uda się ustanowić niezmiennika klasy, sprawdza
on, czy nie został pozostawiony jakiś wyciek zasobów, a następnie funkcja invalid() zwraca niezerowy kod błędu. W ten sposób rozwiązuje się problem z pobraniem kodu błędu z konstruktora. Następnie użytkownik może systematycznie sprawdzać wartość
zwrotną funkcji invalid() po każdym wywołaniu konstruktora i podejmować odpowiednie środki zaradcze w przypadku awarii. Na przykład:
{
my_vector x(n);
if (x.invalid()) {
//... obsługa błędu...
}
//...
}
Aby imitować funkcję zwracającą wartość albo zgłaszającą wyjątek, funkcja może zwracać obiekt pair. Użytkownik może wówczas systematycznie sprawdzać kod błędu po każdym wywołaniu funkcji i włączać odpowiednie procedury w razie wystąpienia awarii. Na przykład:
{
auto v = make_vector(n); // zwraca parę
if (v.second) {
//... obsługa błędu...
}
auto val = v.first;
//...
}
Stosuje się z powodzeniem różne wariacje tej techniki, ale żadna z nich nie jest tak elegancka jak systematyczne wykorzystanie wyjątków.
Hierarchiczna obsługa błędów
Mechanizmy obsługi wyjątków mają za zadanie umożliwić informowanie przez jedną część programu innej części programu o tym, że nie można wykonać określonych działań (że wystąpiła wyjątkowa sytuacja). Zakłada się, że części te są niezależne od siebie oraz że część obsługująca wyjątek może zrobić z nim coś sensownego.
Aby obsługa błędów była skuteczna, potrzebna jest ogólna strategia. To znaczy różne części programu muszą zgadzać się co do sposobu wykorzystania wyjątków oraz miejsca obsługi błędów. Mechanizmy obsługi wyjątków są z zasady nielokalne i dlatego ścisłe trzymanie się ogólnej strategii jest nieodzowne. Z tego wynika, że strategię obsługi błędów należy opracować już na samym początku projektowania. Poza tym strategia ta musi być prosta (w porównaniu ze złożonością programu) i klarowna. Żadne skomplikowane zasady nie byłyby ściśle przestrzegane w tak trudnej dziedzinie, jaką jest obsługa błędów.
Systemy skutecznie radzące sobie z usterkami są wielopoziomowe. Każdy poziom rozwiązuje tyle problemów, ile może, bez zbytniego napinania się, a pozostałe oddelegowuje do wyższych poziomów. Wyjątki pomagają stosować tę zasadę. Ponadto istnieje też funkcja terminate(), która stanowi wyjście awaryjne w sytuacji, gdy mechanizm obsługi wyjątków sam jest uszkodzony
albo niekompletny i nie przechwytuje wszystkich wyjątków. Analogicznie specyfikator noexcept pozwala w łatwy sposób uniknąć błędów, w przypadku których odzyskanie sprawności programu wydaje się niemożliwe.
Nie każda funkcja musi być absolutnie niezawodna, tzn. nie każda może sprawdzać warunki wstępne tak skrupulatnie, żeby nie było możliwości, aby nie został spełniony warunek końcowy. Wszystko zależy od programu i programisty. Chociaż w dużych programach:
1. Ilość pracy potrzebna do zapewnienia opisywanej „niezawodności” jest zbyt duża, aby to robić w sposób spójny.
2. Narzut czasowy i przestrzenny jest zbyt duży, aby system mógł działać w akceptowalny sposób (niektóre błędy, takie jak niepoprawne argumenty, mogą być sprawdzane wielokrotnie).
3. Funkcje napisane w innych językach programowania nie będą przestrzegać naszych zasad.
4. Takie czysto lokalne pojęcie niezawodności prowadzi do komplikacji, które stają się obciążeniem dla niezawodności całego systemu.
Mimo wszystko podział programu na podsystemy, które wykonują swoje działania z powodzeniem albo ulegają awarii w dokładnie zdefiniowany sposób, jest niezbędny, wykonalny i wart zachodu. Dlatego też w ten sposób powinny być zaprojektowane wszystkie większe biblioteki, podsystemy i kluczowe funkcje interfejsów. Ponadto w większości systemów można każdą funkcję zaprojektować tak, aby wykonywała swoje zadanie z powodzeniem albo ulegała awarii w ściśle określony sposób.
W większości przypadków nie mamy komfortu zaprojektowania całego systemu od początku. W związku z tym opracowując ogólną strategię obsługi błędów dla wszystkich części programu, musimy wziąć pod uwagę części, które zostały zaimplementowane wg innych strategii. W tym celu musimy rozwiązać szereg kwestii związanych ze sposobem zarządzania zasobami przez fragment programu oraz ze stanem, w jakim pozostawia on system po wystąpieniu błędu. Chodzi o to, by składnik programu sprawiał wrażenie, jakby działał zgodnie z ogólnie przyjętą strategią obsługi błędów, mimo że wewnętrznie działa wg innych reguł. Od czasu do czasu konieczna jest zmiana jednego stylu powiadamiania o błędach na inny. Na przykład możemy sprawdzać wartość zmiennej errno i zgłaszać wyjątek po wywołaniu funkcji z biblioteki C albo przechwytywać wyjątek i ustawiać wartość zmiennej errno przed zwróceniem wartości do programu w C z biblioteki w C++:
{
errno = 0;
c_function();
if (errno) {
//... lokalne porządki w razie potrzeby i możliwości...
throw C_blewit(errno);
}
}
extern "C" void call_from_C() noexcept // wywołanie funkcji C++ z C; zamiana throw na errno
{
try {
c_plus_plus_function();
}
catch (...) {
//... lokalne porządki w razie potrzeby i możliwości...
errno = E_CPLPLFCTBLEWIT;
}
}
W takich przypadkach należy być na tyle systematycznym, aby zamiana stylów raportowania błędów była kompletna. Niestety takie zamiany są najczęściej potrzebne w chaotycznym kodzie, pozbawionym wyraźnej strategii obsługi błędów, a więc takim, w którym trudno stosować systematyczne rozwiązania.
Obsługa błędów powinna być — w miarę możliwości — hierarchiczna. Gdy funkcja wykryje błąd czasu działania, to nie powinna prosić wywołującego o pomoc w odzyskaniu sprawności czy zajęciu zasobów. Żądania takie tworzą cykle w zależnościach systemowych. To z kolei sprawia, że program jest trudny do zrozumienia oraz stwarza ryzyko powstania nieskończonych
pętli w kodzie obsługującym błędy.
Język C++. Kompendium wiedzy. Wydanie IV, Autor: Bjarne Stroustrup, Wydawnictwo: Helion