Instrukcje: Projektowanie pod kątem bezpieczeństwa wyjątków

Jedna z zalet mechanizmu wyjątków polega na tym, że wykonywanie, wraz z danymi o wyjątku, przechodzi bezpośrednio z instrukcji generującej wyjątek do pierwszej instrukcji „catch” zdolnej go obsłużyć. Program obsługi może się znajdować o dowolną liczbę poziomów wyżej w stosie wywołań. Funkcje wywoływane między instrukcjami „try” i „throw” nie muszą nic wiedzieć o zgłaszanym wyjątku. Muszą jednak być zaprojektowane w taki sposób, aby mogły wykroczyć poza zakres „niespodziewanie” w każdym momencie, gdy istnieje ryzyko rozpowszechnienia wyjątku z niższego poziomu, nie pozostawiając przy tym za sobą żadnych częściowo utworzonych obiektów, przecieków pamięci ani struktur danych w bezużytecznych stanach.

Podstawowe techniki

Zasady obsługi wyjątków powinny być odpowiednio zaawansowane, starannie przemyślane i stanowić integralną część procesu projektowania. Zazwyczaj większość wyjątków jest wykrywanych i zgłaszanych w niższych warstwach modułów oprogramowania, jednak zwykle warstwy te dysponują za małą ilością kontekstu, aby odpowiednio postępować z błędami lub wysyłać komunikaty użytkownikom końcowym. W środkowych warstwach funkcje mogą przechwytywać wyjątki i je ponownie zgłaszać w trakcie badania obiektów powodujących wyjątki albo zawierają dodatkowe przydatne informacje, które mogą przekazywać na wyższe warstwy ostatecznie przechwytujące te wyjątki. Funkcja powinna wychwytywać i „wchłaniać” wyjątek tylko wtedy, gdy po takiej operacji jest w stanie całkowicie wrócić do normalnego działania. W wielu przypadkach właściwym działaniem w środkowych warstwach jest zezwolenie na przekazanie wyjątku w górę stosu. Nawet w najwyższej warstwie odpowiednim zachowaniem może być pozwolenie nieobsługiwanemu wyjątkowi na przerwanie działania programu, jeśli miałby on pozostawić program w stanie nie gwarantującym jego poprawnego działania.

Bez względu na to, jak funkcja obsługuje wyjątek, w celu zagwarantowania jej „bezpieczeństwa pod względem wyjątków” musi być zaprojektowana zgodnie z poniższymi podstawowymi zasadami.

Utrzymywanie prostych klas zasobów

W przypadku hermetyzacji ręcznego zarządzania zasobami w klasach należy użyć klasy, która nie wykonuje niczego poza zarządzaniem pojedynczym zasobem. Zachowując prostą klasę, można zmniejszyć ryzyko wprowadzenia wycieków zasobów. Jeśli to możliwe, użyj inteligentnych wskaźników , jak pokazano w poniższym przykładzie. Przykład jest celowo sztuczny i uproszczony, aby podkreślić różnice względem stosowania wskaźników shared_ptr.

// old-style new/delete version
class NDResourceClass {
private:
    int*   m_p;
    float* m_q;
public:
    NDResourceClass() : m_p(0), m_q(0) {
        m_p = new int;
        m_q = new float;
    }

    ~NDResourceClass() {
        delete m_p;
        delete m_q;
    }
    // Potential leak! When a constructor emits an exception,
    // the destructor will not be invoked.
};

// shared_ptr version
#include <memory>

using namespace std;

class SPResourceClass {
private:
    shared_ptr<int> m_p;
    shared_ptr<float> m_q;
public:
    SPResourceClass() : m_p(new int), m_q(new float) { }
    // Implicitly defined dtor is OK for these members,
    // shared_ptr will clean up and avoid leaks regardless.
};

// A more powerful case for shared_ptr

class Shape {
    // ...
};

class Circle : public Shape {
    // ...
};

class Triangle : public Shape {
    // ...
};

class SPShapeResourceClass {
private:
    shared_ptr<Shape> m_p;
    shared_ptr<Shape> m_q;
public:
    SPShapeResourceClass() : m_p(new Circle), m_q(new Triangle) { }
};

Zarządzanie zasobami przy użyciu idiomu RAII

Aby zapewnić bezpieczeństwo wyjątków, funkcja musi zapewnić, że obiekty przydzielone przy użyciu lub mallocnew są niszczone, a wszystkie zasoby, takie jak dojścia do plików, są zamykane lub zwalniane, nawet jeśli zostanie zgłoszony wyjątek. Pozyskiwanie zasobów to inicjowanie (RAII) powiązania zarządzania takimi zasobami do cyklu życia zmiennych automatycznych. Kiedy funkcja wykracza poza swój zakres, powracając do stanu wyjściowego normalnie lub z powodu wyjątku, następuje wywołanie destruktorów wszystkich w pełni skonstruowanym automatycznych zmiennych. Obiekt otoki idiomu RAII, taki jak inteligentny wskaźnik, wywołuje w destruktorze odpowiednią funkcje usunięcia lub zamknięcia. W kodzie bezpiecznym pod względem wyjątków kluczowe znaczenie ma natychmiastowe przekazywanie własności każdego zasobu do jakiegoś obiektu RAII. Należy pamiętać, że vectorklasy , , stringmake_shared, fstreami podobne obsługują pozyskiwanie zasobu. Jednak i tradycyjne shared_ptr konstrukcje są specjalne, unique_ptr ponieważ pozyskiwanie zasobów jest wykonywane przez użytkownika zamiast obiektu, dlatego liczą się jako zwalnianie zasobów jest niszczenie, ale są wątpliwe jako RAII.

Trzy gwarancje wyjątku

Zazwyczaj bezpieczeństwo wyjątków jest omawiane pod względem trzech gwarancji wyjątków, które funkcja może zapewnić: gwarancję braku awarii, silną gwarancję i podstawową gwarancję.

Gwarancja braku awarii

Gwarancja braku niepowodzenia (lub „braku zgłaszania wyjątków”) jest najsilniejszą gwarancją, jaką może zapewnić funkcja. Stwierdza, że funkcja nie będzie generować wyjątków ani nie pozwoli ich rozpowszechnianie. Gwarancji takiej można wiarygodnie udzielić tylko w przypadku, gdy (a) wiadomo, że wszystkie funkcje wywoływane przez tę funkcję również zapewniają brak niepowodzeń, lub (b) wiadomo, że wszystkie zgłaszane wyjątki są przechwytywane zanim dotrą do tej funkcji, lub (c) wiadomo, jak poprawnie przechwytywać i obsługiwać wszystkie wyjątki mogące docierać do tej funkcji.

Zarówno gwarancja silna, jak i gwarancja podstawowa bazują na założeniu, że destruktory gwarantują brak niepowodzenia. Wszystkie kontenery i typy w bibliotece standardowej gwarantują, że ich destruktory nie zgłaszają wyjątków. Istnieje również wymóg przeciwny: biblioteka standardowa wymaga, aby przekazywane do niej typy zdefiniowane przez użytkownika, na przykład jako argumenty szablonu, zawierały destruktory niezgłaszające wyjątków.

Silna gwarancja

Silna gwarancja stwierdza, że jeśli funkcja wykroczy poza swój zakres z powodu wyjątku, nie nastąpi w niej przeciek pamięci, a stan programu nie ulegnie zmianie. Funkcja zapewniająca silną gwarancję jest zasadniczo transakcją o semantyce zatwierdzenia lub wycofania. Albo kończy się pełnym sukcesem, albo nie powoduje żadnego efektu.

Gwarancja podstawowa

Gwarancja podstawowa jest najsłabsza. Może być jednak najlepszym rozwiązaniem, jeśli silna gwarancja zbyt mocno obciąża pamięć albo inne zasoby systemu. Stwierdza, iż w przypadku wystąpienia wyjątku nie następuje przeciek pamięci, a obiekt nadal znajduje się użytecznym stanie, chociaż jego dane mogły ulec modyfikacji.

Klasy bezpieczne dla wyjątków

Klasa może pomagać gwarantować swoje własne bezpieczeństwo pod względem wyjątków, nawet jeśli jest wykorzystywana przez niezabezpieczone funkcje. Wystarczy, że uniemożliwia swoje częściowe konstruowanie i częściowe niszczenie. Jeśli działanie konstruktora klasy zostaje przerwane przed zakończeniem całego procesu, obiekt nie powstaje i konstruktor nigdy nie będzie wywoływany. Mimo iż będą wywoływane konstruktory zmiennych automatycznych zainicjowanych przed zaistnieniem wyjątku, dojdzie do przecieku dynamicznie przydzielonej pamięć lub zasobów, które nie są zarządzane przez inteligentny wskaźnik lub podobną zmienną automatyczną.

Wszystkie typy wbudowane gwarantują brak niepowodzenia, a typy w bibliotece standardowej obsługują co najmniej gwarancję podstawową. Dla wszystkich typów zdefiniowanych przez użytkownika, które muszą być bezpieczne pod względem wyjątków, należy przestrzegać następujących wytycznych:

  • Do zarządzania wszystkimi zasobami używaj inteligentnych wskaźników lub innych otok typu RAII. Staraj się nie umieszczać funkcji zarządzania zasobami w destruktorach klas, ponieważ gdy konstruktor zgłosi wyjątek, destruktor nie będzie wywoływany. Jeśli jednak klasa jest dedykowanym menedżerem zasobów kontrolującym dokładnie jeden zasób, można użyć destruktora do zarządzania zasobami.

  • Pamiętaj, że wyjątek zgłoszony w konstruktorze klasy bazowej nie może zostać wchłonięty w konstruktorze klasy pochodnej. Jeśli chcesz dokonać translacji wyjątku klasy bazowej i wygenerować go ponownie w konstruktorze klasy pochodnej, użyj bloku funkcji „try”.

  • Zastanów się, czy wszystkie stany klasy mają być przechowywane w składowej danych opakowanej w inteligentny wskaźnik, zwłaszcza jeśli klasa ma koncepcję "inicjowania, która może zakończyć się niepowodzeniem". Mimo że język C++ umożliwia niezainicjowane składowe danych, nie obsługuje niezainicjowanych ani częściowo zainicjowanych wystąpień klas. Działanie konstruktora musi się kończyć się sukcesem lub niepowodzeniem. Jeśli konstruktor nie wykona swojego pełnego procesu, nie zostanie utworzony żaden obiekt.

  • Nie pozwól, aby jakikolwiek wyjątek został pominięty przez destruktora. Podstawowy aksjomat języka C++ mówi, że destruktory nigdy nie powinny pozwalać na przekazywanie wyjątków do wyższych poziomów stosu wywołań. Jeśli destruktor musi wykonać operację, która może się skończyć zgłoszeniem wyjątku, musi zrobić to w bloku „try catch” i wchłonąć wyjątek. Standardowa biblioteka zapewnia tę gwarancję wszystkim destruktorom, które definiuje.

Zobacz też

Nowoczesne najlepsze rozwiązania dotyczące języka C++ dotyczące wyjątków i obsługi błędów
Instrukcje: interfejs między kodem obsługi wyjątków a innym kodem