[STM32] Wyjątki C++ na przykładzie systemu ISIX-RTOS
Do prawidłowego działania programu konieczna jest poprawna inicjalizacja wszystkich zasobów, bez których nie może działać poprawnie. W przypadku, gdy nie uda nam się prawidłowo zainicjalizować, jednego z zasobów musimy pamiętać aby zwolnić wcześniejsze zasoby które zostały prawidłowo przydzielone. Widzimy również cała masę sprawdzeń warunków, w każdym etapie wywołania, które też zajmują nie mało czasu procesora, jeżeli spojrzymy na to, że muszą być wykonywane wielokrotnie, praktycznie na każdym etapie wywołania. W tak zawiłym kodzie istnieje tutaj również wiele możliwości popełnienia błędu. Wystarczy jednokrotne pominięcie sprawdzenia warunku, w dowolnym miejscu i cała logika obsługi błędów przestaje funkcjonować prawidłowo. Jest to jeden z klasycznych przykładów obalających teorie zwolenników C twierdzących, że programy w C wykonują się bardzo szybko i są proste i czytelne.
Obsługa sytuacji wyjątkowych w C++
Język C++ posiada dedykowany mechanizm obsługi wyjątków stanowiący integralną część języka. Jest on pozbawiony wcześniej wspomnianych wad ręcznej obsługi sytuacji wyjątkowych. Do największych zalet należy tutaj brak konieczności ciągłego sprawdzania rezultatów, brak możliwości zignorowania zgłoszonego wyjątku ( poprzez nie sprawdzenie zwróconego kodu błędu), oraz co najważniejsze automatyczne zwalnianie zasobów przez destruktory obiektów w momencie propagacji wyjątku. Wszystkie te czynniki powodują, że kod staje się znacznie bardziej czytelny, oraz bardziej odporny na błędy. Wyjątek stanowi obiekt, który może dowolnego typu a w szczególności może być typem prostym POD jak typ int. Możemy również zbudować hierarchię klas wyjątków, czy skorzystać ze standardowej hierarchii klas wyjątków zawartych w pliku nagłówkowym <exception> oraz <stdexcept>. Wyjątki w C++, obsługiwane są przez słowa kluczowe: try, catch, throw. Mechanizm ich działania polega na tym, że kod w przypadku wystąpienia błędu zamiast zwracać rezultat funkcji informujący o błędzie rzuca wyjątek za pomocą wywołania throw exception(), gdzie exception() jest to klasa wyjątku, który będzie rzucony (najwygodniej jest w tym miejscu używać obiektów tymczasowych). Natomiast w innej części programu fragment, który może potencjalnie rzucić wyjątek zostaje umieszczony w sekcji try, po której następują klauzule przechwytywania poszczególnych klas wyjątków. Ostatnia klauzula catch( … ) powoduje przechwycenie wszystkich pozostałych wyjątków, ale bez możliwości dostępu do obiektu wyjątku:
try { //Kod ktory moze zglosic wyjatek init_all() } catch( exception1 &e1 ) { //obsluga wyjatku klasy exception1 } catch( exception2 &e2) { //Obsluga wyjatku klasy exception2 } catch( ... ) { //Obsluga jakiego kolwiek wyjatku }
Działanie mechanizmu wyjątków polega na tym że, rzucony wyjątek zwija stos bieżącej funkcji, niszcząc wszystkie zmienne lokalne, włącznie z wywołaniem ich destruktorów jeżeli zmienną lokalną stanowi obiekt klasy a nie typ prosty (np. int), a następnie próbuje skoczyć do najbliższej klauzuli catch, która go obsłuży. Jeżeli w danej funkcji nie występuje klauzula catch, zwijany jest stos kolejnej funkcji która ją wywołała. Dzieję się tak aż do napotkania klauzuli catch, w kolejnej funkcji wywołującej. Jeżeli stanie się tak, iż zwijanie wyjątku dojdzie do funkcji main(), a funkcja ta również nie będzie zawierała klauzuli catch() odpowiadającej danemu wyjątkowi, nastąpi sytuacja którą nazywamy nie przechwyconym wyjątkiem. Niewyłapany wyjątek powoduje natychmiastowe wywołanie funkcji terminate(), która zwyczajowo po wypisaniu na standardowym wyjściu rodzaju wyjątku powoduje oddanie kontroli systemowi operacyjnemu, który kończy program. W przypadku znalezienia odpowiedniej klauzuli catch() dla danego wyjątku, wykonuje się procedura jego obsługi, a po zakończeniu wykonywania kodu klauzuli catch, następuje dalsze wykonanie programu, które jest kontynuowane tuż za sekcjami catch, tak jak gdyby nigdy nic się nie wydarzyło. Naturalnie nie musimy w danej sekcji catch obsługiwać wszystkich rodzajów wyjątków, część z nich może być obsłużona w jednej funkcji, natomiast część w zupełnie innej. Dzięki temu, że w momencie wywołania wyjątku zwijany jest stos, oraz wywoływane są destruktory obiektów lokalnych, budując odpowiednio obiekty klas nie będzie potrzeby ręcznego zarządzania zasobami jak w przypadku języka C, a wszystko będzie odbywać się automatycznie. Spójrzmy jeszcze raz na przykład alokacji pamięci, gdzie w przypadku języka C trzeba było zarówno sprawdzić poprawność przydziału pamięci przez malloc(), jak i zadbać o odpowiednią inicjalizację struktury. W przypadku C++ do alokacji pamięci wykorzystujemy operator new, gdzie przykładowa alokacja z poprzedniego przykładu może wyglądać tak:
MyObject *my = new MyObject;
porównując to kodem z początku poprzedniego punktu możemy zauważyć, iż jest dużo prostszy i bardziej czytelny. Jeżeli objekt MyObject, będzie klasą posiadającą konstruktor, zostanie on automatycznie wywołany, co spowoduje wykonanie czynności początkowych zdefiniowanych w konstruktorze. Również standardowy operator new nie zwraca NULL a zgłasza wyjątek std::bad_alloc, którego nie musimy przechwytywać osobno w każdej funkcji. Wystarczy jedynie użycie klauzuli catch(std::exception &e) w funkcji main(), co powoduje znaczne zwiększenie czytelności kodu oraz redukuje konieczność ciągłego sprawdzania rezultatów funkcji, na każdym etapie wywołania. Należy tutaj przestrzec początkujących programistów przed nadmiernym używaniem systemu wyjątków i wykorzystaniem go nie do zgłaszania sytuacji nadzwyczajnych (wystąpienie błędu), a jako systemu przekazywania wartości. Zgłaszanie wyjątków jest stosunkowo czasochłonnym procesem z uwagi na wykonywanie czynności związanych ze zmianą przebiegu wykonania programu. Jednak umiejętne ich używanie, powoduje wzrost ogólnej wydajności programu, ponieważ podczas „prawidłowego” (bezbłędnego), przebiegu nie musimy wykonywać ciągłych i wielokrotnych sprawdzeń wartości.
W języku C++ istnieje kilka predefiniowanych klas wyjątków tak jak wspomniany wcześniej std::bad_alloc, które mogą być zgłaszane przez bibliotekę standardową. Klasą bazową dla wszystkich wyjątków jest tutaj klasa std::exception, której deklaracja wygląda następująco:
class exception { public: exception () throw(); exception (const exception&) throw(); exception& operator= (const exception&) throw(); virtual ~exception() throw(); virtual const char* what() const throw(); }
Najistotniejsza jest tutaj metoda wirtualna what(), która zwraca łańcuch tekstowy zawierający opis błędu. Wszystkie klasy wyjątków rzucane przez bibliotekę standardową jako klasę bazową wykorzystują exception, a zatem klauzula catch( exception &e) powoduje przechwycenie wszystkich wyjątków, klas pochodnych które dziedziczą z tej klasy. Pozostałe wyjątki które mogą być zgłaszane przez bibliotekę standardową C++, podzielono na błędy logiczne, wywodzące się z klasy bazowej logic_error (tabela 1), oraz błędy wykonania wywodzące się z klasy runtime_error (tabela 2).