C++ Core Guidelines: Regeln zur Ausnahmebehandlung

Modernes C++  –  0 Kommentare

Was ist die richtige Art und Weise, Ausnahmen zu werfen und zu fangen? Insbesondere geht es um die Frage, wann man eine Ausnahme wirft und wie man diese fangen sollte.

Um diese Regeln geht es in diesem Artikel:

E.14: Use purpose-designed user-defined types as exceptions (not built-in types)

Du sollst weder Standardausnahmen noch vor allem Built-in-Datentypen als Ausnahmen verwenden. Hier sind Beispiele zu den zwei Don'ts der Guidelines:

Ein Built-in-Datentyp
void my_code()     // Don't
{
// ...
throw 7; // 7 means "moon in the 4th quarter"
// ...
}

void your_code() // Don't
{
try {
// ...
my_code();
// ...
}
catch(int i) { // i == 7 means "input buffer too small"
// ...
}
}

In diesem Fall ist die Ausnahme lediglich vom Datentyp int ohne jegliche Semantik. Für was die Zahl 7 steht, steht im Kommentar, sollte aber ein selbsterklärender Datentyp sein. Der Kommentar kann falsch sein. Um sicher zu gehen, musst du in der Dokumentation nachsehen. Darüber hinaus kannst du keine zusätzliche Information an eine Ausnahme vom Datentyp int fügen. Falls du eine 7 als Ausnahme hast, gebe ich davon aus, dass zumindest die Zahlen 1 bis 6 für Ausnahmen zur Verfügung stehen. 1 steht dabei für einen unspezifischen Fehler. Das ist viel zu kompliziert, fehleranfällig, schwierig zu lesen und zu pflegen.

Eine Standardausnahme
void my_code()   // Don't
{
// ...
throw runtime_error{"moon in the 4th quarter"};
// ...
}

void your_code() // Don't
{
try {
// ...
my_code();
// ...
}
catch(const runtime_error&) { // runtime_error means "input buffer too small"
// ...
}
}

Eine Standardausnahme anstelle eines Built-in-Datentyps als Ausnahme zu verwenden, ist besser, denn du kann zusätzliche Informationen in der Ausnahme verpacken. Dies ist besser, aber nicht gut. Warum? Die Ausnahme ist zu unspezifisch. Es ist lediglich ein runtime_error. Stelle dir vor, die Funktion my_code ist Teil eines Input-Sub-Systems. Falls der Aufrufer der Funktion die Ausnahme mittels std::runtime_error fängt, besitzt dieser keine Hinweis, ob er sich mit einem allgemeinen Fehler der Art "input buffer too small" oder einem spezifischen Fehler des Input-Sub-Systems "input device is not connected" beschäftigt.

Um dieses Problem zu lösen, solltest du deine spezifische Ausnahme von std::exception ableiten. Hier ist ein einfaches Beispiel:

class InputSubSystemException: public std::exception{
const char* what() const noexcept override {
return "Provide more details to the exception";
}
};

Jetzt kann der Aufrufer des Input-Sub-Systems ganz spezifisch die Ausnahme mittels catch(const InputSubSystemException& ex) fangen. Zusätzlich ist es möglich, die Ausnahmehierarchie durch Ableitung von InputSubSystemException zu verfeinern.

E.15: Catch exceptions from a hierarchy by reference

Wenn du eine Ausnahme einer Hierarchie by-value fängst, lauert Slicing:

void subSystem(){
// ...
throw USBInputException();
// ...
}

void clientCode(){
try{
subSystem();
}
catch(InputSubSystemException e) { // slicing may happen
// ...
}
}

Durch das Fangen der Ausnahme vom Datentyp USBInputException by-value to InputSubSystemException, tritt Slicing in Aktion und die Ausnahme e besitzt den einfacheren Typ InputSubSystemException. Die Details zu Slicing lassen sich auf meinem früheren Artikel nachlesen: "C++ Core Guidelines: Regeln zu Don'ts".

Gerne bringe ich die wichtigen Aussage dieser Regel auf den Punkt:

  1. Fange Ausnahme mittels einer konstanten Referenz. Verwende nur eine Referenz, wenn diese verändert werden soll.
  2. Falls du eine Ausnahme e erneut werfen willst, verwende lediglich throw und nicht throw e. Im zweiten Fall wird die Ausnahme kopiert.

E.16: Destructors, deallocation, and swap must never fail

Diese Regel ist recht offensichtlich. Destruktoren und die Deallokation sollten nie eine Ausnahme auslösen, da es keine zuverlässige Art gibt, eine Ausnahme während der Destruktion eines Objekts zu verarbeiten.

swap wird häufig als Grundbaustein für die Copy- und Move-Semantik eines Datentyps verwendet. Falls eine Ausnahme während swap auftritt, bleibt ein nicht oder nur teilweise initialisiertes Objekt zurück. Hier geht es mehr Details zum noexcept swap: "C++ Core Guidelines: Vergleiche und die Funktion swap".

E.17: Don’t try to catch every exception in every function und E.18: Minimize the use of explicit try/catch

Aus der Perspektive des Kontrollflusses betrachtet, besitzt try/catch viel gemein mit der goto-Anweisung. Das heißt, wenn eine Ausnahme geworfen wird, springt der Kontrollfluss direkt zu dem Ausnahme-Handler, der in einer ganz anderen Funktion oder einer Funktion in einem anderen Sub-System sein kann. Letztlich führt dies gerne zu Spaghetti-Code. Dies ist Sourcecode, der einen schwer zu vorhersagen Kontrollfluss besitzt und damit schwierig zu pflegen ist.

Am Ende sind wir wieder bei der ersten Regel zur Fehlerbehandlung: E.1: Develop an error-handling strategy early in a design.

Jetzt ist natürlich die Frage: Wie solltest du eine Ausnahmebehandlung strukturieren. Ich denke, du solltest dir die Frage stellen: Kann die Ausnahme lokal verarbeitet werden? Falls ja, tue es. Falls nein, lasse die Ausnahme weiter propagieren, bis du diese richtig verarbeiten kannst. Oft sind Sub-System Grenzen die geeignete Stellen, Ausnahmen zu verarbeiten, denn du willst den Anwender des Sub-Systems vor beliebigen Ausnahmen schützen. An der Grenze gibt es die reguläre und irreguläre Kommunikation. Die reguläre Kommunikation ist der funktionale Aspekt des Interfaces oder was das System tun soll. Die irreguläre Kommunikation steht für die nichtfunktionalen Aspekte des Interfaces oder wie sich das System verhalten soll. Ein großer Teil der nichtfunktionalen Aspekte ist die Fehlerbehandlung und damit die richtige Stelle um Ausnahmen zu verarbeiten.

Wie geht's weiter?

Sechs Regeln zu Fehlerbehandlung sind in den C++ Core Guidelines noch übriggeblieben. Sie sind natürlich mein Thema für den nächsten Artikel. Danach geht es weiter mit Konstanten und Immutablilität.