Navigation

Exceptionsicherheit

Exceptionsicherheit

1. Was ist Exceptionsicherheit?

Unter Exceptionsicherheit eines Programms versteht man, daß es Exceptions zum einen immer korrekt behandelt, zum anderen auch nur in den Situationen Exceptions wirft, wo diese erlaubt sind. Im Folgenden möchte ich auf vier Standardsituationen eingehen, in denen Exceptions Probleme verursachen können. Mit diesen vier Fällen erhebe ich zwar keinen Anspruch auf Vollständigkeit, aber sie dürften einen Großteil der brenzligen Situationen abdecken und auch ein wenig Sensibilität zum Thema Exceptionsicherheit schaffen. Die vier Fälle sind:

2. Exceptions in normalen Funktionen oder Methoden

Der übliche Anwendungsfall für Exceptions ist die Fehlerbehandlung. Als Exception kann man im Prinzip alles verwenden, was ein Typ hat, also mithin jede Art von Wert (in realen Programmen wird man häufig aber Exceptionklassen verwenden, also insbesondere Klassen, die z.B. von std::exception abgeleitet sind).

Exceptions werden mittels throw geworfen, also ausgelöst. Mit dem Auslösen der Exception verläßt das Programm den eigentlich vorgesehenden Pfad der Programmausführung und springt - nachdem alle lokalen Variablen abgeräumt wurden - in den nächstgelegen catch Block, der eine Exception passenden Typs erlaubt (dieser kann auch in einer der aufrufenden Funktionen zu finden sein, muß also nicht zwingend in der gleichen Funktion zu finden sein, wo das throw steht). Dieses Verfahren wird als bekannt vorausgesetzt und nicht näher erläutert. Gibt es zu einer Exception keinen entsprechenden catch Block, so wird unexpected aufgerufen, eine Funktion im C++ Framework, welche das Programm terminieren läßt. Als Minimalanforderung an ein exceptionsicheres Programm gilt daher, daß alle Exceptions, die geworfen werden, auch wieder im Programm gefangen werden. Im einfachsten Fall kann man dies durch einen globalen try - catch Block in main erreichen:










10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
#include <iostream>
#include <stdexcept>

int main(...) {
    try {
        ...
        // Eigentlicher Programmcode
        ...
    }
    catch(std::exception& e) {
        // Fängt von std::exception abgeleitete Exceptions
        std::cerr<<"Unerwarteter Fehler: <<e.what()<<std::endl;
        return 1;
    }
    catch(...) {
        //  Fängt jede Exception
        std::cerr<<"Unerwarteter Fehler unbekanntent Typs!<<std::endl;
        return 1;
    }
}

Allein mit dem Fangen von Exceptions ist es aber nicht getan. Auch muß sichergestellt werden, daß kein Speicher, offene Dateien oder sonstige belegten Resourcen zwar angefordert, aber niemals freigegeben werden, nur weil eine Exception geworfen wurde. Diese an sich selbstverständliche Aussage kann man garnicht oft genug wiederholen, denn entweder aus Flüchtigtkeit oder wegen der irrigen Annahme, das Werfen der Exception würde hier alle Aufräumarbeiten übernehmen, kommt gelegentlich solcher Code zustande:










10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
void simple_copy(const char* from, const char* to) {
    FILE* in;
    FILE* out;

    in = fopen(from, "rb);
    if (NULL == in) {
        throw "Kann Datei nicht um Lesen öffnen!;
    }
    out = fopen(to, "wb);
    if (NULL == out) {
        throw "Kann Datei nicht um Schreiben öffnen!;
    }

    int c;
    while(EOF != (c=getc(in))) {
        putc(c, out);
    }

    if (!feof(in) || ferror(out)) {
        throw "Irgendwas lief beim Kopieren schief!;
    }

    fclose(out);
    fclose(in);
}

Diese Routine soll eine Datei kopieren, der Parameter from gibt den Namen der Quelldatei an, to den Namen der Zieldatei. Das Programm wird in der Regel zwar keinen Absturz verursachen, aber würde man diese Routine einige hundertmal mit einem falschen to Parameter aufrufen (also etwa einem Leerstring), so würde die Routine irgendwann immer fehlschlagen, unabhängig davon, ob nun korrekte Parameter übergeben wurden oder nicht. Der Grund liegt im fehlerhaften Exceptionmanagement: Die Exception in Zeile 7 ist völlig unproblematisch, aber bei den beiden anderen Exceptions sind ein (Zeile 11) bzw. zwei (Zeile 20) FILE* Objekte zwar mittels fopen erzeugt worden, werden allerdings im Falle einer Exception nicht gelöscht. Wird also die Funktion über eine der beiden Exceptions verlassen, bleiben Dateien weiter geöffnet, was zum oben beschriebenen Verhalten führt. Es gibt nun mehrere Alternativen, dieses Problem zu lösen, von denen ich drei vorstellen möchte:

  1. Man verwendet an Stelle von FILE* eine Streamklasse, also genauer std::fstream Objekte: in diesem Fall wird, da diese Klassen über einen Destruktor verfügen, sobald die Funktion verlassen wird, der Destruktor aufgerufen, und damit auch bereits geöffnete Dateien geschlossen. (Dies gilt natürlich nicht, wenn diese Objekte mit new angelegt wurden, sondern nur, wenn sie einfache lokale Variablen sind). Eine solche Lösung ist - wenn möglich, in der Regel allen übrigen vorzuziehen, allerdings funktioniert sie nicht immer: nicht immer hat man Klassen zur Verfügung.

  2. Eine recht einfache Lösung scheint zu sein, einfach bevor die Exceptions geworfen wurden, die bis dahin belegten Resourcen wieder freizugeben. So würde etwa der Source für die Zeilen 19-21 mutieren zu:








    ...
    if (!feof(in) || ferror(out)) {
        fclose(in);
        fclose(out);
        throw "Irgendwas lief beim Kopieren schief!;
    }
    ...
    Analog müssten auch die Zeilen 10-12 angepasst werden (weil ja in geschlossen werden muß). Das Problem dieser Lösung ist, daß bei komplexeren Routinen recht viel Code doppelt geschrieben werden muß und bei Erweiterungen ebenfalls stets nachgehalten werden muß welche Resourcen offen sind, was im Zweifel erhöhten Wartungsaufwand bzw. Fehleranfälligkeit bedeutet.

  3. Die dritte Möglichkeit besteht darin, in der Routine die geworfene Exception zunächst selbst zu fangen, alle Resourcen zu schließen und dann die Exception erneut an den Aufrufer zu werfen. Der ensprechende - nunmehr exceptionsichere - Code sähe so aus:










    10 
    11 
    12 
    13 
    14 
    15 
    16 
    17 
    18 
    19 
    20 
    21 
    22 
    23 
    24 
    25 
    26 
    27 
    28 
    29 
    30 
    31 
    void simple_copy(const char* from, const char* to) {
        FILE* in = NULL;
        FILE* out = NULL;

        try {
            in = fopen(from, "rb);
            if (NULL == in) {
                throw "Kann Datei nicht um Lesen öffnen!;
            }
            out = fopen(to, "wb);
            if (NULL == out) {
                throw "Kann Datei nicht um Schreiben öffnen!;
            }

            int c;
            while(EOF != (c=getc(in))) {
                putc(c, out);
            }

            if (!feof(in) || ferror(out)) {
                throw "Irgendwas lief beim Kopieren schief!;
            }
        }
        catch(...) {
            if (out) fclose(out);
            if (in) fclose(in); 
            throw;
        }

        fclose(out);
        fclose(in);
    }

In der Regel würde ich die erste Alternativen alle anderen vorziehen. In den Situationen, in denen sie nicht anwendbar ist, würde ich aus den o.g. Gründen die dritte Alternative wählen.

3. Exceptions in Konstruktoren

Exceptions in Konstruktoren sind mit dem o.g. Fall von Exceptions in normalen Funktionen oder Methoden vergleichbar: wird im Konstruktor eine Exception geworfen, so wird der Destruktor dieser Klasse nicht aufgerufen, so daß man also auch alle Resourcen, die man im Konstruktor belegt hat und normalerweise im Destruktor freigeben würde, selbst freigeben muß. Allerdings sollte man im Auge behalten, daß - falls wir uns in einem Konstruktor eine abgeleiteten Klasse befinden - der Destruktor der Basisklasse sehr wohl abgearbeitet wird. Das folgende Programm soll dies illustrieren:










10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
28 
29 
30 
31 
32 
33 
34 
35 
#include <iostream>

class base {
public:
    base() { std::cout<<"base constructor<<std::endl; }
    virtual ~base() { std::cout<<"base destructor<<std::endl; }
};

class derived: public base {
    bool is_good;
public:
    derived(bool is_good) :base(), is_good(is_good) {
        std::cerr<<"derived constructor<<std::endl;
        if (!is_good) throw "FEHLER;
    }

    ~derived() { std::cerr<<"derived destructor<<std::endl; }
};

int main() {
    try {
        std::cout<<"Gute Instanz:<<std::endl;
        derived good(true);
    }
    catch(const char* fehler) {
        std::cerr<<fehler<<std::endl;
    }       
    try {
        std::cout<<"Nicht gute Instanz:<<std::endl;
        derived bad(false);
    }
    catch(const char* fehler) {
        std::cerr<<fehler<<std::endl;
    }       
}

Die Klasse derived ist eine Spezialiserung von base und beide Klassen schreiben entsprechende Meldungen, wenn ihr Konstruktor bzw. Destruktor aufgerufen wird. Um eine komplizierte Klasse zu vermeiden, geben wir der derived Klasse anhand eines Parameters mit, ob sie im Konstruktor eine Exception werfen soll. So können wir leicht nachvollziehen, was geschieht, wenn der Konstruktor von derived eine Exception wirft oder nicht. Die Ausgabe des Programms ist

Gute Instanz:
base constructor
derived constructor
derived destructor
base destructor
Nicht Gute Instanz:
base constructor
derived constructor
base destructor
FEHLER

Man erkennt, daß wenn im Konstruktor von derived eine Exception geworfen wird, tatsächlich niemals der Destruktor von derived aufgerufen wird. Von einem exceptionsicheren Programm ist daher zu erwarten, daß es entsprechende Aufräumarbeiten im Konstruktor selbst ausführt. Leider sieht man häufig Source, in dem der Autor zu glauben scheint, der Destruktor würde immer aufgerufen...

4. Exceptions in Zuweisungsoperatoren

Der Zuweisungsoperator wird bei Klassen in der Regel überladen, wenn eine sogenannte "tiefe Kopie" notwendig ist: Ist etwa in einem Objekt ein Speicherbereich dynamisch mittels new oder malloc angelegt worden, so muß Ähnliches auch bei dem Zuweisungsoperator geschehen. Das Folgende Beispiel zeigt einen solchen - nicht sicheren - Zuweisungsoperator für eine Klasse my_string:










10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
class my_string {
    int length;     // Stringlänge 
    char* data;     // Stringdaten
    ...
public:
    my_string& operator = (const my_string& quelle) {
        if (this != &quelle) {              // Nur was tun, wenn Objekte nicht identisch
            // Alten Inhalt löschen
            delete[] data;          

            // Neuen Inhalt setzen  
            data = new char[quelle.length];
            length = quelle.length;     
            for(int i=0; i<length) {
                data[i] = quelle.data[i];
            }
        }
        return *this;
    }
    ...
}

Man kann den Zuweisungsoperator in zwei Teile unterteilen: im Ersten Teil, Zeile 8-10, wird der alte Inhalt von this gelöscht und im zweiten Schritt, Zeile 11-17, wird dann der neue Inhalt aufgebaut. Zwar mag diese Vorgehensweise intuitiv nachvollziehbar sein, allerdings hat sie den gravierenden Nachteil, nicht exceptionsicher zu sein: In Zeile 12 wird mittels new der Speicher für den neuen Wert angelegt. Es ist durchaus möglich, daß dies schief geht und daher eine std::bad_alloc Exception geworfen wird. Dummerweise haben wir aber in der Zeile 9 den alten Inhalt gelöscht, so daß in diesem Falle das Objekt in einem inkonsistenten Zustand zurückgelassen wird. Offenkundig darf man also nicht einfach so naiv vorgehen, wie oben gezeigt. Die Lösung bestünde offenkundig darin, alle Aktionen, die eine Exception auslösen könnten, auszuführen, bevor man den alten Inhalt des Strings löscht. In diesem einfachen Beispiel würde es genügen, zuerst den Speicher anzufordern, den Zeiger darauf in einer lokalen Variablen zu speichern und anschließend den alten Inhalt löschen und den Inhalt der lokalen Variable nach data zu kopieren. Handelt es sich um ein komplexeres Objekt, so wird diese Aufgabe jedoch schnell ziemlich fehlerträchtig und umständlich.

Es gibt aber eine recht generische Lösung zu diesem Problem: ausgehend von der überlegung, daß wenn ein Zuweisungsoperator überladen werden muß es ebenso einen entsprechenden Kopier-Konstruktor geben muß, kann man sich folgende Strategie vorstellen:

  1. Zunächst wird von dem Objekt rechts vom Gleichheitszeichen (hier: quelle) mit Hilfe des Kopierkonstruktors eine Kopie erzeugt, nennen wir sie copy. Sollte in dem Konstruktor - von dem wir annehmen er sei exceptionsicher - eine Exception geworfen werden, so ist dies nicht weiter tragisch, weil wir ja das Objekt links vom Gleichheitszeichen (hier: this) in keiner Weise verändert haben.

  2. Der nächste Schritt ist ein wenig tricky: Wir erfinden eine Methode, nennen wir sie swap, welche alle Instanzvariablen von this mit denen einer anderen Instanz (copy) vertauscht. D.h. also die Felder data und length werden von copy nach this kopiert und umgekehrt. Wir nehmen an, daß swap niemals eine Exception werfen wird (was eine hinnehmbare Einschränkung ist). Als Resultat erhält copy also den alten Wert von this; und umgekehrt erhält this den Wert von copy und damit eine Kopie von quelle.

  3. Das einzige, was uns zu unserem Glück nun noch fehlt ist, daß irgendwie der Inahlt von copy freigegeben wird, denn ansonsten ist ja schon alles geschehen. Aber hierfür brauchen wir ja im Prinzip nichts zu tun, weil copy eine lokale Variable ist, deren Destruktor implizit aufgerufen wird, sobald unser Zuweisungsoperator verlassen wird.

Wer genau hingeschaut hat, wird feststellen, daß zu keinem Zeitpunkt ein Objekt wirklich inkonsistent ist. Der eben skizzierte Code schaut so aus:










10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
class my_string {
    int length;     // Stringlänge 
    char* data;     // Stringdaten
    ...
private:
    void swap(my_string& other) {
        std::swap(other.length, length);
        std::swap(other.data, data);
    }

public:
    my_string& operator = (const my_string& quelle) {
        my_string copy(quelle);     // Erzeuge Kopie vom Quellobjekt
        swap(copy);                 // Vertauche Inhalt von copy mit this

        return *this;           
    }
    ...
}

Neben der Exceptionsicherheit gewinnen wir noch mehr: erfahrungsgemäß ähneln sich Kopierkonstruktor und Zuweisungsoperator recht stark, wenn man auf die oben genannte Vorgehensweise verzichtet. Im zuletzt vorgestellten Zuweisungsoperator findet man jedoch nirgends ein new oder delete: es wird vollständig auf die Implementierungen des Kopierkonstruktors und des Destruktors zurückgegriffen, was hier Coderedundanzen vermeidet. Die oben vorgestellte Vorgehensweise setzt implizit voraus, daß Destruktoren keine Exceptions werfen. Warum das so sein sollte, wird im folgenden Abschnitt zwar detailiert dargestellt, aber ist hier schon ersichtlich: angenommen, im Destruktor von my_string könnte eine Exception geworfen werden, also insbesondere in Zeile 17 von obigen Listing, wenn das in Zeile 13 erzeugte copy Objekt abgeräumt wird, so wäre zum Zeitpunkt, wenn die Exception geworfen wird, die Zuweisung bereits erfolgt. Im Idealfall sollte allerdings eine Methode, welche eine Exception wirft, keinen Effekt haben (Stichwort: "Exceptiongarantie"). Oder anders ausgedrückt: wird im Zuweisungsoperator eine Exception geworfen, so sollte man davon ausgehen können, daß die Zuweisung nicht stattgefunden hat. Dies kann man aber nicht garantieren, sollte im Destruktor eine Exception geworfen worden sein.

5. Exceptions in Destruktoren

Auf den ersten Blick könnte man meinen, daß das Verhalten einer Exception im Destruktor recht analog zu dem in einem Konstruktor ist: daß wenn im Destruktor einer abgeleiteten Klasse eine Exception geworfen wird dies dazu führt, daß der Destruktor der Basisklasse nicht mehr aufgerufen wird. Dem ist aber nicht so! Auch die Destruktoren der Basisklassen werden aufgerufen, wie eine leichte Modifikation des Programmbeispiels oben zeigen würde (was meines Wissens jedoch nicht garantiert ist). Das Problem lauert an ganz anderer Stelle: Betrachten wir folgendes Programm, bei dem Zeile 17 einmal auskommentiert und einmal aktiv sei:










10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
#include <iostream>

class klasse {
public:
    klasse() {
        std::cout<<"constructor<<std::endl;
    }
    ~klasse() {
        std::cout<<"destructor<<std::endl;
        throw "FEHLER;     
    }
};

int main(int argc, char** argv) {
    try {
        klasse instanz;
        // throw "ANDERER FEHLER";
    }
    catch(const char* fehler) {
        std::cerr<<fehler<<std::endl;
        return 1;
    }
}

Wir betrachten also mit klasse eine Klasse, deren Destruktor jedenfalls eine Exception wirft. Der Destruktor wird automatisch aufgerufen, wenn der try Block verlassen wird (Zeile 18). Ist Zeile 17 nicht aktiv, so erhalten wir die Ausgabe, die wir vermutlich alle erwarten:

constructor
destructor
FEHLER

In diesem Fall scheint also alles in Ordnung zu sein: Der Destruktor wirft eine Exception und diese wird auch ordnungsgemäß gefangen. Wenn Zeile 17 allerdings scharf geschaltet wird, erscheint eine Ausgabe wie diese (sie ist abhängig vom verwendeten Compiler und Betriebssystem):

constructor
destructor
Aborted

Zunächst sollten wir klären, was geschieht: In Zeile 17 wird also eine Exception geworfen, deren Text "ANDERER FEHLER" lautet. Das bewirkt, daß der try Block verlassen wird, so daß damit implizit der Destruktor von klasse aufgerufen wird, welcher wiederrum eine Exception mit dem Text "FEHLER" wirft. Die Preisfrage lautet nun: welche der beiden Exceptions gewinnt, d.h. welche der beiden Texte sollte eigentlich ausgegeben werden? C++ löst diese Preisfrage recht drakonisch und sagt: "ich kann nicht zwei Exceptions gleichzeitig verarbeiten, das ist eine unerwartete Situation für mich" und ruft daher die Funktion unexpected auf, welche - auf meinem System - den Text "Aborted" ausgibt und - auf allen Systemen - das Programm beendet. Der Fall, daß zwei Exceptions gleichzeitig anliegen, kann im Prinzip nur bei dem sogenannten Stackunwinding auftreten, also der oben geschilderten Situation.

Also ist es recht gefährlich, eine Exception einfach so aus einem Destruktor heraus zu werfen. Nun gibt es in C++ eine Funktion namens uncaught_exception, welche angibt, ob der Destruktor im Zusammenhang mit dem Stackunwinding einer bereits geworfenen Exception aufgerufen wurde: gibt uncaught_exception false zurück, so handelt es sich um einen "normalen" Destruktor Aufruf und man könnte auf die Idee kommen, in diesem Fall eine Exception zu werfen (wird hingegen true zurückgegeben, so liegt bereits eine geworfene Exception vor, für die der entsprechende catch Block noch nicht erreicht ist. Dieser Fall tritt in unserem Beispiel auf, wenn wir Zeile 17 aktivieren und Zeile 18 ausgeführt wird: der catch Block ist zwar noch nicht erreicht, aber es werden bereits die Destruktoren der lokalen Objekte (hier: instanz) aufgerufen.

So kann man z.B. in dem Fall, in dem uncaught_exception true zurückliefert, eine Exception unterdrücken; im anderen Fall jedoch werfen. Angewendet auf unser Beispiel sieht der Code dann so aus:










10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
#include <iostream>
#include <exception>

class klasse {
public:
    klasse() {
        std::cout<<"constructor<<std::endl;
    }
    ~klasse() {
        std::cout<<"destructor<<std::endl;
        // Exceptions werden nur gewrofen, wenn keine andere anliegt
        if (!std::uncaught_exception()) {
            throw "FEHLER;     
        }
    }
};

int main(int argc, char** argv) {
    try {
        klasse instanz;
        // throw "ANDERER FEHLER";
    }
    catch(const char* fehler) {
        std::cerr<<fehler<<std::endl;
        return 1;
    }
}

Läßt man dieses Programm laufen, erhält man die folgende Ausgaben, welche anzeigt, daß im catch die erste geworfene Exception ankommt:

constructor
destructor
ANDERER FEHLER

Wenngleich dieser Code prinzipiell funktioniert, so ist diese Strategie in der Regel nicht empfehlenswert: Im Zusammenhang mit dem Zuweisungsoperator wurde bereits das Stichwort "Exceptiongarantie" genannt: Wenn in einer Funktion oder Methode eine Exception geworfen wird, so sollte im Idealfall die Methode keinen Effekt haben. Zwar läßt sich dieses Prinzip nicht immer ohne weiteres komplett durchhalten, aber es sollte die generelle Linie sein, nach der man seinen Code entwickelt. Erlaubt man nun in Destruktoren Exceptions, so wird es - wie oben gesehen - praktisch unmöglich, diesen Ansatz zu folgen. Daher gilt der generelle Merksatz: Werfe niemals Exceptions in Destruktoren.

Leider bedeutet dieser Merksatz auch, daß man in Destruktoren aufkommende Fehler entweder unter den Tisch fallen lassen muß (ganz schlecht), oder aber sich recht komplexe Mechanism einfallen lassen muß die in Destruktoren aufkommenden Fehler anderweitig zu propagieren (beispielsweise durch Speichern der Fehler und späterer Abfrage. Generell sollte man daher in Destruktoren so wenig wie möglich fehlerauslösende Operationen ausführen.

6. Fazit

  1. Exceptionsicherheit ist nur erreichbar, wenn man alle Exceptions fängt, welche geworfen werden.

  2. Im Falle einer Exception sind Resourcenlöcher zu vermeiden, d.h. man muß alle Resourcen, die man im normalen Programmablauf freigegeben hätte, gesondert - etwa in einem catch Block freigeben.

  3. Für Konstruktoren gilt das gleiche, wie unter Punkt 2 genannt. Es passiert nichts Magisches in Konstruktoren.

  4. Zuweisungsoperatoren dürfen niemals inkonsistente Objekte zurücklassen, die entsprechende Gegenstrategie bedeutet im zweifel, ein temporäres Objekt zu erstellen, was Zuweisungsoperatoren leider teuer macht. Sicherheit hat ihren Preis.

  5. Destruktoren dürfen niemals Exceptions werfen. Ein Verstoß dieser Regel führt dazu, daß man im Prinzip kaum noch nachvollziehen kann, welchen Zustand die involvierten Objekte nachher haben.


Ihre Meinung ist mir wichtig

Haben Sie einen Fehler gefunden, wollen etwas anmerken oder fragen? Gefällt Ihnen etwas nicht oder wollen Sie ein Lob loswerden? - Nur raus damit!