![]() |
|
Programowanie:
Artykuły
FAQ
Download
Książki
WWW:
Artykuły
Narzędzia
Kursy
Darmowe
FAQ
Skrypty
Humor
Inne:
Forum
Wiki
Liczniki
Linki
Chat
Grafika
Video
Inne
|
Problemy z obsługą błędów w kodzie C++WstępArtykuł ten przeznaczony jest dla osób zainteresowanych programowaniem. Jego poziom oceniam na średnio zaawansowany. Do jego zrozumienia potrzebna jest umiejętność programowania i znajomość języka C++ łącznie z programowaniem obiektowym. Przedstawiam w nim moje przemyślenia na temat obsługi błędów w kodzie programu. Problem jest wbrew pozorom nie jest łatwy do dobrego i jednocześnie eleganckiego rozwiązania. Opisuję tu mój tok myślenia, do którego doprowadziła mnie wieloletnia praktyka w programowaniu oraz moje obecne poglądy na ten temat. Z góry uprzedzam jednak, że wciąż nie znalazłem satysfakcjonującego rozwiązania.Błędy Błąd możnaby zdefiniować jako sytuację, w której program nie działa albo działa źle - to znaczy na przykład niezgodnie z wymaganiami, z bardziej lub mnie oczywistymi oczekiwaniami, planami, projektami i założeniami programisty albo projektu bądź też niezgodnie z intuicją czy ogólnie przyjętymi standardami. Być może lepiej powiedzieć, że błąd to miejsce w kodzie, które stanowi przyczynę takiej sytuacji. Wstęp BłędyMetody obsługi błędów Brak obsługi błędów Prosta obsługa błędów Zaawansowana obsługa błędówInne zagadnienia Łączenie z innymi mechanizmami Wydajność Wielowątkowość Transakcyjność Problem finalizacji Usprawnienia składniowe Marnotrawstwo błędów Możliwości rozbudowy Błędy alokacji Kiedy można nie sprawdzać błędów?Podsumowanie
assert, jak w przykładzie:
#include <cassert> int GetArrayElement(int Index) { assert(Index >= 0 && Index < g_ArraySize); return g_Array[Index]; }
Metody obsługi błędówBrak obsługi błędówNajprostszą metodą obsługi błędów jest brak jakiejkolwiek obsługi. To z całą pewnością nie jest dobry pomysł. Rozpatrzmy przykłady: int StrToInt(const std::string &s) { return atoi(s.c_str()); } atoi, która zgodnie z dokumentacją
zaprzestaje parsowania łańcucha po napotkaniu pierwszego nieprawidłowego
znaku.
Można powiedzieć, że nasza funkcja zawsze kończy się powodzeniem - zwraca
pewną wartość liczbową.
Jeśli więc łańcuch zawiera litery albo inne znaki (nie jest prawidłowym zapisem liczby), wartość zwracaną przez funkcję można uznać za niezdefiniowaną. Wyobraźmy sobie teraz co się stanie, jeśli taka wartość zostanie użyta do obliczeń? Oczywiście wyniki tych obliczeń będą bzdurne. Jeszcze gorzej, kiedy z użyciem tej funkcji wczytywana jest z jakiegoś łańcucha na przykład liczba elementów tablicy, a potem następuje wczytywanie zawartości tej tablicy. Game::Game() { m_MonsterTexture = LoadTexture("monster.tga"); m_MonsterWidth = m_MonsterTexture->GetWidth(); m_MonsterHeight = m_MonsterTexture->GetHeight(); } Pomyślmy, co się stanie, jeśli tekstury nie uda się wczytać (np. plik nie istnieje)? To zależy od zachowania funkcji wczytującej w takiej sytuacji. Jeśli niepowodzenie spowoduje, że obiekt tekstury w ogóle nie zostanie utworzony, funkcja LoadTexture może zwrócić wskaźnik pusty lub pewną niezdefiniowaną
wartość. W obydwu przypadkach próba odwołania się pod ten wskaźnik spowoduje
błąd ochrony. Jeśli natomiast obiekt tekstury powstanie, wartości zwracane
przez jego metody z pewnością nie będą poprawne, ponieważ szerokość i wysokość
tekstury nie będzie znana. Mogą one więc zwrócić pewną niedefiniowaną wartość
albo 0. W każdym z tych przypadków mamy do czynienia z błędem, ponieważ
błędem jest w ogóle próba pobierania informacji z tekstury, jeśli nie udało się
jej wczytać.
HWND Wnd = CreateWindow("PROGRAM", "Program 1.0", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, NULL, NULL, a_Instance, 0); ShowWindow(Wnd, a_CmdShow); UpdateWindow(Wnd); MSG Msg; ZeroMemory(&Msg, sizeof(Msg)); while (GetMessage(&Msg, NULL, 0, 0)) { TranslateMessage(&Msg); DispatchMessage(&Msg); } CreateWindow tworzy okno, a więc pewien zasób i zwraca jego uchwyt.
Uchwyt ten jest zachowywany w zmiennej lokalnej i przekazywany dalej
do funkcji służących do operowania na tego typu zasobach, czyli do pokazania okna,
a potem do pętli przetwarzającej komunikaty.
Pomyślmy co się stanie, jeśli utworzenie okna się nie powiedzie (np. procedura komunikatów tego okna w reakcji na komunikat WM_CREATE zwróci wartość -1)?
Wówczas funkcja CreateWindow, zgodnie z jej dokumentacją,
zamiast uchwytu zwraca 0.
Programista natomiast próbuje tutaj dalej wykorzystywać tą wartość jako poprawny
uchwyt i przekazuje ją do kolejnych funkcji Windows API.
Efekt jest w pełni zależny od tego, jak na przekazanie pustego uchwytu zareagują
te funkcje. Czy spowodują błąd ochrony? Czy będą udawały, że pracują poprawnie?
Czy wtedy program zapętli się w nieskończoność?
Jakakolwiek nie byłaby reakcja, używanie niewłaściwego uchwytu to ewidentny błąd.
Sprawdzanie takich szczególnych sytuacji, czyli obsługa błędów, jest często pomijana w kodach edukacyjnych - w różnego rodzaju kursach i artykułach. Przyczyna jest oczywista - w takich kodach chodzi jedynie o pokazanie zasady działania, ułatwienie jej zrozumienia, a brak obsługi błędów czyni kod krótszym i czytelniejszym. W praktyce natomiast należy, moim zdaniem, ZAWSZE stosować obsługę błędów - nawet w bardzo prostych i małych programach. Obsługa błędów powinna polegać na dwóch rzeczach:
Prosta obsługa błędów Wiemy już, że obsługa błędów jest konieczna. Zobaczmy teraz, ponownie na przykładach, jak mogłyby wyglądać proste rozwiązania tej obsługi (które są powszechnie stosowane w bibliotekach) i dlaczego są one niewystarczające. Możemy tak zaprojektować funkcję LoadTexture, że w przypadku
niepowodzenia przy wczytywaniu tekstury nie utworzy ona obiektu, ale zwróci
zamiast wskaźnika na utworzony obiekt pewną wartość specjalną - wskaźnik pusty 0.
Wówczas użycie tej funkcji mogłoby wyglądać tak:
Game::Game() { m_MonsterTexture = LoadTexture("monster.tga"); if (m_MonsterTexture) { m_MonsterWidth = m_MonsterTexture->GetWidth(); m_MonsterHeight = m_MonsterTexture->GetHeight(); } } StrToInt, nie ma żadnej możliwej do zwracania
wartości specjalnej, która mogłaby oznaczać błąd. Każda zwracana wartość może być
poprawna. Dlatego pewnym rozwiązaniem byłoby zwracanie przez funkcję informacji
o powodzeniu (np. jako bool) oraz przekazywanie prawdziwej wartości
wyjściowej przez parametr wskaźnikowy.
bool StrToInt(const std::string &s, int *Output); bool g_Error = false; Game::Game() { m_MonsterTexture = LoadTexture("monster.tga"); if (m_MonsterTexture) { m_MonsterWidth = m_MonsterTexture->GetWidth(); m_MonsterHeight = m_MonsterTexture->GetHeight(); } else g_Error = true; } 1. W najprostszym przypadku, jak w przykładach powyżej, może to być wartość logiczna - typu bool mówiąca o powodzeniu lub niepowodzeniu.
Ma ona tą zaletę, że pewna wartość specjalna (np. wskaźnik pusty czy indeks
tablicy równy -1) może być traktowana jako zgłoszenie takiego właśnie błędu.
Jej wadą natomiast jest brak jakichkolwiek szczegółów o przyczynie i miejscu
wystąpienia błędu.
2. Pewną informację o przyczynie błędu niosłaby wartość liczbowa. Zmienna globalna typu int z kodem błędu mogłaby oznaczać brak
błędu dla wartości równej 0, a dla każdej innej oznaczać określony rodzaj błędu.
Wartość taką możnaby też przetwarzać na komunikat korzystając z pewnej tablicy
odwzorowującej dopuszczalne kody błędów na ich opisy w postaci łańcuchów.
Nie sposób jednak przechować w takim błędzie dodatkowych informacji, np.
nazwy pliku, którego nie udało się wczytać.
3. Wady tej pozbawione jest rozwiązanie, w których zmienna globalna odpowiedzialna za błąd jest typu łańcuchowego (np. std::string).
Wtedy kod zgłaszający błąd może zapisać do niej dowolny komunikat, także dołączając
do niego dodatkowe informacje, jak nazwę wczytywanego pliku czy wiersz i kolumnę
tekstu, w którym wystąpił błąd składni.
To już bardzo dużo, ale wciąż nie wystarcza. Aby pokazać dlaczego, musimy zdać sobie sprawę, że wykonanie kodu ma postać hierarchii, w której jedne funkcje wywołują inne, a te następne itd. Przykładowy ciąg wywołań mógłby wyglądać tak: Game::Game() LoadTexture() LoadFile() fopen fread fclose ParseLoadedData() LoadTexture() LoadFile() fopen fread fclose ParseLoadedData() ... LoadTexture najpierw ładuje do pamięci
zawartość pliku, a następnie przetwarza ją wczytując jako obrazek.
Funkcja wczytująca plik natomiast posługuje się do obsługi plików standardowymi
funkcjami C - fopen itd.
Zastanówmy się teraz, w którym miejscu powinien być formułowany komunikat błędu? Każda funkcja mogłaby wykrywając błąd zastępować go swoim własnym albo mogłaby zostawiać ten oryginalny.
ParseLoadedData, ponieważ wczytane dane binarne nie są
poprawnym obrazkiem (plik graficzny został podmieniony przez złośliwego gracza
na dokument Worda).
4. Potrzeba więc czegoś więcej, niż pojedynczy komunikat z błędem zgłoszonym przez jedną z funkcji w tej hierarchii wywołań. Rozwiązaniem jest odtworzenie stosu wywołań takiego, jak ten: ParseLoadedData() LoadTexture() Game::Game() ... WinMain() 5. Tak oto dochodzimy do sedna sprawy. Satysfakcjonującym rozwiązaniem jest dopiero możliwość budowania stosu komunikatów. Błąd musi więc mieć postać stosu łańcuchów (np. std::stack).
Wtedy każda funkcja stwierdzając zajście błędu dopisuje na szczyt stosu własny
komunikat dołączając do niego wszystkie wymagane informacje (np. nazwę pliku albo
wiersz i kolumnę parsowanego tekstu).
Takie komunikaty odczytywane potem w odwrotnej kolejności (zdejmowane ze stosu) tworzą coś na kształt ciągu przyczynowego, który możnaby przeczytać jako jedno długie zdanie łącząc poszczególnie łańcuchy słowem "bo", np.: Nie można zainicjalizować gry [bo] Nie można wczytać tekstury z pliku: monster.tga [bo] Nie można przetworzyć wczytanych danych jako obrazka Zaawansowana obsługa błędów Pokażę teraz dwie metody implementacji tego najbardziej zaawansowanego podejścia do obsługi błędów. Pierwsza z nich używa zwykłych zmiennych globalnych do przechowywania informacji o błędzie i wymaga każdorazowego sprawdzania warunków. Powstaje kod pełen if-ów - bardzo nieczytelny i nieestetyczny.
Metoda druga używa mechanizmu wyjątków.
Jak się okazuje, wbrew pozorom również sprawia wiele problemów, a pisany z jej
użyciem kod nie jest estetyczny.
Jeszcze do niedawna używałem metody pierwszej. Ostatnio przekonuję się, że lepsza jest jednak druga. Przy ich ocenie biorę pod uwagę głównie przejrzystość kodu i ilość dodatkowego, koniecznego pisania. Z żadnej z nich jednak nie jestem w pełni zadowolony. Przykład obsługi błędów pokażę na dwóch zmyślonych funkcjach, których zadaniem jest odrysowanie ekranu jakiejś gry. Oto ich wersje pozbawione obsługi błędów: void DrawAll() { DrawBackground(); DrawHorizon(); DrawSprites(); DrawParticles(); DrawGUI(); } Każda z wywoływanych funkcji może zwrócić błąd za pomocą implementowanego przez nas mechanizmu, ale w tej wersji funkcja nie sprawdza błędów. Zadaniem będzie takie jej napisanie, aby w przypadku zwrócenia błędu przez którąś z funkcji pokierowała przepływem sterowania tak, by nie zostały wywołane kolejne, a na koniec dopisany został do stosu komunikat o niemożności odrysowania całości ekranu gry. void DrawSprites() { int sprite_drawer_id = CreateSpriteDrawer(); bool *arr = new int[g_Sprites.GetCount()]; g_Sprites.FillArray(arr); VERTEX *v; SpriteDrawerLock(sprite_drawer_id, &v); for (int i = 0, index = 0; i < g_Sprites.GetCount(); i++) { if (arr[i]) { v[index++] = SpriteToIndex(g_Sprites.GetSprite(i)); } } SpriteDrawerUnlock(sprite_drawer_id); SpriteDrawerDraw(sprite_drawer_id); delete [] arr; DeleteSpriteDrawer(sprite_drawer_id); }
Metoda 1 - bez wyjątkówImplementacja biblioteki do obsługi błędów w opisany wyżej sposób, nieużywającej wyjątków C++ mogłaby w skrócie wyglądać tak:#include <string> #include <stack> namespace err { std::stack<std::string> g_Stack; // Zwraca true, jeśli jest błąd bool Is() { return (!g_Stack.empty()); } // Dodaje komunikat o błędzie void Add(const std::string &a_Msg, const std::string &a_File, int a_Line) { if (a_File.empty() && a_Line == 0) g_Stack.push(a_Msg); else if (a_File.empty()) g_Stack.push("[" + IntToStr(a_Line) + "] " + a_Msg); else if (a_Line == 0) g_Stack.push("[" + a_File + "] " + a_Msg); else g_Stack.push("[" + a_File + "," + IntToStr(a_Line) + "] " + a_Msg); } // Czyści błąd void Clear() { while (!g_Stack.empty()) g_Stack.pop(); } // Zdejmuje ze stosu i zwraca pojedynczy komunikat błędu // lub zwraca false, jeśli stos jest już pusty bool Pop(std::string *a_Msg) { if (g_Stack.empty()) return false; else { *a_Msg = g_Stack.top(); g_Stack.pop(); return true; } } // Zwraca wszystkie błędy jako jeden łańcuch i czyści błąd // Zwraca false, jeśli nie było błędu bool PopAll(std::string *a_Messages) { a_Messages->clear(); if (!Is()) return false; else { std::string s; while (Pop(&s)) { if (a_Messages->empty()) (*a_Messages) += '\n'; (*a_Messages) += s; } return true; } } } // namespace err bool.
Jak widać, używam tu zmiennej globalnej.
Do zgłaszania błędu, jak i do dopisywania kolejnych komunikatów służy funkcja
Add.
Na poziomie, na którym błąd zostaje wychwycony, należy zdjąć ze stosu wszystkie
komunikaty funkcją Pop lub PopAll otrzymując tym samym
pełną informację tekstową o błędzie, a jednocześnie czyszcząc stan błędu.
Zaprezentowaną wyżej, przykładową funkcję DrawAll można
przepisać z użyciem tego modułu na trzy różne sposoby.
void DrawAll() { DrawBackground(); if (!err::Is()) { DrawHorizon(); if (!err::Is()) { DrawSprites(); if (!err::Is()) { DrawParticles(); if (!err::Is()) { DrawGUI(); } } } } if (err::Is()) err::Add("Nie można odrysować gry", __FILE__, __LINE__); } if-ów i wcięć,
a taki kod jest bardzo nieczytelny.
Zakładam, że każda z wywoływanych tutaj funkcji zgłasza błąd zapisując do niego komunikat o niepowodzeniu danej operacji, więc ja tutaj na końcu dopisuję tylko w przypadku błędu informację o niepowodzeniu całej operacji wykonywanej przez tą funkcję. Zawsze tak robię w swoich programach. Takie założenie gwarantuje prawidłowe zbudowanie stosu komunikatów. void DrawAll() { DrawBackground(); if (!err::Is()) DrawHorizon(); if (!err::Is()) DrawSprites(); if (!err::Is()) DrawParticles(); if (!err::Is()) DrawGUI(); if (err::Is()) err::Add("Nie można odrysować gry", __FILE__, __LINE__); } void DrawAll() { DrawBackground(); if (err::Is()) goto error; DrawHorizon(); if (err::Is()) goto error; DrawSprites(); if (err::Is()) goto error; DrawParticles(); if (err::Is()) goto error; DrawGUI(); if (err::Is()) goto error; return; error: err::Add("Nie można odrysować gry", __FILE__, __LINE__); } goto.
Status błędu jest sprawdzany po opuszczeniu każdej wywoływanej funkcji i jeśli
błąd jest zgłoszony, następuje skok do miejsca, w którym dopisana zostaje
informacja o błędzie wyższego poziomu i funkcja kończy się.
Jeśli natomiast błędu nie ma, sterowanie dochodzi aż do instrukcji
return i funkcja kończy się bez dopisywania komunikatu o błędzie.
To rozwiązanie też ma jednak wadę. Polega ona na tym, że używanie instrukcji goto jest powszechnie
niezalecane i uważane za zły styl programowania.
Ten przykład pokazuje więc, jak wielkie trudności sprawia i jakich środków
może wymagać dobra, wydajna, a jednocześnie niezaciemniająca zbytnio kodu
obsługa błędów w programie.
void DrawSprites() { int sprite_drawer_id = CreateSpriteDrawer(); if (!err::Is()) { bool *arr = new int[g_Sprites.GetCount()]; g_Sprites.FillArray(arr); if (!err::Is()) { VERTEX *v; SpriteDrawerLock(sprite_drawer_id, &v); if (!err::Is()) { for (int i = 0, index = 0; i < g_Sprites.GetCount(); i++) { if (arr[i]) { v[index++] = SpriteToIndex(g_Sprites.GetSprite(i)); if (err::Is()) break; } } SpriteDrawerUnlock(sprite_drawer_id); } if (!err::Is()) SpriteDrawerDraw(sprite_drawer_id); } delete [] arr; DeleteSpriteDrawer(sprite_drawer_id); } if (err::Is()) err::Add("Nie można odrysować sprites", __FILE__, __LINE__); } DrawSprites
z użyciem przedstawionego wyżej modułu.
Ostatnia instrukcja jest nam już znana.
Wcześniej następują zagnieżdżone warunki, które dość dobrze odwzorowują
strukturę tej funkcji - tworzenie, używanie i usuwanie jednych zasobów okala
inne.
Metoda 2 - z wyjątkamiOpisaną wyżej koncepcję zaawansowanej obsługi błędów można zaimplementować również z wykorzystaniem mechanizmu wyjątków języka C++. Będziemy wtedy rzucali i wyłapywali wyjątek pewnej własnej klasy, a obiekt tej klasy przechowywał będzie wspomniany stos łańcuchów z komunikatami błędów. Oto przykładowa implementacja takiego modułu:#include <string> #include <stack> namespace err { class Error { private: /// Stack of error messages std::stack<std::string> m_Msgs; protected: /** Use rhis constructor if you need to compute something before creating first error message and can't call constructor of this base class on the constructor initialization list */ Error() { } public: /// Push a message on the stack /** Use __FILE__ for file and __LINE__ for line */ void Add(const std::string &msg, const std::string &file = "", int line = 0); /// Pop message from the top of the stack or return false if empty bool Pop(std::string *msg); /// General error creation Error(const std::string &msg, const std::string &file = "", int line = 0) { Add(msg, file, line); } }; void Error::Add(const std::string &msg, const std::string &file, int line) { if (msg.empty()) return; std::string file_name = ExtractFileName(file); if (file_name.empty() && line == 0) m_Msgs.push(msg); else if (file_name.empty()) m_Msgs.push("[" + IntToStr(line) + "] " + msg); else if (line == 0) m_Msgs.push("[" + file_name + "] " + msg); else m_Msgs.push("[" + file_name + "," + IntToStr(line) + "] " + msg); } bool Error::Pop(std::string *msg) { if (m_Msgs.empty()) return false; else { *msg = m_Msgs.top(); m_Msgs.pop(); return true; } } } // namespace err err::Error.
Dopisanie nowego komunikatu na stos wymaga jego wyłapania (przez referencję,
by mieć dostęp do oryginalnego obiektu), wywołania metody Add i
"odrzuceniu" dalej. Obsługa zaś, na złapaniu wyjątku i odczytaniu z niego
komunikatów metodą Pop. Oto przykład użycia dla pierwszej
z omawianych funkcji:
void DrawAll() { try { DrawBackground(); DrawHorizon(); DrawSprites(); DrawParticles(); DrawGUI(); } catch(err::Error &e) { e.Add("Nie można odrysować gry", __FILE__, __LINE__); throw; } } catch, w której na szczyt stosu dopisywany jest nowy
komunikat i błąd zgłaszany jest ponownie.
W tej konkretnej sytuacji wyjątki sprawdziły się doskonale.
void DrawSprites() { try { int sprite_drawer_id = CreateSpriteDrawer(); try { bool *arr = new int[g_Sprites.GetCount()]; try { g_Sprites.FillArray(arr); VERTEX *v; SpriteDrawerLock(sprite_drawer_id, &v); try { for (int i = 0, index = 0; i < g_Sprites.GetCount(); i++) { if (arr[i]) { v[index++] = SpriteToIndex(g_Sprites.GetSprite(i)); } } SpriteDrawerUnlock(sprite_drawer_id); } catch(...) { SpriteDrawerUnlock(sprite_drawer_id); throw; } SpriteDrawerDraw(sprite_drawer_id); delete [] arr; } catch(...) { delete [] arr; throw; } DeleteSpriteDrawer(sprite_drawer_id); } catch(...) { DeleteSpriteDrawer(sprite_drawer_id); throw; } } catch(err::Error &e) { e.Add("Nie można odrysować sprites", __FILE__, __LINE__); throw; } } try.
Co więcej, zwalnianie wszelkich zasobów powtarza się tutaj dwa razy -
dla sytuacji bez błędu i z błędem.
Możnaby rzecz, że taki kod jest wręcz paskudny!
finally w języku C++.
Sekcja ta występuje w wielu językach programowania, np. w Object Pascal (Delphi),
Python, Java, C#.
Nawet w takich, w których zwalnianie pamięci i innych zasobów rzadko bywa
potrzebne, ponieważ środowisko posiada garbage collector.
Występuje ona obok sekcji catch w bloku try,
a zawarty w niej kod wykonuje się zawsze niezależnie od tego, czy wykonanie
kodu z wnętrza try zakończyło się sukcesem, czy zgłosiło wyjątek.
Jej założeniem jest właśnie dostarczenie miejsca na wpisanie instrukcji
zwalniających różne zasoby. Przykładowy fragment z użyciem finally
wyglądałby tak:
CreateSth(); try { // Może zgłosić błąd UseSth(); } finally { DeleteSth(); } #include <fstream> void foo() { std::ifstream plik("dane.bin"); // Może zgłosić błąd WczytajPlik(plik); // Błąd czy nie błąd, przy wyjściu z tej funkcji zmienna plik przestaje // istnieć, a w jej destruktorze plik zostaje zamknięty. } auto_ptr, która
ma przeładowane operatory dające wrażenie używania zwykłego wskaźnika,
a przy niszczeniu sama zwalnia obiekt, na który wskazuje:
#include <memory> void bar() { std::auto_ptr<Klasa> wskaznik = std::auto_ptr<Klasa>(new Klasa()); // Może zgłosić błąd UzywajObiektu(wskaznik); // Błąd czy nie błąd, przy wyjściu z tej funkcji zmienna wskaznik przestaje // istnieć, a w jej destruktorze utworzony dynamicznie obiekt klasy Klasa, // na który wskaźnik pokazuje, zostaje zwolniony. } Oczywiście, można sobie napisać samemu obiektowe, samozwalniające się w destruktorze nakładki na wszystkie rodzaje zasobów wszystkich bibliotek, z których korzystamy. Tylko czy to można nazwać eleganckim rozwiązaniem? Moim zdaniem to nie jest najlepszy pomysł. Ponadto istnieją oprócz samych zasobów pewne byty, które również podlegają zwalnianiu, a które typowymi zasobami nie są. Możnaby je nazwać stanami obiektów. Stanem takim jest pokazane w przykładzie wyżej zablokowanie (rozpoczynane jakąś metodą ze słowem Lock i wymagające późniejszego odblokowania
Unlock), jak również rozpoczynanie czegoś (Begin i
End) czy inne tym podobne.
Myśląc zgodnie z przedstawionym wyżej założeniem języka C++ należałoby
również i na to napisać własne nakładki obiektowe.
Nazwy tych klas musiałyby brzmieć mniej więcej tak, jak ZablokowanieBufora
czy RozpoczęcieWysyłaniaDanych.
Wydaje mi się to bardzo sztuczne...
Inne zagadnieniaŁączenie z innymi mechanizmamiKażda biblioteka w jakiś sposób zgłasza błędy. Każda ma własne mechanizmy służące do tego celu. Najczęściej jest to jedno z rozwiązań przedstawionych w poprzednim rozdziale, w punkcie "Prosta obsługa błędów" lub ich połączenie - np. zwracanie specjalnej wartości plus przechowywany globalnie liczbowy kod błędu plus funkcja potrafiąca zamieniać taki kod na komunikat tekstowy. Zachodzi więc potrzeba, ilekroć wywołujemy funkcję zewnętrznej biblioteki, przechwytywania zwracanego przez nią błędu i zamieniania go na nasz ujednolicony sposób. Możliwość takiej zamiany, a właściwie stworzenia naszego błędu na podstawie błędu zgłaszanego przez różne biblioteki warto wbudować w moduł obsługi błędów. Napisałem stworzenia, ponieważ logicznie wywołanie funkcji bibliotecznej jest miejscem, w którym następuje wyskok z naszego kodu, a więc podczas jego wywołania nie spodziewamy się zgłoszonego błędu w naszym kodzie. Ewentualne powstanie błędu w wyniku wykonania takiej funkcji stanowi więc najniższy poziom - miejsce, w którym błąd zostaje zainicjowany, a dalej "lecąc" już przez nasz kod jest uzupełniany o komunikaty wyższych poziomów. Jako przykład rozpatrzmy błędy zgłaszane przez bibliotekę DirectX. Ponieważ używa ona interfejsu COM, każda jej funkcja zwraca wartość typu HRESULT. Wartość tą należy testować makrem SUCCEEDED
lub FAILED i jeśli oznacza błąd, na podstawie jej wartości
można uzyskać skrótową nazwę błędu funkcją DXGetErrorString9
oraz komunikat błędu funkcją DXGetErrorDescription9.
Funkcja tworząca błąd w mechanizmie nieużywającym wyjątków, na podstawie kodu błędu zwróconego przez jakąś funkcję DirectX, może wyglądać tak: void Create_DirectX(HRESULT hr, const std::string &Msg, const std::string &File, int Line) { Add("(DirectX," + UintToStr(hr) + ") " + std::string(DXGetErrorString9(hr)) + " (" + std::string(DXGetErrorDescription9(hr)) + ")"); if (!Msg.empty()) Add(Msg, File, Line); } HRESULT hr = m_SavedDeviceState->Apply(); if (FAILED(hr)) err::Create_DirectX(hr, "Nie można przywrócić ustawień urządzenia", __FILE__, __LINE__); class Error_DirectX : public Error { public: Error_DirectX(HRESULT hr, const std::string &msg, const std::string &file, int line) { Add("(DirectX," + UintToStr(hr) + ") " + std::string(DXGetErrorString9(hr)) + " (" + std::string(DXGetErrorDescription9(hr)) + ")"); if (!Msg.empty()) Add(msg, file, line); } }; HRESULT hr = m_SavedDeviceState->Apply(); if (FAILED(hr)) throw err::Error_DirectX(hr, "Nie można przywrócić ustawień urządzenia", __FILE__, __LINE__); Wydajność dwóch przedstawionych metod obsługi błędów trudno ocenić i porównać. Nie robiłem specjalnych testów w tym celu. Z jednej strony mechanizm wyjątków uważany jest za powolny i wymaga w większości implementacji, o ile mi wiadomo, kilkukrotnego wewnętrznego kopiowania przez wartość obiektu wyjątku. Z drugiej jednak można się spodziewać, że automatyczne odwijanie stosu po rzuceniu wyjątku może być szybsze, niż ręczne sprawdzanie stanu błędu w licznych instrukcjach warunkowych if.
Powstaje jeszcze pytanie, na ile wydajna musi być cała ta obsługa błędów? Jedno jest pewne - działanie programu w normalnych sytuacjach powinno być jak najmniej spowalniane przez ten kod obsługi. Natomiast kiedy już błąd zostaje zgłoszony, można sobie zwykle pozwolić na mniej optymalne rozwiązania. Wyjątkiem są takie miejsca w kodzie, w których spodziewamy się częstego występowania błędów i traktujemy je jako coś normalnego kontynuując przetwarzanie mimo ich występowania. Wtedy jednak trudno zwykle nazwać takie sytuacje błędami, a mechanizm do ich obsługi powinien być zupełnie inny - prostszy, wydajniejszy, być może zupełnie bez komunikatów tekstowych i niepołączony z mechanizmem omawianym tutaj. Czasami w kodzie, który wymaga najwyższej wydajności można zrezygnować z obsługi błędów mimo, że błędy mogą w nim wystąpić. Trzeba wtedy po prostu mieć nadzieję, że nie wystąpią. Ważne jest jednak, by stosowanie bądź niestosowanie obsługi błędów stosować konsekwentnie do zagnieżdżonych wywołań funkcji. Jeśli wiem, że funkcja, którą wywołuję może zgłaszać błąd, muszę i w kodzie wywołującym być na błąd przygotowany i obsługiwać błędy. Dlatego w swoim kodzie oznaczam funkcje mogące zgłaszać błąd komentarzem // [e].
Wielowątkowość Nie każdy program wymaga używania wielowątkowości. Jednak tam, gdzie chcemy jej używać, musimy również odpowiednio przygotować moduł obsługi błędów. Metoda nieużywająca wyjątków nie jest bezpieczna wątkowo, ponieważ zawiera zmienną globalną. Jej dostosowanie mogłoby polegać na zamknięciu tego stosu i funkcji operujących na nim w jakąś klasę zwaną "kontekstem błędu", której obiekt tworzony byłby na potrzeby danego wątku. Napisałem nawet taką klasę i z powodzeniem użyłem jej w jednym wielowątkowym programie, ale w pewnym momencie z przerażeniem odkryłem, że ona nie ma prawa działać. O ile bowiem w takiej czy innej funkcji związanej z danym wątkiem roboczym mogę odwoływać się do danego kontekstu błędu i do niego zapisywać błąd, o tyle skąd wywoływana w tym wątku jakaś moja funkcja biblioteczna ogólnego przeznaczenia, (na przykład wczytująca plik z dysku do pamięci) ma wiedzieć, do jakiego kontekstu powinna zapisać ewentualny błąd? Nie sposób przecież przekazywać wskaźnika na ten kontekst z funkcji do funkcji. Jedynym wyjściem wydaje się przechowywanie tego wskaźnika w jakiejś przestrzeni udostępnianej przez system dla programisty i skojarzonej z danym wątkiem oraz każdorazowe odwoływanie się do tego miejsca. W Windows API istnieje taki mechanizm i nazywa się Thread Local Storage (TLS). Pytanie tylko, czy to będzie wydajne? Dlatego pod tym względem wygrywa metoda druga. Wyjątki bowiem, o ile mi wiadomo, są bezpieczne wątkowo. Jeśli tylko wyjątek zostanie wyłapany jeszcze w ramach danego wątku i przetworzony na jakąś inną, zdolną do synchronizacji między wątkami postać przed opuszczeniem funkcji głównej wątku, to mogą jednocześnie "lecieć" wyjątki rzucone w różnych wątkach i każdy z nich jest niezależny. Transakcyjność Interesujące jest też pytanie, czy jeśli wystąpił błąd podczas tworzenia jakiegoś zasobu, zasób ten powstał (tylko nie w pełni, jest w jakimś błędnym stanie) i trzeba go usunąć, czy też nie? Ja nazywam to problemem transakcyjności. Transakcja w bazach danych to ciąg operacji, który albo w całości zakończy się sukcesem, albo nic nie zostanie zrobione. Jeśli wystąpi błąd w którejś z operacji składowych, wszystkie poprzednie zostają cofnięte. W podobnym świetle można rozpatrywać zagadnienie tworzenia zasobów. Czy powinno być ono transakcyjne? Jeszcze do niedawna sądziłem, że nie. Obecnie bardziej skłaniam się ku poglądowi, że powinno. Nie jestem jednak do końca zdecydowany. Resource::Resource(int SomeData) { SomeData2 = ComputeSth(SomeData); if (err::Is()) goto error; m_Field1 = new Class1(SomeData2.Info1); if (err::Is()) goto error; m_Field2 = new Class2(SomeData2.Info2); if (err::Is()) goto error; return; error: err::Add("Cannot create resource", __FILE__, __LINE__); } Resource::~Resource() { delete m_Field2; delete m_Field1; } void Foo() { Resource *res = new Resource(); if (!err::Is()) { // use res... } delete res; } Foo.
Jeśli podczas tworzenia wystąpił błąd, nie wolno używać obiektu - jest on
nieprawidłowy.
Destruktor klasy Resource zwalnia wszystkie te pola, które
udało się zaalokować.
Pola są zerowane na liście inicjalizacyjnej konstruktora,
a więc jeśli nie dojdzie do ich przypisana, pozostaną wyzerowane.
Operator delete natomiast ma to do siebie (o czym nie wszyscy wiedzą i robią
to ręcznie), że sprawdza, czy wskaźnik jest niezerowy
przed zwolnieniem obiektu.
Resource::Resource(int SomeData) : m_Field1(0), m_Field2(0) { try { SomeData2 = ComputeSth(SomeData); m_Field1 = new Class1(SomeData2.Info1); try { m_Field2 = new Class2(SomeData2.Info2); } catch(...) { delete m_Field1; throw; } } catch(err::Error &e) { e.Add("Cannot create resource", __FILE__, __LINE__); throw; } } Resource::~Resource() { delete m_Field2; delete m_Field1; } void Foo() { Resource *res = new Resource(); try { // use res... delete res; } catch(...) { delete res; throw; } } Foo nie musi usuwać
obiektu, jeśli jego utworzenie się nie uda (konstruktor rzuci wyjątek).
Widzimy natomiast, że konstruktor jest bardziej skomplikowany.
Jeśli tworzenie obiektu drugiego zakończy się błędem, obiekt pierwszy
musi zostać zwolniony tak, by żadne śmieci nie zostały - drugiej okazji
na ich zwolnienie już nie będzie, bo destruktor dla tego obiektu się nie wykona.
Na podstawie przedstawionych przykładów widać więc, że metoda obsługi błędów bez użycia wyjątków naturalnie komponuje się z podejściem, które nazywam tu nietransakcyjnym, metoda używająca wyjątków natomiast z transakcyjnym. Problem jest jednak szerszy i trzeba jasno zdefiniować, czy np. w swojej klasie należy wywołać metodę odblokowującą Unlock, jeżeli wcześniej
wywołana została metoda blokująca Lock i zakończyła się błędem.
Są więc tutaj dwa różne podejścia. Każde ma swoje wady i zalety. Każde ma swój sens, ale na każde można też spojrzeć jako na niezbyt eleganckie. Jednym słowem - żadne nie wydaje mi się w stu procentach właściwe i doskonałe. Problem finalizacji Żelazna zasada języka C++ mówi, że nie wolno rzucać wyjątków z destruktorów. Przyczyna tego jest następująca: Na raz może "lecieć" tylko jeden wyjątek. Od momentu jego rzucenia do wyłapania następuje odwijanie stosu i jedynym kodem programu, jaki może się wtedy wykonać są właśnie destruktory klas wywoływane dla niszczonych przy tej okazji obiektów automatycznych (lokalnych). Jeśli więc taki destruktor spróbuje rzucić wyjątek w momencie, w którym inny właśnie "leci", rezultat będzie niezdefiniowany - najprawdopodobniej skończy się to "wysypaniem się" aplikacji. Ale problem ma też głębsze znaczenie, niż tylko czysto techniczne. Tak samo sytuacja wygląda w przypadku obsługi błędów bez użycia wyjątków. Jeśli bowiem jakiś kod zgłosił błąd, kolejne funkcje przy wyjściu dopisywały do jego stosu komunikaty i w którymś momencie, w destruktorze jakiegoś obiektu coś się nie uda, to co powinno się stać ze stosem komunikatów błędu? Czy nowy błąd pochodzący z tego destruktora powinien zastąpić stary? Z pewnością nie, ponieważ wtedy wymazana zostanie istotna informacja o błędzie - w dodatku o tym pierwotnym, najważniejszym. Czy zatem nowy błąd powinien zostać dopisany na szczyt stosu? Też nie, ponieważ stos komunikatów reprezentuje logiczny ciąg przyczynowy, stanowi jakby zrzut stosu wywołań od czynności najbardziej wewnętrznych do najbardziej zewnętrznych i nie powinien posiadać żadnych rozgałęzień. Dopisanie do niego zupełnie innego komunikatu zaburzyłoby tą strukturę, złamałoby jego założenie i komunikat straciłby spójność. Może zatem należałoby przewidzieć w błędzie możliwość tworzenia całej kolekcji wielu równoległych stosów z komunikatami? To już rozwiązanie zupełnie dziwne i niedorzeczne. Możnaby się w ogóle nie zastanawiać nad tym problemem. To jest logiczne samo w sobie, że finalizacja (niszczenie, usuwanie zasobów, zwalnianie pamięci) nie powinna kończyć się niepowodzeniem. W końcu jeśli nie uda się zwolnić pamięci, to co najwyżej powstanie wyciek. I tak nie można było mu zapobiec - bo cóż mogliśmy zrobić źle, że owo zwolnienie się nie udało? Zresztą, praktycznie zawsze każde zwolnienie zasobu czy pamięci kończy się sukcesem, jeśli tylko do funkcji zwalniającej podany zostanie prawidłowy identyfikator lub wskaźnik danego obiektu. Wyjątkiem mogą być tylko sytuacje, kiedy na przykład istnieją w chwili niszczenia obiekty podrzędne danemu albo obiekt jest w pewnym stanie (zablokowany czy jakaś operacja rozpoczęta) - ale próba niszczenia obiektu w takiej sytuacji to już błąd logiczny programisty. Jednak istnieją niestety sytuacje, w których możliwość obsługi błędów podczas finalizacji jest potrzebna. Po raz pierwszy spotkałem się z taką pisząc klasę strumienia kompresji. Była to część hierarchii klas różnorodnych strumieni (właściwie nadal jest i dobrze mi służy). Tworzonemu obiektowi klasy strumienia kompresującego podawało się wskaźnik na inny strumień - docelowy. Można było zapisywać dane do strumienia kompresującego, a on kompresował je i zapisywał dane skompresowane do tego strumienia docelowego. Przeznaczone do skompresowania dane były buforowane. Dopiero kiedy wewnętrzny bufor klasy zapełniał się, porcja danych była kompresowana z użyciem biblioteki zlib i wyjście było zapisywane do strumienia wyjściowego. Może się zdarzyć (i zazwyczaj tak bywa), że po wysłaniu ostatniej porcji danych do strumienia wyjściowego jakieś dane zostaną w jego buforze. Dlatego destruktor tej klasy dokańcza dzieła kompresując i wysyłając do strumienia wyjściowego pozostałe dane. Problem w tym, że czynności te (tak kompresja, jak i zapis do strumienia wyjściowego) mogą zakończyć się błędem, a destruktor nie powinien przecież zgłaszać błędów. Z drugiej strony jednak niepowodzenie w zapisaniu danych to poważny błąd. Faktu, że nie udało się dokończyć zapisywania skompresowanego pliku i pozostał on niekompletny nie powinno się przemilczeć. Zostawiłem ten kod bez obsługi błędów z nadzieją, że nic złego się w tym miejscu nigdy nie wydarzy. Rozwiązania tego problemu nie znam do dziś... Usprawnienia składniowe Można wymyślić wiele usprawnień, które za pomocą sprytnych makr, klas czy nawet szablonów pomogą upiększyć kod najeżony obsługą błędów (a raczej uczynić go mniej nieładnym). Nie podaję tutaj konkretnego przykładu, bo sam niczego takiego nie stosuję. Chciałbym jednak przestrzec przed wszelkimi rozwiązaniami, które kompilują się do takiego kodu, w których komunikat błędu jest formułowany nawet kiedy błędu nie ma. Jego tworzenie może bowiem wymagać czasochłonnych operacji (np. znajdowanie wiersza i kolumny na podstawie pozycji kursora w parsowanym dokumencie tekstowym, konwersji liczb na łańcuchy i różnych operacji na łańcuchach), a więc jego każdorazowe przygotowywanie byłoby marnotrawieniem czasu. Marnotrawstwo błędów Spokoju nie daje mi też pewien drobiazg, który polega na tym, że w pewnych charakterystycznych sytuacjach komunikat z błędem, tak pieczołowicie budowany, może zostać przemilczany i niewykorzystany. Wtedy czas poświęcony na tak zaawansowaną obsługę błędów zostaje zmarnowany. Kiedy możemy mieć do czynienia z taką sytuacją i jak możemy się przed tym ustrzec? Na pewno nie wtedy, kiedy wiemy na pewno, że zgłaszany w danym miejscu błąd jest krytyczny i spowoduje zakończenie całej aplikacji czy danej operacji (np. wczytywanie plików kluczowych do działania programu podczas jego uruchamiania). Również nie wtedy, gdy funkcja robi jakiś drobiazg i wiemy, że będzie wywoływana bardzo wiele razy (np. konwersja łańcucha na liczbę). Wówczas bowiem możemy zaprojektować ją tak, aby zwracała bardziej lakoniczną informację o błędzie i niewykorzystywała całego tego rozbudowanego mechanizmu. Mogą pojawić się jednak do napisania funkcje, co do których nie wiemy, czy ich niepowodzenie będzie stanowiło poważny błąd aplikacji, czy tylko szczegół, nad którym program przejdzie do porządku dziennego, pominie jakiś element i wywoła ją ponownie, być może jeszcze tysiąc razy, dla kolejnych danych. Wszystko zależy od sytuacji, w jakiej zostanie ona wywołana. Co w takim razie można zrobić, aby uchronić się przed takim marnotrawieniem czasu na generowanie błędów, których treść i tak zostanie potem przemilczana? Wydaje mi się, że nic. Możnaby wprawdzie wprowadzić jakieś oznaczenie, jakąś flagę symbolizującą oczekiwany "poziom szczegółowości" obsługi błędów i każdorazowo sprawdzać ją, ale trudno mi sobie wyobrazić używanie czegoś takiego. Możliwości rozbudowy Przedstawiona tutaj koncepcja obsługi błędów jest dość złożona, ale stosownie do potrzeb danego projektu można ją jeszcze wzbogacić o dodatkowe elementy. Jednym z nich może być mechanizm do wygodniejszego budowania komunikatów o błędach. Mogłaby to być możliwość wpisywania do komunikatu informacji różnych typów (łańcuchów, liczb itp., a może nawet obiektów własnych klas) za pomocą przeładowanego operatora << tak, jak do strumienie biblioteki standardowej
C++:
catch(err::Error &e) { e << "Błąd w linii: " << iLine << std::endl; throw; } prinf:
catch(err::Error &e) { e.Add("Błąd w linii: %i", iLine); throw; } Jeszcze inny to zapewnienie możliwości określania poziomu obsługi błędów, np. czy pewne błędy mają powodować całkowite zakończenie programu, czy jedynie zaniechanie pewnej operacji. Błędy alokacji Możnaby też sprawdzać błędy alokacji pamięci dynamicznej. Teoretycznie każde użycie operatora new może zakończyć się
niepowodzeniem.
Błąd jest zgłaszany przez niego poprzez rzucenie wyjątku klasy std::bad_alloc,
która należy do hierarchii klas wyjątków biblioteki standardowej z klasą
bazową exception i metodą
virtual const char *what() const; zwracającą komunikat błędu.
Nie sposób jednak wyobrazić sobie sprawdzania każdej alokacji, otaczania jej w dodatkowy kod i przetwarzania ewentualnego błędu na własny format. Nie jest to też niezbędne - w końcu jeśli w systemie brakuje już pamięci wirtualnej, to i tak ani program, ani użytkownik, ani sam system operacyjny dużo już nie zdziała. Dlatego ja w swoich programach nie sprawdzam błędów alokacji pamięci. Kiedy można nie sprawdzać błędów? Istnieje szereg sytuacji, kiedy można zrobić wyjątek od reguły i złagodzić kontrolę błędów lub całkowicie z niej zrezygnować.
PodsumowanieO napisaniu tego artykułu myślałem już od dawna. Przedstawiłem w nim moje przemyślenia na temat obsługi błędów w kodzie C++. Pokazałem, jak ewoluowały moje poglądy na ten temat i jaki jest ich stan obecny. Podkreślam przy tym, że w sprawie wielu rzeczy w tej materii, także zasadniczych, nie jestem do końca zdecydowany ani w pełni przekonany do jednego rozwiązania.Odnoszę bowiem wrażenie, że coś jest w tym wszystkim nie tak. Że nie tylko nie istnieje rozwiązanie idealne, ale też i nie może istnieć, bo coś tu nie pasuje. Sposób, w jaki wygląda współczesne programowanie obiektowe pozwala doskonale, jasno i czytelnie formułować zarówno pojedyncze algorytmy, jak i strukturę zaawansowanych aplikacji. Nie pozwala jednak na równie przejrzystą obsługę błędów. Być może kiedyś wynaleziony zostanie inny, lepszy sposób zapisu kodu programu. Póki co, pozostaje zastanawiać się nad problemami takimi, jak przedstawione w tym artykule. Dlatego liczę na kontakt. Zachęcam do pisania e-maili i dzielenia się ze mną swoimi uwagami na temat tego artykułu. Jeśli znalazłeś w nim jakiś błąd, nie zgadasz się ze mną, masz inne poglądy na pewne sprawy, masz lepszy pomysł na rozwiązanie tego problemu - napisz!
| ||||||||
|