![]() |
|
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 p |