Exception Handling (Ausnahmebehandlung)

Unter Exceptions (Ausnahmen) versteht man Fehler oder Sondersituationen, die bei der Programmausführung in einer Bibliothek auftreten können und direkt an das aufrufende Programm gemeldet werden sollen. Beispiele sind falsche Eingaben, arithmetische Fehler (z.B. Divisionen durch Null) und Bereichsüberschreitungen bei Vektoren.

Beim Entwurf von Bibliotheken und Klassen ist anfangs nicht klar, wie und vor allem an welcher Stelle Fehler geeignet zu behandeln sind. Insbesondere soll der Anwender der Bibliothek entscheiden, wie bei einem Fehler verfahren wird: Falls es dazu kommt, dass der Arbeitsspeicher nicht für eine Speicheranforderung ausreicht, ist es für viele Anwendungen akzeptabel, sie geordnet zu beenden. Andere müssen auch in diesem Fall weiter ausgeführt werden. Anstatt den Fehler durch alle Funktionen explizit durchzureichen und dafür alle Definitionen zu ändern, wird bei Exceptions der Fehler beim Auftreten geworfen (throw) und in der Anwendung durch den zugehörigen Exception Handler gefangen (catch).

Die Syntax für den throw-Ausdruck lautet:

throw expression ;

Der zu überwachende Code steht in einem try-Block, der von mindestens einem Exception- Handler überwacht wird. Es können beliebig viele catch-Blöcke zu einem try-Block angegeben werden. Sie werden der Reihenfolge nach berücksichtigt.

try {
    // Dieser Teil des Codes wird überwacht // hier wird eine Exception geworfen
}
catch (const Exceptionclass1& aExc) {
    // hier werden Ausnahmen der Exceptionclass1
    // und aller Unterklassen bearbeitet
}
catch (const Exceptionclass2& aExc) {
    // hier werden Ausnahmen der Exceptionclass2
    // und aller Unterklassen bearbeitet
}
catch (...) {
    // hier werden alle übrigen Ausnahmen behandelt
}

Wird eine Exception im Programm nicht gefangen, erfolgt eine Systemfehlermeldung mit Programmabbruch. Daher gibt es den (...)-catch-Block, der alle Exceptions fängt. Nach diesem Block können keine anderen Exceptions mehr gefangen werden. Dieser allgemeine Exception-Handler sollte nur sehr vorsichtig benutzt werden. Man beachte, dass auch Unterklassen gefangen werden:

catch(Base&) {
    // fängt auch Derived -Objekte
}
catch (Derived&) {
    // hat keinen Effekt
}

Bei einer Hierarchie von Exceptions sollte die Ausnahme mittels einer polymorphen Funktion bearbeitet werden:

catch(Base& e) {
    // fängt alle Base-Objekte und ruft die richtige Bearbeitungsfunktion auf 
    e.vTreat();
}

Die Exception ist dabei ein Objekt, das beim Werfen erstellt wird, und dem Handler weitergegeben wird. Für diesen Zweck werden eigene Klassen verwendet. Sie sollten von std::exception oder anderen Exception-Typen aus der Standardbibliothek erben:

#include <exception>
#include <stdexcept> 
using namespace std;

class MyException : public logic_error { 
    // ...
};

Die meisten von std::exception geerbten Klassen der Standardbibliothek fordern einen Beschreibungsstring beim Konstruieren (allerdings nicht exception selbst), der an den Konstruktor der Standardexception weitergegeben wird. Weitere Datenelemente für eigene Informationen können natürlich hinzugefügt werden.

class MyException : public logic_error {
    int p_i;
public:
    MyException (const string& what, int i) : logic_error(what), p_i(i) {}
};

Zur Verdeutlichung diene zunächst folgendes Beispiel: Bei der Array-Klasse aus dem vorigem Kapitel soll at(int) eine Referenz auf das Element an einem Index zurückgeben. Falls der Index aber negativ oder außerhalb der Länge des Arrays ist, ist das nicht möglich und eine Exception vom Typ std::out_of_range wird geworfen:

template <typename T, int N> T& Array<T, N>::at(int i)
{
    if (0 <= i && i < N)
        return p_array[i];
    else
        throw std::out_of_range("Fehler: Array Index außerhalb des gültigen Bereichs.");
}

Im Fehlerfall wird der Code nach dem throw-Statement nicht mehr ausgeführt und es wird direkt zu einem passenden Handler gesprungen. Um diese Fehlersituation zu berücksichtigen, muss der Aufruf der Funktion at() in einem try- Block stehen, der einen Exception-Handler für out_of_range enthält:

try {
    // Dieser Block wird vom Exception-Handler überwacht.
    /* ... */
    x = array.at(3);
    // Ausführung springt zum Handler
    /* ... */
    }
catch (const out_of_range& e) { // Handler
    cout << e.what() << endl;
}

Beim Wurf der Exception wird mit dem Code des entsprechenden Handlers fortgesetzt. Der entsprechende try-Block wird nur bis zum Aufruf ausgeführt. Direkt danach wird also die Beschreibung auf cout ausgegeben. Das geworfene Objekt wird an die Variable des catch-Blocks (hier e) übergeben und kann so bearbeitet werden.

Da die Exception-Objekte der Standardbibliothek sehr allgemein sind, sollten bei größeren Projekten eigene Exception-Klassen eingeführt werden, ggf. in einer Hierarchie. Dadurch können Fehler genauer abgebildet werden und es werden Kollision mit Ausnahmen anderer Programmteile vermieden.

Exceptions sollen aber nicht nur ausgebbar sein. Oft erfordern Fehler eine Behandlung. Daher empfiehlt sich eine (polymorphe) Bearbeitungsfunktion (z.B. vTreat()), die dann im catch-Block aufgerufen werden kann. Die Exception-Klassen müssen dann Daten- elemente für die Daten haben, die die Ausnahme beschreiben. Diese werden beim throw durch Aufruf entsprechender Konstruktoren gefüllt.

Eine Hierarchie für mathematische Fehler könnte dann so aussehen:

class MathError : std::exception {
    // Fehlerbeschreibung
    string what; 

public:    
    // Konstruktor mit Fehlerbeschreibung
    MathError(const string& s) : what(s){}
    
    virtual void vTreat () const {
        // Ausgabe der Beschreibung
        cout << what << endl; 
    }
};

class NegRoot: public MathError {
    // Fehlerhaftes (negatives) Argument
    double p_arg;
    
public:    
    // Konstruktor mit Argument
    NegRoot(double arg)
    // Beschreibung konstant
    : MathError("Fehler: Wurzel aus negativer Zahl"), p_arg(arg){}

    // Fehlerbehandlung: Ausgabe der Beschreibung mit 􏰀→ Argument
    void behandeln () const override {
        MathError::vTreat();
        cout << " Argument: " << p_arg << endl;
    }
};

Ein Exception Handler dazu könnte dann die Fehler über eine Referenz zu MathError abfangen und behandeln:

try {
    /* ... */
}
catch (const MathError& e) {
    // Handler für MathError und alle Ableitungen davon
    e.vTreat();
}

Ebenso wären separate catch-Blöcke möglich (z. B. wenn keine polymorphe Behandlung des Fehlers existiert):

try {
    /* ... */
}
catch (NegRoot& e) {
    // behandle NegRoot
}
catch (MathError& e) {
    // behandle alle anderen MathError
}

Im folgenden Beispiel soll, wenn ein Fehler auftritt auch der Name der geöffneten Datei ausgegeben werden:

void f(string file) { 
    ifstream fin(file); 
    try {
        // ...
    } 
    catch (const exception& e) {
        cout << "Fehler: " << e.what() << "bei " << file << endl;
    }
}

Allerdings kann in dieser Funktion nicht alles notwendige ausgeführt werden (z.B. Abbruch) und der Fehler soll an das Hauptprogramm weitergegeben werden. Im obigen Beispiel geschieht das nicht. In diesen Situationen kann die aktuelle Exception im catch-Block für den aufrufenden Code noch einmal geworfen werden. Dies geschieht durch ein throw ohne Argumente:

catch (const exception& e) {
    cout << "Fehler: " << e.what() << "bei " << file << endl;
    // aktuelle Exception noch einmal werfen 
    throw;
}

Exceptions sollten nicht für jede Situation verwendet werden. Bei der Methode find eines Containers ist es möglich, dass ein gesuchtes Element nicht existiert. Es ist sinnvoll, dass im Rückgabetyp abzubilden, zum Beispiel mit einem End-Iterator. Bei anderen Funktionen ist ein Scheitern nicht normal, zum Beispiel bei Indexoperationen. Diese geben normalerweise Referenzen zurück. Wenn allerdings der Index außerhalb des validen Bereichs ist, wird eine Exception geworfen. Insbesondere wenn ein Konstruktor ein Objekt nicht konstruieren kann, sind Exceptions der einzige Weg, die Konstruktion abzubrechen.

Als Referenz sind hier die wichtiges Arten von Exceptions aus der Exception-Hierarchie in der Standardbibliothek angegeben:

Das Werfen einer Ausnahme beeinflusst die Schnittstelle einer Funktion, da die aufrufende Funktion einen try-Block und entsprechende Exception Handler bereitstellen muss. Daher ist es sinnvoll, die Ausnahmen, die geworfen werden können als Teil der Funktionsdeklaration zu spezifizieren. Die Syntax für eine erweiterte Funktionsdeklaration lautet:

<Rückgabetyp > <Funktionsname > (<Parameterliste >) throw 􏰀→ (<Ausnahmeliste >);

also z.B.

void fkt(int i) throw (exc1 , exc2);

Dies garantiert dem Aufrufer, dass die Funktion fkt nur Ausnahmen aus den Klassen (Typen) exc1 und exc2 und davon abgeleiteten Klassen wirft. Der Versuch, eine andere Ausnahme zu werfen, führt dann zu einem Übersetzungsfehler. Die erweiterte Funktions- deklaration muss sowohl bei der Definition als auch beim Prototyp benutzt werden. Eine Funktionsdeklaration ohne throw erlaubt das Werfen beliebiger Ausnahmen. Eine Funktion, die keine Ausnahmen wirft, kann dies durch den Zusatz throw() explizit in die Schnittstelle aufnehmen.


Übungsaufgaben zu diesem Kapitel finden Sie hier.