C++ bez cholesterolu: Zaawansowane programowanie w C++: Wyjątki
3.4 Wyjątki
"... doświadczenie związane z jej [obsługi sytuacji wyjątkowych]
realizacją w języku C++ nie było wielkie (...). To było niepokojące, choć
wokół panowało zgodne przekonanie, że jakaś odpowiednia postać obsługi
sytuacji wyjątkowych w języku C++ to dobry pomysł (...). Z tej ogólnej zasady
wyraźnie wyłamywał się Doug McIlroy, który stwierdził, że możliwość
korzystania z obsługi sytuacji wyjątkowych spowoduje, iż system będzie mniej
niezawodny, ponieważ twórcy bibliotek i inni programiści, zamiast starać się
zrozumieć i rozwiązywać problemy, będą zgłaszać sytuacje wyjątkowe. Tylko czas
pokaże, czy przypuszczenie Douga było słuszne."
Bjarne Stroustrup, "Projektowanie i rozwój języka C++"
Wiadomości podstawowe
Hmm... czas pokaże, ale mam nadzieję również, że czytelni(k|czka) sam
oceni, czy takie obawy są uzasadnione. Wyjątki są mechanizmem pozwalającym
obsłużyć różne sytuacje wyjątkowe, tzn. takie, które powinny się zdarzać
relatywnie rzadko. Zazwyczaj jest to obsługa błędu, choć nie oznacza to wcale,
że to miałby być jakiś wyjątkowy błąd - może być to błąd zupełnie spodziewany
i oczekiwany, niemniej jego obsługa wymagałaby różnych przeskoków i kombinacji
(jeśli ktoś się zapoznał z setjmp z rozdziału o bibliotekach C, to powinien
się zorientować, o co chodzi, ale poznanie tych funkcji nie jest konieczne do
poznania wyjątków).
Wyjątki służą głównie do tego, żeby zuniwersalizować system obsługi sytuacji
wyjątkowych (no i przede wszystkim wyeliminować używanie setjmp/longjmp).
Problemy z obsługą sytuacji wyjątkowych
Spróbujmy rozważyć najpierw kilka standardowych przypadków wystąpienia w
programie sytuacji wyjątkowej. Jedna z najbardziej denerwujących to
nieprzydzielenie pamięci.
Block* b = malloc( sizeof (Block) );
if ( b == NULL )
//?
Jeśli coś takiego jak przydział pamięci odbywa się często (a zazwyczaj tak
jest), to konia z rzędem temu, komu starczy cierpliwości na obsługę każdego z
nieudanych przydziałów pamięci. A poza tym... załóżmy, że złapaliśmy taką
sytuację, że nie przydzielono pamięci. I co wtedy? Załóżmy, że jesteśmy w
środku programu, zrobilismy mnóstwo obiektów, wszystko ze sobą współpracuje,
chcieliśmy odrobinę dodatkowej pamięci na wykonanie jakiejś małej operacji a
tu - CIACH! Nie ma pamięci... Stwierdziliśmy fakt i... co właściwie możemy
zrobić? Zgłosić buga? A jeśli bez tego obiektu program nie będzie się
kwalifikował do dalszego wykonywania? A jeśli użytkownik z powodu wyłożenia
się programu straci ważne dane?
Ja osobiście uważam (i prawdopodobnie twórcy C++ zgadzają się ze mną), że błąd
przydziału pamięci jest takiej samej rangi błędem, co błędne odwołanie się do
pamięci. Dlatego do jego obsługi osobiście na UNIXie proponowałbym sygnały,
aczkolwiek zazwyczaj trudno jest powiedzieć, co właściwie można zrobić kiedy
nie ma pamięci (często to w praktyce oznacza, że nie ma nawet z czego wykroić
kawałka bufora na komunikat o błędzie).
Ale to jeszcze nic. W tamtym przypadku najczęściej robi się printf(
"Error: memory exhaused\n" ); abort(); i koniec (doprowadzając
użytkownika - w zależności od jego charakteru - do zdumienia lub furii).
Załóżmy jednak (ogólno-teoretycznie), że podczas pozyskiwania zasobów, w
którym każdy zasób przydzielony w następnej kolejności korzysta z zasobów
uzyskanych wcześniej (w uproszczeniu przyjmijmy, że ze wszystkich). I nagle
jednego z zasobów nie da się uzyskać. I co? Zwolnić wszystkie zasoby? A co z
zasobami, które nie zostały przydzielone? Kod, który uwzględnia wszystkie
"wcześniej przydzielone" zasoby wygląda mniej więcej tak:
if ( !Pozyskaj( 1 ) )
exit( 1 );
if ( !Pozyskaj( 2 ) )
{
Zwolnij( 1 );
exit( 1 );
}
if ( !Pozyskaj( 3 ) )
{
Zwolnij( 2 );
Zwolnij( 1 );
exit( 1 );
}
i tak dalej; komentarz chyba jest zbędny... Zresztą to jest właśnie jeden z
dowodów bzdurności twierdzenia, jakoby programy w C mogły być bardzo szybkie -
jasne, pewnie programiści myślą, że takie sprawdzanie warunków na okrągło nic
nie kosztuje (a wielu z nich nawet pielęgnuje pogląd, że wykonanie instrukcji
switch/case jest stałego czasu -- w rzeczywistości najlepsze, co się
kompilatorowi zazwyczaj udaje osiągnąć to wyszukiwanie binarne).
Jest jeszcze inna rzecz. Wyobraźmy sobie funkcję, która ma zwrócić jakąś
wartość, a jej wywołanie może się również nie powieść, ale każda zwracana
wartość jest prawidłowa (jak choćby różne matematyczne funkcje z ograniczoną
dziedziną np. log, czy sqrt). Matematyczne funkcje pod systemami UNIXowymi
często kończą się wywołaniem sygnału, ale użytkownik nie za bardzo może sobie
na to pozwolić. Jedyna możliwość w takim wypadku to zwrócić uniwersalną
wartość (jak np. często stosuje się zwrócenie maksymalnej wartości danego
zakresu liczbowego w przypadku dzielenia przez zero), ale nie ma wtedy
możliwości powiadomienia o błędzie.
Ale to jest wszystko jeszcze nic. Wyobraźmy sobie, że wywołanie funkcji
przebiega wedle takiej sekwencji:
Runaway -> Jump -> Caught -> Flash
I załóżmy, że w funkcji Flash wystąpiła sytuacja uniemożliwiająca dalsze
wykonywanie funkcji Runaway! Klasyczne rozwiązanie polega na tym, że Flash
zwraca niepowodzenie, na co Caught zwraca niepowodzenie, na co Jump zwraca
niepowodzenie... a to jest i tak jeszcze jedna z mniej skomplikowanych
sekwencji!
Teoria wyjątków
Wyjątek jest to po prostu obiekt i wcale nie musi być jakiegoś ściśle
określonego typu. Jednak shierarchizowanie klas, które będą służyć do rzucania
wyjątkami jest jak najbardziej pożądane (i oczywiście takowe istnieją, proszę
sobie obejrzeć pliki nagłówkowe stdexcept i exception). Zaczyna się od tego,
że pewien fragment kodu PRÓBUJE się (ang. try) wykonać. Tam, w razie
wystąpienia sytuacji wyjątkowej, RZUCA się (ang. throw) wyjątek, który
następnie powinien zostać PRZECHWYCONY (ang. catch). Wygląda to w następujący
sposób:
try {
// tu kod wrażliwy na wyjątek
} catch ( <k1> ) {
// tu obsługa przechwycenia wyjątku klasy <k1>
} catch ( <k2> ) {
// tu obsługa przechwycenia wyjątku klasy <k2>
} catch ( ... ) {
// tu obsługa jakiegokolwiek wyjątku
}
Oczywiście wyrażenie `catch ( ... )' musi wystąpić dopiero na końcu listy,
gdyż ta klauzula obsługuje cokolwiek jej w ręce wpadnie (choć od razu
wypadnie: nie ma możliwości żadnego dostępu do obiektu wyjątku w takiej
klauzuli). Co prawda jako argument klauzuli catch wpisałem sam typ, aczkolwiek
jest to normalny argument, taki sam, jak argument funkcji. Klauzula catch jest
tutaj jakby przeciążoną funkcją, gdyż obsługa jest wybierana na podstawie
klasy wyjątku. Jednak nie działają tutaj żadne reguły rozstrzygania
niejednoznaczności; tutaj panuje zasada "kto pierwszy ten lepszy".
Zatem z reguły najpierw należy umieszczać klasy jak najdalej pochodne, później
zaś dopiero klasy bardziej pierwotne.
Rzucanie wyjątkiem
Wyjątkiem rzuca się przy pomocy instrukcji throw. Najczęściej wyjątek tworzy
się jako obiekt tymczasowy, bo tak jest najwygodniej. Mamy zresztą do
dyspozycji mnóstwo wyjątków standardowych (we wspomnianych plikach
nagłówkowych). Zatem możemy sobie od niechcenia rzucić wyjątkiem (zawsze to
lepiej, niż mięsem, jednak skutki są o wiele bardziej tragiczne :*):
throw exception(); // exception() to konstruktor; tworzy obiekt tymczasowy
Instrukcja throw jako argumentu potrzebuje obiektu; w tym wypadku jest to
obiekt tymczasowy. Obiekt ten będzie nam istniał aż do zakończenia
odpowiedniej dla niego klauzuli catch (jeśli taka zostanie znaleziona, bo
jeśli nie, to będzie bardzo źle...) i w końcu o to przecież nam chodzi. Jest
to jedyna słuszna klasa dla obiektów wyjątków. Co oznacza w praktyce, że na
upartego można stosować inną, ale jest to tylko utrudnianie sobie życia.
Propagacja wyjątku
Rzucony w ten sposób wyjątek - co jest dla programisty chyba najważniejsze -
zwija stos (ang. unwinds a stack) bieżącego zasięgu (tzn. niszczy wszystkie
zmienne lokalne, włącznie z wywołaniem ich destruktorów) i próbuje skoczyć do
klauzuli catch najbliższej, która go obsłuży. Jeśli takiej nie ma w danej
funkcji, wyjątek się nie ceregieli i zwija stos bieżącej funkcji po czem
wykona skok do odpowiedniej dla siebie klauzuli catch w funkcji, z której ta
bieżąca została wywołana. W efekcie, rzucony nie obsłużony wyjątek przelatuje
po kolei wszystkim funkcjom przed oczami, przy okazji zwijając je, aż natrafi
na taką, która go obsłuży (ten właśnie sposób postępowania wyjątków nazywa się
semantyką zakończenia). Stos funkcji możnaby sobie wyobrazić jak drzewo,
którego korzeń (ang. root!) to funkcja main. Zatem rzucony wyjątek likwiduje
wszelkie funkcje wraz z ich zawartością (ale likwiduje uczciwie, w odróżnieniu
od takiej np. funkcji exit() !) i tak jedzie z tym w dół (jak czołg) aż dotrze
do funkcji, która go obsłuży. Jeśli dotrze do funkcji main, a ta również nie
zechce go obsłużyć, wyjątek również bez zbędnych ceregieli zwinie stos main()
i zmienne globalne, a następnie wywoła specjalną funkcję, zwaną terminate().
Jej domyślna akcja polega na wywołaniu funkcji abort(). Można oczywiście
ustawić własną terminate() przy pomocy funkcji set_terminate() (funkcja ta
jest zadeklarowana w nagłówku exception).
Tu mała uwaga - pisałem wcześniej, że funkcji abort i exit nie należy w C++
używać. To jest właśnie jeden wyjątek. Jak najbardziej NALEŻY tych funkcji
używać w funkcjach rejestrowanych jako terminate. Jest to w tym wypadku
całkowicie bezpieczne, gdyż wszelkie destruktory już zostały wykonane. Z
wyjątkiem oczywiście destruktorów zmiennych lokalnych tej funkcji, ale z tym
to chyba można sobie jeszcze poradzić (w razie konieczności można użyć funkcji
destroy z STL). Jednak funkcja exit() jest w takiej sytuacji ABSOLUTNIE JEDYNĄ
możliwością zwrócenia kodu powrotnego programu, gdyż ta funkcja nie pozwala
już o niczym zadecydować i jest wykonywana już PO ZWINIĘCIU funkcji main i
zmiennych globalnych (a poza exit() tylko return w funkcji main pozwala na
zwrócenie kodu powrotnego programu!).
Nie należy jednak zbytnio się przywiązywać do tych kwestii; wywołanie funkcji
terminate powinno być czymś jeszcze bardziej wyjątkowym, niż same wyjątki!
Przechwytywanie wyjątku
Dla wyjątku jednak lepiej jest, jeśli zostanie przechwycony. Dotarłszy zatem
do odpowiedniej klauzuli catch, wykonuje się procedura jego obsługi. Kiedy ta
procedura się zakończy, wykonuje się kod znajdujący się za obszarem try/catch
dalej jak gdyby nigdy nic.
Oczywiście może się zdarzyć, że wyjątek nie pasuje do żadnej klasy,
występującej w klauzulach catch (catch ( ... ) nie jest obowiązkowy), wtedy
wszystko przebiega tak, jakby w tym miejscu żadnego przechwytywania nie było.
Podobnie, jak wyjątek doradzam przekazywać jako obiekt tymczasowy (choć można
jak się chce), tak przechwytywać dany wyjątek również doradzam przez
referencję (NIE przez wartość!). Powód jest prosty - wewnątrz klauzuli
operujemy w ten sposób bezpośrednio na przekazanym obiekcie, a nie na jego
kopii, co może mieć często dość istotne znaczenie (ogólnie w C++ należy unikać
przekazywania obiektów przez wartość ze względu na czaso- i stosochłonność;
poza tym dobrze jest rzucać wyjątkiem klasy polimorficznej, dzięki czemu można
zbadać jego rzeczywisty typ; łapiąc wyjątek przez wartość pozbawiamy się tej
możliwości).
Oczywiście nie musimy w danej serii klauzuli catch obsługiwać wszystkich
wyjątków. Nawet możemy niektóre obsłużyć tylko częściowo. Można np. w danej
klauzuli catch, dorzucić dodatkowe dane identyfikacyjne do danego wyjątku, po
czem "puścić go dalej":
int Meele()
{
try {
...
} catch ( XKling& x ) {
string s = "Meele:";
s += x.what();
x.Setwhat( s );
throw;
}
}
Składnia `throw;' oznacza, że należy rzucić ten sam wyjątek, który się
przechwyciło. Łatwo się domyślić zatem, że brak wśród klauzul catch wyrażenia
catch ( ... ) oznacza to samo, jakby jego obsługa brzmiała { throw; }.
Ograniczanie wyjątków
Przydatną niekiedy rzeczą (choć osobiście nie udało mi się jeszcze tego
sprawdzić) jest ograniczanie klas, do jakich może należeć wyjątek, jaki
funkcja ma "wypuszczać" (deklaracja taka bowiem dotyczy funkcji),
czyli tzw. filtr na wyjątki. Jeśli chcemy, żeby dana funkcja obsługiwała np.
tylko invalid_argument i out_of_range, deklarujemy ją w następujący sposób:
int Fn( int ) throw ( invalid_argument, out_of_range );
Funkcja o takiej deklaracji może rzucać na zewnątrz wyjątki tylko tych dwóch
zadeklarowanych klas. W wypadku rzucenia wyjątku innej klasy (czyli w tym
wypadku "nieoczekiwanego", ang. unexpected), wywoływana jest funkcja
unexpected(), której domyślną akcją jest wywołanie terminate(). Podobnie jak
terminate(), unexpected() może również zostać ustawiona przez
set_unexpected(). Lista wyjątków może też być oczywiście pusta. Jak należy
domniemać, przed wykonaniem unexpected() wykonuje się wszystko to, co i
normalnie poprzedza wywołanie terminate()!
Zaznaczam jednak uczciwie, że ograniczanie wyjątków, jest jednym z
"samobójczych" narzędzi. Takim samym "samobójczym"
narzędziem jest oczywiście terminate. Jednak o ile terminate jest standardową
reakcją na błąd programisty, o tyle unexpected jest jeszcze dodatkowo przez
programistę programowalną. Jest to bardzo "Smalltalkowe" rozwiązanie,
gdyż stanowi, że błąd programistyczny jest wykrywany przez wykonywany program
(a nie przez kompilator). Tu wystarczy, że jakaś funkcja wywołana spod
ograniczonej-na-wyjątki funkcji rzuci jednym z niewymienionych wyjątków i
program idzie w buraki. Kompilator jest w stanie zareagować wcześniej tylko pod
warunkiem, że zna kod wszystkich funkcji, jakie są spod tej
ograniczonej-na-wyjątki wywoływane. Nawet zaś gdyby potrafił to program
wiążący, to też nie wyłapie wszystkich tego typu błędów; np. nie dowie się tego
przy wywoływaniu funkcji przez wskaźnik, czyli de facto również wywołania metod
wirtualnych, a wskaźnik do funkcji z kolei można pobrać np. przez dlsym (temu
jednak z kolei mozna zaradzic, ustanawiajac ograniczanie na wyjatki dla
wskaznika do funkcji; tylko niektore kompilatory maja problem z implementacja
tego featuresa). Inna byłaby sprawa, gdyby takie ograniczanie wyjątków nakładać
na KAŻDĄ bez wyjątku funkcję. Ale komu chciałoby się w to bawić...
Zwalnianie zasobów
Pewnie wielu mi zarzuci, że przedstawiłem problem (z tym pozyskiwaniem
zasobów), przedstawiłem narzędzia, ale nie przedstawiłem, jak ten problem
rozwiązać - z przedstawionych informacji żadne sensowne rozwiązanie nie
wynika. Ale ja mówiłem o nim na samym początku - wyjątek zwija stos i wywołuje
DESTRUKTORY zwijanych obiektów. Zatem cały "power" wyjątków wcale
nie leży w samych wyjątkach, ale w destruktorach. Mało kto jednak tą kwestię
dobrze rozumie. Z tego właśnie powodu w wielu językach, które zerżnęły wyjątki
z C++ (np. Java), a nawet w niektórych dialektach C++ (zwykle Microsofta, czy
Borlanda) dodaje się jeszcze do serii catch taką klauzulę w stylu
`finally'. Wygląda to mniej więcej tak:
try {
// probably throw
} catch ( e ) {
// repair situation, probably exit
} finally {
// do the final things
}
Klauzula `finally' ma zawrzeć to, co jest konieczne do wykonania czynności
końcowych (zanim nastąpi wyjście z funkcji, które jest zawarte w klauzuli
obsługi wyjątku ę'). W C++ niczego takiego nie ma. Czy to jest błąd twórców
C++? Nie, przeciwnie, to `finally' jest idiotyzmem. Tzn. w języku takim jak
Java jest to po prostu konieczna łata na dziurę, jakich w Javie jest pełno (tu
jest to łata na brak destruktora; podobnie interfejs w Javie jest łatą na brak
wielorakiego dziedziczenia).
Przedstawię tutaj tylko dwie sytuacje, ale chyba dość typowe.
int Fn()
{
try {
ifstream in( "plik" );
FreadX( in ); // może rzucić wyjątkiem
FreadY( in ); // też może rzucić wyjątkiem
ofstream out( "plik.1" );
FwriteX( out ); // też może rzucić wyjątkiem
FwriteY( out ); // też może rzucić wyjątkiem
} catch ( exception& e ) {
cout << "Error: " << e.what() << endl;
}
return 0;
}
Załóżmy, że FwriteY rzuci wyjątkiem. Co wtedy? Klauzula `catch' nie może
zawierać w sobie zamknięcia pliku `out', bo gdyby wyjątek wystąpił np. w
FreadX to w ogóle tu nie ma czego zamykać. Tutaj jednak wszystko jest w
porządku, bowiem destruktory ifstream i ofstream normalnie te pliki pozamykają
(tylko te oczywiście, które w ogóle były otwierane) i to niezależnie od tego,
czy funkcja skończy się normalnie, czy przez wyjątek. Ale co zrobić, jeśli
mamy obiekty dynamiczne, które przecież trzeba zwalniać ręcznie? Na to też
jest rada:
int Fn()
{
try {
ifstream in( "dane" );
auto_ptr<Klocek> pk( new Klocek() );
pk->x = FreadX( in );
pk->y = FreadY( in );
// Ok, minęliśmy krytyczną sekcję
DodajDoBazy( pk );
pk.release();
} catch ( exception& e ) {
cout << "Error: " << e.what() << endl;
}
}
Jest tutaj zastosowana pewna sztuczka ze standardowym typem wzorcowym (o
wzorcach później) auto_ptr. Typ ten opakowuje wskaźnik (do podanego typu),
zatem do funkcji DodajDoBazy przekazywany jest obiekt jako `Klocek*'.
Funkcje FreadX i FreadY mogą rzucić wyjątkiem. Jeśli się to stanie, zanim
zostanie wywołane `pk.release()', obiekt, na który wskazuje pk zostanie
zwolniony przez destruktor wskaźnika pk. Właśnie od tego jest auto_ptr, żeby
tymczasowo obiektowi dynamicznemu nadał właściwości zmiennej lokalnej. Dopiero
kiedy minie się sekcję krytyczną, auto_ptr zrzeka się kontroli nad obiektem
przez wywołanie metody release(). Tu ważna uwaga! Zgodnie z obowiązującym
standardem, metoda release() ZERUJE jednocześnie wskaźnik pk, należy go więc
gdzieś przechować ZANIM się tą metodę wywoła.
Przy okazji - auto_ptr stanowi też dobre opakowanie dla wszelkich typów C
tworzących głównie obiekty dynamiczne, np. typu FILE. Wzorce (które niedługo
poznamy) mają to do siebie, że dowolne ich części można zmieniać jak się chce.
Zatem dla auto_ptr<FILE> można sobie zdefiniować własny destruktor,
który wykona sobie fclose - to jest najprostsze rozwiązanie. Można też
oczywiście zdefiniować własną strukturę opakowującą. Jak widać więc, w C++
trzeba tylko dobrze umieć wykorzystać destruktory, a żadne `finally' nie
jest tu potrzebne.
Podsumowanie wyjątków
Osobiście zachęcam do eksperymentowania z wyjątkami, aczkolwiek - jak we
wszystkich zaawansowanych właściwościach - zalecam umiar i rozsądek. Uzyskałem
swego czasu nawet pozytywne rezultaty tłumaczenia sygnałów na wyjątki (tzn.
jedna funkcja obsługiwała wszystkie sygnały, ale w zależności od numeru
rzucała odpowiednim wyjątkiem), choć Bjarne Stroustrup niezbyt przychylnie się
odnosi do tego pomysłu. Tu kwestia jest zresztą o tyle ciekawa, że o ile
sygnały nie mają ustalonej semantyki (da się więc zaimplementować semantykę
wznowienia), o tyle wyjątki w C++ wspierają wyłacznie semantykę zakończenia
(kwestia braku tej semantyki nie jest jednak w sygnałach niczym sensownym: w
języku C obsługę przyjęcia sygnału należy wykonać przez longjmp, gdyż dany
sygnał zazwyczaj i tak dyskwalifikuje bieżący kontekst z wykonywania).
Nie zalecam oczywiście stosowania wyjątków do przekazywania danych, choć sam
stosowałem je jako normalny element programu, obsługujący sytuację choć
wyjątkową, to jednak w stu procentach oczekiwaną. Nie należy obawiać się ich
używania, zwłaszcza że w wielu zastosowaniach będą nieocenione.
Wyszukiwarka
Podobne podstrony:
ExceptionDetailMessagermi exceptions5function java last exception clearexceptionsrmi exceptions7exceptionsrmi exceptions2java lang Exceptionfunction satellite caught exceptionles08 except showmeexceptionsjava lab09 exceptionExceptionDetailMessageEXCEPT1 id 2053264 Nieznanyexceptions docExceptionListenerwięcej podobnych podstron