MOJA PASJA - PROGRAMOWANIE
   Dzisiaj jest Niedziela, 23 kwietnia, 2017r. Ostatnia aktualzacja miała miejsce: 10 grudnia 2006r. Homepage

Programowanie: Artykuły * FAQ * Download * Komponenty * Książki WWW: Artykuły * Narzędzia * Kursy * Darmowe * FAQ * Skrypty * Ksiązki Off-Topic: Aforyzmy * Humor Inne: Forum * Wiki * Liczniki * Linki * Chat * Grafika * Video * Inne



Problemy z obsługą błędów w kodzie C++

Autor: Adam Sawicki    

WWW: regedit.gamedev.pl    

Wstęp

Artykuł 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.

Jest kilka rodzajów błędów:
Błędy projektowe
Kiedy program zawiera błąd mimo, że jego kod jest poprawny, ponieważ błędnie zostały wymyślone i sformułowane jego założenia, jego wymagania, jego projekt.
Błędy kompilacji
Kiedy program się nie kompiluje, ponieważ występuje w nim jakiś błąd składni. Takie błędy są zazwyczaj proste do znalezienia i usunięcia.
Błędy wykonania
Kiedy program się kompiluje, ale nie działa albo działa źle.
Te ostatnie - błędy wykonania - można z kolei podzielić na:
Błędy spowodowane przez programistę
Tzw. błędy logiczne - kiedy program jest poprawny składniowo, ale zawiera błąd, który zawsze lub w szczególnych przypadkach powoduje nieprawidłowe działanie programu. Efektem takiego błędu może być np. błędna reakcja programu na zdarzenia, brak takiej reakcji, awaryjne zakończenie programu (błąd ochrony pamięci - znany też jako "wykonanie niedozwolonej operacji" lub "segmentation fault" - albo błąd typu "debug assertion failed") bądź jego nieuruchamianie się (zakończenie jeszcze zanim zostanie zainicjalizowany). Przyczyną zaś - np. nieupilnowany wskaźnik czy indeks poza zakresem.
Błędy niezależne od programisty
To błędy, którym nie można zapobiec. Można jedynie przewidzieć miejsca ich występowania i odpowiednio przygotować na nie program pisząc kod ich obsługi.
Właśnie tej ostatniej kategorii błędów poświęcony jest ten artykuł. Błędów powodowanych przez programistę także można próbować unikać pisząc pewien kod do ich obsługi (sprawdzania zakresów indeksów czy poprawności wskaźników i odpowiedniego reagowania na błąd). Można to robić nawet w taki sam sposób, jak opisany niżej do obsługi błędów niezależnych od programisty. Zazwyczaj jest to jednak strata czasu. Poprawne zdefiniowanie, a potem zaimplementowanie struktury kodu gwarantuje (w przypadku idealnym) brak takich błędów. Każdorazowe sprawdzanie warunków, które w poprawnym kodzie zawsze będą spełnione (czyli tzw. asercji) w kodzie wydaniowym niepotrzebnie spowalnia program. Dlatego do ewentualnej kontroli tego rodzaju błedów używam co najwyżej funkcji assert, jak w przykładzie:
#include <cassert>
 
int GetArrayElement(int Index)
{
  assert(Index >= 0 && Index < g_ArraySize);
  return g_Array[Index];
}
Błędy niezależne od programisty powstają w sytuacjach takich, jak np. obsługa plików (plik może nie istnieć, może być zablokowany, może nie posiadać odpowiednich uprawnień itd. - istnieje ogromna ilość różnych możliwych błędów wejścia-wyjścia) czy obsługa sieci (serwer może być niedostępny, połączenie może zostać zerwane itd.). Ogólnie można powiedzieć, że należy się ich spodziewać wszędzie tam, gdzie program:
  • komunikuje się ze światem zewnętrznym (plikiem, siecią, innym programem, użytkownikiem)
  • korzysta z pewnych bibliotek (każda z bibliotek posiada pewien mechanizm zgłaszania błędów)
  • Pozyskuje pewne zasoby (wczytuje z pliku, generuje, alokuje itd.)
  • dokonuje niebanalnych obliczeń (litery podczas konwersji łańcucha na liczbę albo zero podczas dzielenia)
Nieco inne będą wymagania co do obsługi błędów dla autora biblioteki, a inne dla autora programu. Programista piszący bibliotekę ma wyjątkowo trudne zadanie, bo oprócz ograniczeń w możliwości przekazywania błędów do programu używającego jego dzieła (sposoby łączenia kodu programu z bibliotekami DLL i SO pozostają praktycznie na poziomie DOS-a łupanego i dopiero technologia .NET ma szansę to zmienić umożliwiając np. przekazywanie między nimi wyjątków) musi starać się kontrolować poczynania użytkownika biblioteki (sprawdzać wszelkie przekazywane parametry, bo lepiej żeby biblioteka zakomunikowała błąd wartości parametru, niż wysypała cały program z powodu błędu ochrony pamięci). Nie ma ponadto pewności, czy dana funkcja biblioteczna będzie wywoływana tylko raz podczas inicjalizacji programu (gdzie można sobie pozwolić na dokładniejszą, ale i mniej wydajną kontrolę), czy w pętli tysiące razy na sekundę (gdzie wymagana jest najwyższa wydajność). Artykuł ten jest nastawiony raczej na obsługę błędów w kodzie programu, a nie biblioteki.

Metody obsługi błędów

Brak obsługi błędów

Najprostszą 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());
}
Ta funkcja ma za zadanie konwertować łańcuch na liczbę całkowitą. Robi to z użyciem funkcji 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();
}
W tym przykładzie konstruktor klasy oznaczającej całą grę tworzy obiekt tekstury, który jak można się domyślać jest zasobem wczytywanym z pliku o podanej nazwie. Wskaźnik na utworzony obiekt jest zwracany przez funkcję wczytującą i zachowywany w polu klasy. Następnie metoda zapamiętuje w polach szerokość i wysokość tej tekstury pobieraną za pomocą metod dostępowych jej obiektu.

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);
}
To typowa pętla główna programu pisanego w Windows API. Funkcja 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:

Zakomunikowaniu użytkownikowi o błędzie
Bez tego użytkownik nie wiedziałby, co się tak naprawdę stało. Komunikat powinien nieść maksimum informacji. Jakie dokładnie - o tym będzie mowa niżej. Ogólnie powinien precyzować przyczynę błędu i pomóc w jego zlokalizowaniu. Moim zdaniem nie należy bać się wprowadzania tam wielu informacji technicznych. Komunikat błędu jest dla programisty, nie dla użytkownika, a jeśli użytkownik wyczyta z treści jakieś przydane informacje (np. Nie można wczytać tekstury z pliku: monster.tga), to tym lepiej. Pozostałe informacje po prostu zignoruje. Zależnie od rodzaju programu zakomunikowaniem tym może to być na przykład:
  • Pokazanie okienka z komunikatem błędu
  • Zapisanie komunikatu do pewnego pliku tekstowego czy innego loga
Odpowiednia akcja programu
Chodzi o to, aby nie brnąc dalej w kłopoty - nie próbować używać zasobów, których nie udało się pozyskać albo nie dokonywać obliczeń na niewczytanych danych, a także zwolnić wszystkie pozyskane wcześniej zasoby w przypadku anulowania pewnych akcji czy zamykania całego programu. Ogólnie wszystko to sprowadza się do odpowiedniego pokierowania przepływem sterowania i właśnie o trudnościach w eleganckim rozwiązaniu tego problemu jest ten artykuł. Podejmowaną akcją może być na przykład:
  • Awaryjne zakończenie całego programu (tak zazwyczaj robią gry)
  • Zaniechanie rozpoczętej akcji (np. polecenia otwarcia pliku w programie użytkowym)
Obsługa błędów służy:

użytkownikowi
Program reaguje na błędne sytuacje lepiej, niż reagowałby bez obsługi błędów, a komunikat błędu pozwala zorientować się w jego przyczynie.
programiście
Szczegółowy komunikat błędu BARDZO pomaga w lokalizowaniu problemów z kodem - tak na własnym, jak i na cudzym komputerze.
Dlatego ja obecnie nie wyobrażam sobie pisania kodu bez obsługi błędów. Kiedy popełniam jakiś błąd (np. źle wpiszę nazwę pliku do wczytania przez program), natychmiast dostaję komunikat mówiący dokładnie, czego nie udało się zrobić. W przeciwnym wypadku program po prostu by nie zadziałał albo wysypałby się, a ja musiałbym żmudnie szukać problemu, sprawdzać kod, debugować itd.

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();
  }
}
Niestety, to rozwiązanie nie nadaje się do wszystkich funkcji. W funkcjach takich, jak 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);
Także to rozwiązanie nie jest uniwersalne, ponieważ nie sposób zastosować go do konstruktorów klas (które bardzo często mają za zadanie pozyskiwanie pewnych zasobów, a więc muszą też reagować na błędy). Konstruktor nie ma wartości zwracanej. Kolejnym pomysłem jest użycie zmiennej globalnej do informowania o błędzie.
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;
}
Wszystkie te rozwiązania działają i są stosowane. Trudno też uznać je za mało eleganckie - jak zobaczymy poniżej, rozwiązania bardziej złożone wcale nie będą piękniejsze. Jednakże mają jedną poważną wadę. Chodzi o przekazywaną informację o błędzie. Przeanalizujmy, jakiego rodzaju mogłaby to być informacja.

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()
  ...
Konstruktor obiektu głównego gry wczytuje kolekcję tekstur. Funkcja wczytująca 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.
  • Funkcja LoadFile napisałaby: Nie można wczytać pliku: monster.tga
  • Funkcja ParseLoadedData otrzymuje jedynie wskaźnik na wczytane już do pamięci dane i ma za zadanie ich przetworzenie. Nie zna nazwy pliku i niedobrze byłoby przekazywać jej tą nazwę tylko po to, by dołączyła ją do ewentualnego komunikatu o błędzie. Jej komunikat musiałby więc brzmieć tak: Nie można przetworzyć wczytanych danych jako obrazka.
  • Funkcja LoadTexture napisałaby: Nie można wczytać tekstury z pliku: monster.tga
  • Konstruktor klasy Game napisałby: Nie można zainicjalizować gry
Wyobraźmy sobie teraz, że niepowodzeniem kończy się któreś z wywołań funkcji ParseLoadedData, ponieważ wczytane dane binarne nie są poprawnym obrazkiem (plik graficzny został podmieniony przez złośliwego gracza na dokument Worda).
  • Gdyby każda funkcja widząc niepowodzenie wywołanej przez nią pozostawiała komunikat błędu, w zmiennej z komunikatem pozostałby ten zgłoszony przez funkcję najbardziej zagnieżdżoną, czyli Nie można przetworzyć wczytanych danych jako obrazka..
  • Gdyby każda funkcja widząc niepowodzenie wywołanej przez nią nadpisała komunikat błędu własnym, w zmiennej z komunikatem pozostałby ten zgłoszony przez funkcję położoną najwyżej w hierarchii, czyli Nie można zainicjalizować gry.
W ani jednej, ani drugiej sytuacji komunikat błędu nie niesie wystarczających informacji. Niepowodzenie w inicjalizacji gry jest zbyt ogólne, aby można było coś powiedzieć o przyczynie błędu. Niepowodzenie w przetworzeniu danych na obrazek natomiast (podobnie jak np. w konwersji łańcucha na liczbę) zbyt szczegółowe, bo taka elementarna operacja może być wykonywana w wielu różnych miejscach.

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()
Automatycznie zbudowanie tego stosu w przypadku wystąpienia błędu jest wspierane przez wiele nowoczesnych języków programowania, jak Python czy C# i inne na platformę .NET. Niestety, nawet taki automatyczny stos nadal nie jest wystarczający. Powyższy komunikat pozwala precyzyjnie zlokalizować miejsce wystąpienia błędu w kodzie, ale nie posiada wszystkich informacji potrzebnych do zlokalizowania przyczyny samego błędu. W tym przypadku nie wiadomo, którego z plików nie udało się wczytać. Funkcja wczytująca teksturę jest wywoływana wielokrotnie dla różnych plików. Zachodzi więc potrzeba ręcznego dołączania przez funkcje pewnych informacji do komunikatów o błędzie.

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();
}
Pierwsza z nich ma za zadanie odrysować wszystko. Swoje zadanie wykonuje wywołując po kolei funkcje do odrysowania tła, horyzontu sceny, duszków (czyli obiektów gry), efektów cząsteczkowych i na końcu interfejsu użytkownika.

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);
}
Druga funkcja jest trochę bardziej skomplikowana, jak na przykład edukacyjny. Jednak w praktyce zdarzają się przecież funkcje jeszcze bardziej złożone. Mamy tutaj jakby kilka poziomów, które nawzajem się okalają:

  1. Na początku tworzony jest zasób reprezentujący "rysowacz obiektów", który reprezentuje identyfikator liczbowy. Wiele bibliotek posiada swoje zasoby właśnie w takiej postaci - identyfikatorów czy uchwytów. Jeśli jego utworzenie się udało, na końcu obiekt ten trzeba zwolnić.
  2. Tworzona jest tablica dynamiczna. Ona również musi zostać na końcu zwolniona.
  3. Tabla jest wypełniana przez funkcję należącą do jakiegoś globalnego obiektu, zapewne przechowującego informacje o kolekcji jednostek w grze Wiemy, iż ta funkcja również może zakończyć się niepowodzeniem.
  4. Obiekt służący do rysowania zasobów zostaje zablokowany. Funkcja blokująca zwraca wskaźnik, co daje bezpośredni dostęp do pewnego obszaru pamięci i pozwala go wypełnić. To także mechanizm często spotykany w różnych bibliotekach. Po wypełnieniu pamięci należy dokonać odblokowania.
  5. Po odblokowaniu wywoływana jest funkcja dokonująca właściwego odrysowania. Powinna zostać wywołana tylko jeżeli wszystkie wcześniejsze zakończyły się sukcesem.
  6. Podczas trwania zablokowania tablica jest wypełniana wartościami w pętli, przy czym funkcja zmieniająca zwrócony obiekt sprita na indeks również może zakończyć się niepowodzeniem.
Pokaże teraz, jak może wyglądać implementacja modułów do obsługi błędów i jak należałoby przepisać te dwie powyższe funkcje, aby prawidłowo reagowały na błędy z ich użyciem. Zakładam przy tym, że wywoływane funkcje również zgłaszają błąd z użyciem danego modułu obsługi błędów. O tym, jak radzić sobie z różnymi sposobami zgłaszania błędów przez funkcje różnych bibliotek zewnętrznych i jak konwertować je na jednolitą postać, napiszę w następnym rozdziale.

Metoda 1 - bez wyjątków

Implementacja 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
Przyjąłem tu założenie, że program nie jest w stanie błędu, kiedy stos komunikatów jest pusty. Równie dobrze można pamiętać ten stan w osobnej zmiennej typu 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__);
}
Pierwszy z nich i chyba najbardziej oczywisty to wykonywanie kolejnych funkcji tylko jeśli poprzednie zakończyły się sukcesem. Powstaje sekwencja zagnieżdżonych w sobie 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__);
}
Sekwencję zagnieżdżonych warunków można zastąpić łańcuszkiem warunków na jednym poziomie sprawdzając stan błędu przez wywołaniem każdej kolejnej funkcji. To rozwiązanie wygląda już lepiej, ale ma jedną wadę. Kiedy pierwsza funkcja zgłasza błąd, następuje po niej niepotrzebnie czterokrotne sprawdzenie błędu przed próbą wywołania każdej kolejnej, czego nie było w rozwiązaniu powyżej.
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__);
}
Rozwiązaniem łączącym zalety dwóch powyższych - przejrzystym, a jednocześnie optymalnym, jest funkcja przedstawiona powyżej. Używa ona instrukcji skoku 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__);
}
Tak mogłaby wyglądać obsługa błędów w funkcji 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.

  1. Najpierw tworzony jest obiekt służący do rysowania. Jeśli jego utworzenie się powiedzie, wykonywany jest kod, w którym można go wykorzystywać, a następnie jest on usuwany.
  2. W kodzie tym najpierw tworzona jest tablica dynamiczna. Zakładam, że błędy dynamicznej alokacji pamięci nie są sprawdzane (będzie o tym zagadnieniu mowa poniżej). Następnie wywołuję funkcję wypełniającą tą tablicę.
  3. Jeśli wypełnienie się powiedzie, wykonuję jakby dwie czynności - pierwsza składa się z zablokowania, używania pozyskanej w ten sposób pamięci i jej odblokowania, a druga, jeśli nie ma do tej pory błędu, z wywołania funkcji rysującej.
  4. Pierwszy z tych bloków działa w taki sam sposób, jak każde pozyskiwanie zasobów. Wywoływana jest funkcja blokująca, a jeśli uda się zablokować, wykonywany jest kod mogący korzystać z pamięci pod zwróconym wskaźnikiem i potem następuje odblokowanie.
  5. W pętli wypełniana jest tablica, a ponieważ wiem, że i tam może wystąpić błąd, w każdej iteracji sprawdzam go i jeśli jest zgłoszony, przerywam pętlę.

Metoda 2 - z wyjątkami

Opisaną 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
Zgłoszenie błędu polega tutaj na rzuceniu wyjątku klasy 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;
  }
}
Kod jest bardzo przejrzysty. Możnaby się zachwycić mechanizmem wyjątków C++ sądząc po tym przykładzie, że doskonale rozwiązuje on problem obsługi błędów. Zakładamy, że każda z wywoływanych tutaj funkcji może rzucić wyjątek naszej klasy. Jeśli to zrobi, wtedy sterowanie pominie pozostałe funkcje i przejdzie do sekcji 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;
  }
}
Niestety w przypadku drugiej funkcji już tak nie jest. Jej wersja z obsługą błędów opartą na wyjątkach jest dłuższa niż w metodzie 1. Widać tutaj aż kilka zagnieżdżonych sekcji 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!

  1. Pierwsza sekcja try pełni takie samo zadanie jak w poprzednim przykładzie - łapie wyjątek i dopisuje nowy komunikat na szczyt stosu.
  2. Druga sekcja try wykonuje się po próbie utworzenia obiektu rysującego. Jej celem jest zabezpieczenie jego usunięcia na wypadek błędu. Jeśli udało się go utworzyć, sekcja ta rozpoczyna kod korzystający z tego obiektu, a na jej końcu następuje usunięcie tego obiektu. W przypadku błędu natomiast, następuje takie samo usunięcie obiektu i błąd jest zgłaszany dalej.
  3. Sekcja trzecia pełni analogiczne zadanie w stosunku do alokowanej tu tablicy dynamicznej, zapewniając zwolnienie pamięci także na wypadek błędu.
  4. Analogicznie sekcja czwarta, znajdująca się po funkcji blokującej, zawiera kod korzystający z pamięci zablokowanego obiektu i zapewnia tak w przypadku pomyślnego wykonania kodu, jak i w przypadku błędu dokonanie odblokowania.
Konieczność dwukrotnego powtarzania instrukcji zwalniającej dany zasób wynika z braku sekcji 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();
}
Niestety twórcy C++ uparcie nie chcą wprowadzić tej pożytecznej składni. Twierdzą oni, że zgodnie z założeniem języka wszelkie obiekty powinny być tworzone jako zmienne automatyczne (lokalne) i zwalniać się same po wyjściu z zasięgu. Faktycznie można tak robić np. ze strumieniami C++:
#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.
}
A nawet ze wskaźnikami - dzięki klasie 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.
}
Niestety teoria okazuje się być tylko (jak to ja mawiam) nieudolną próbą usystematyzowania praktyki. W praktyce każdy program, który robi coś więcej, niż przetwarzanie znaków wejściowych na wyjściowe z użyciem plików i konsoli tekstowej, musi używać oprócz biblioteki standardowej C++ także innych bibliotek. Biblioteki takie mają zwykle (z powodów, na które narzekałem już wyżej) interfejs strukturalny. Do ich zasobów trzeba odwoływać się przez jakieś wskaźniki, uchwyty czy identyfikatory i o tworzeniu samozwalniających się obiektów automatycznych nie może być mowy.

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 mechanizmami

Każ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);
}
Jej użycie natomiast:
HRESULT hr = m_SavedDeviceState->Apply();
if (FAILED(hr))
  err::Create_DirectX(hr, "Nie można przywrócić ustawień urządzenia",
    __FILE__, __LINE__);
Przetwarzanie błędu DirectX na błąd naszego mechanizmu w metodzie 2 (używającego wyjątków) można zrealizować tworząc klasę pochodną błędu. Będzie ona miała stosowną nazwę i konstruktor przyjmujący odpowiednie parametry, a podczas uzupełniania błędu o nowe komunikaty i jego łapania w miejscu docelowym używa się już tylko referencji do klasy bazowej, bez wiedzy to prawdziwym typie obiektu wyjątku.
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);
  }
};
Jej użycie:
HRESULT hr = m_SavedDeviceState->Apply();
if (FAILED(hr))
  throw err::Error_DirectX(hr, "Nie można przywrócić ustawień urządzenia",
    __FILE__, __LINE__);
Wydajność

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;
}
Ten przykład pokazuje podejście nietransakcyjne i używające mechanizmu obsługi błędów bez wyjątków. Podejście nietransakcyjne ma tą zaletę, że prościej jest pisać konstruktory i destruktory obiektów. Jeśli nie udało się dynamicznie stworzyć obiektu drugiego, obiektu pierwszego nie trzeba usuwać. Pozostanie on zaalokowany, a wskaźnik do niego zapisany w polu klasy. Ponieważ przykład ten używa mechanizmu obsługi błędów bez wyjątków, obiekt mimo błędu powstaje, a co za tym idzie, musi również zostać usunięty. Tak też postępuje funkcja 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;
  }
}
Ten przykład pokazuje podejście transakcyjne i używające mechanizmu obsługi błędów z wyjątkami. Podejście transakcyjne wydaje się być bardziej logiczne. W taki też sposób działają wyjątki - jeśli w konstruktorze wystąpi błąd, obiekt nie powstaje. Dlatego teraz funkcja 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;
}
Lub w taki sposób, w jaki działa funkcja prinf:
catch(err::Error &e)
{
  e.Add("Błąd w linii: %i", iLine);
  throw;
}
Inny pomysł to połączenie wypisywania komunikatów o błędach z zaawansowanym systemem logowania, który potrafi zapisywać komunikaty np. na konsoli programu, na konsoli systemowej, do pliku w formacie TXT, HTML, RTF, wyświetlać jako okienka dialogowe w programie itp.

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ć.
  • W kodzie kluczowym dla wydajności, gdzie z każdą instrukcją trzeba się liczyć i można pozwolić sobie na zignorowanie ewentualnych błędów licząc, że się nie pojawią.
  • Kiedy nie spodziewamy się, że błąd może powstać mimo, że teoretycznie jest możliwość jego zwrócenia, np. podczas wywoływania funkcji jakiejś biblioteki, która ma za zadanie tylko policzyć coś albo ustawić jakiś stan, a tym bardziej zwrócić jakąś pamiętaną przez bibliotekę wartość (np. GetWindowRect z Windows API).

Podsumowanie

O 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!

   Wasze opinie:
   Średnia ocena: 7.91/10 (32 głosów)
   
   Liczba komentarzy: 1
Dodawanie nowego komentarza

Autor:
E-mail (opcjonalnie):
Treść:



Stronę przygotował: Kacper Cieśla (comboy). Wszelkie prawa zastrzeżone.
Reklama * Zgłoś błąd * Kontakt * Hosting * O stronie * Sponsoring
Czas generowania strony: 0.344s