exceptions


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:
ExceptionDetailMessage
rmi exceptions5
function java last exception clear
exceptions
rmi exceptions7
exceptions
rmi exceptions2
java lang Exception
function satellite caught exception
les08 except showme
exceptions
java lab09 exception
ExceptionDetailMessage
EXCEPT1 id 2053264 Nieznany
exceptions doc
ExceptionListener

więcej podobnych podstron