Aus Linux-Magazin 06/2021

C++-Core-Guidelines – Folge 58

© mikekiev / 123RF.com

Fehlerbehandlung bildet einen integralen Bestandteil jeder guten Softwarearchitektur. Wer sie beim Design nicht von Anfang an berücksichtigt, riskiert, dass es im Fehlerfall vernehmlich kracht.

Bevor wir uns im Folgenden mit den fast 30 Regeln zur Fehlerbehandlung in den C++ Core Guidelines beschäftigen [1], stellt sich zuerst die elementare Frage, welche Aspekte eigentlich zur Fehlerbehandlung gehören. Sie sollte vier Punkte berücksichtigen: Sie muss Fehler erkennen, Informationen zu dem Fehler an einen Handler übermitteln, das Programm in einem gültigen Zustand halten sowie Ressourcenlecks vermeiden.

Primär sollte man laut den C++ Core Guidelines Ausnahmen verwenden. David Abrahams [2], einer der Gründer der Boost-Library und ehemaliges Mitglied des ISO-C++-Standardisierungskomitees, formalisiert in seinem Dokument “Exception-Safety in Generic Components” [3], was Exception-Safety bedeutet. Die Abrahams Guarantees [4] beschreiben einen grundlegenden Vertrag, der greift, wenn man Exception-sicheren Code analysiert. Die vier Abstufungen der Abrahams Guarantees lauten im Original:

  • No-throw guarantee, also known as failure transparency: Operations are guaranteed to succeed and satisfy all requirements even in exceptional situations. If an exception occurs, it will be handled internally and not observed by clients.
  • Strong exception safety, also known as commit or rollback semantics: Operations can fail, but failed operations are guaranteed to have no side effects, so all data retain their original values.
  • Basic exception safety, also known as a no-leak guarantee: Partial execution of failed operations can cause side effects, but all invariants are preserved and there are no resource leaks (including memory leaks). Any stored data will contain valid values, even if they differ from what they were before the exception.
  • No exception safety: No guarantees are made.

Hier nochmals die vier Punkte kurz und kompakt: Die stärkste Garantie lautet, dass keine Ausnahme auftritt. Ist das doch der Fall, lässt sich das System wieder auf den Zustand vor der Ausnahme zurücksetzen. Punkt 3 sichert zu, dass kein Ressourcenleck durch eine Ausnahme entsteht und das Programm sich stets in einem wohldefinierten Zustand befindet. Der letzte Punkt gibt schlicht keinerlei Garantie.

Oft ist es nicht möglich, dass ein Programm sich vollkommen von einer Ausnahme erholt. Dann gibt es zwei Möglichkeiten: Zuerst einmal kann man das Programm in einem einfachen Zustand ausführen. Das heißt, dass die Software nicht mehr ihre volle Funktionalität besitzt, aber zumindest ihre Grundfunktionen noch zur Verfügung stellt. Das könnte etwa bedeuten, dass sich ein Defibrillator nicht mehr zum Defibrillieren verwenden lässt, aber zumindest noch Anweisungen an den Operator geben kann.

Im einfachsten Fall startet man das Programm lediglich neu. Das erweist sich oft als schnellster und einfachster Weg, um wieder in einen sicheren Zustand zu kommen und die volle Funktionalität anzubieten.

Nach diesen doch recht theoretischen Betrachtung steigen wir jetzt in die Praxis ein. Zuallererst ist die Fehlerbehandlungsstrategie ein elementarer Bestandteil einer professionellen Softwarearchitektur.

Grundlegende Gedanken

Die erste Regel [5] widmet sich direkt der grundlegenden Frage: “E.1: Develop an error-handling strategy early in a design.” Sie besteht nur aus folgender Begründung: “A consistent and complete strategy for handling errors and resource leaks is hard to retrofit into a system.”

Um ehrlich zu sein: Das ist denn doch zu dünn für eine Begründung. Bei der Fehlerbehandlung handelt es sich um einen sogenannten Cross-cutting Concern wie Logging oder Security. Solche Cross-cutting Concerns lassen sich nicht einfach modularisieren und daher schwierig umsetzen. Sie haben darüber hinaus Einfluss auf die ganze Software.

Exception-Safety bildet einen wichtigen Bestandteil des Interface-Entwurfs und muss somit von Anfang an im Fokus stehen. Nun stellt sich die Frage: Was ist ein Interface? Die relativ allgemein gehaltene Definition eines Interfaces: Ein Interface ist ein Protokoll zwischen zwei Komponenten. Eine Komponente kann eine Funktion, ein Objekt, ein Subsystem oder das ganze System sein, aber auch eine externe Abhängigkeit wie Hardware oder ein Betriebssystem.

An der Grenze gibt es zwei Arten der Kommunikation: reguläre und irreguläre. Die reguläre Kommunikation ist der funktionale Aspekt des Interfaces oder anders ausgedrückt: was das System tun soll. Die irreguläre Kommunikation steht für die nichtfunktionalen Aspekte des Interfaces. Sie geben vor, wie sich das System verhalten soll.

Ein großer Teil der nichtfunktionalen Aspekte ist die Fehlerbehandlung oder einfach das, was schiefgehen kann. Oft werden die nichtfunktionalen Aspekte schlicht Qualitätsattribute genannt. Doch wie lassen sich insbesondere die nichtfunktionalen Aspekte eines Interfaces richtig entwerfen?

Im Fall der Fälle

Auf den angemessen Umgang mit Ausnahmen gehen insbesondere die beiden Regeln E.17 [6] und E.18 [7] ein: “Don’t try to catch every exception in every function” und “Minimize the use of explicit try/catch”.

Aus der Perspektive des Kontrollflusses betrachtet besitzt »try/catch« viele Gemeinsamkeiten mit der »goto«-Anweisung. Wird eine Ausnahme geworfen, springt der Kontrollfluss direkt zu dem Ausnahme-Handler, der sich in einer ganz anderen Funktion oder einer Funktion in einem anderen Subsystem befinden kann. Letztlich führt das oft zu Spaghetti-Code, als Quellcode mit einen schwer vorherzusagenden Kontrollfluss, der schwierig zu pflegen ist.

Jetzt stellt sich naturgemäß die Frage: Wie sollte man Ausnahmebehandlung strukturieren? Als Erstes gilt es zu prüfen, ob sich die Ausnahme lokal verarbeiten lässt. Falls nicht, lässt man die Ausnahme weiter propagieren, bis genug Kontext vorliegt, um sie zu verarbeiten. Oft eignen sich Subsystemgrenzen dazu, Ausnahmen zu verarbeiten, denn der Anwender soll vor ihnen geschützt werden.

An dieser Grenze gibt es reguläre und irreguläre Kommunikation. Die reguläre Kommunikation umfasst den funktionalen Aspekt des Interfaces oder was das System tun soll. Die irreguläre Kommunikation steht für die nichtfunktionalen Aspekte oder wie sich das System verhalten soll – das umfasst auch die Fehlerbehandlung.

Entwirft man Interfaces anhand dieser zwei Kanäle, hat dies einen weiteren großen Vorteil: Ein entsprechendes Design strukturiert die Software, deren einzelne Komponenten sich dann einfach dokumentieren und als Einheit testen lassen. Allerdings setzen Programmierer Ausnahmen gern sehr innovativ falsch ein.

Dos und Don’ts

Bevor die Don’ts zu Ausnahmen im Fokus dieses Artikels stehen, beschäftigt sich die Regel E.3 [8] mit den Dos: “Use exceptions for error handling only.”

Eine Ausnahme teilt dem Aufrufer einer Funktion mit, dass diese ihre Aufgabe nicht erfüllen kann. Die möglichen Gründe für eine Ausnahme sind vielfältig: Eine Bedingung an die Funktion kann nicht eingehalten werden, ein Konstruktor vermag das Objekt nicht zu erzeugen, eine Out-of-Range-Ausnahme tritt auf, oder eine Ressource lässt sich nicht anfordern.

Wirft nun die Funktion eine Ausnahme, muss der Aufrufer der Funktion sich um die Ausnahme kümmern und kann sie nicht wie bei einem Fehlercode ignorieren. Das bringt uns zur Todsünde beim Einsatz von Ausnahmen: Ausnahmen für den regulären Kontrollfluss einzusetzen. Das Beispiel in Listing 1 trennt den regulären Kontrollfluss nicht von jenem bei einer Ausnahme. Selbst im Erfolgsfall wirft der Code eine Ausnahme, beendet die For-Schleife und gibt den Index der Suche mittels einer Ausnahme zurück, die er in den Rückgabewert übersetzt (»return i;«). Im Fehlerfall verwendet der Code die direkte Return-Anweisung »return -1;« – ziemlich verwirrend.

Gibt es weitere Regeln beim Umgang mit Ausnahmen zu beachten? Auf jeden Fall!

Listing 1

Kontrollfluss per Ausnahme

// don't: exception not used for error handling
int find_index(std::vector<std::string>& vec, const std::string& x)
{
  try {
    for (auto i = 0; i < vec.size(); ++i)
      if (vec[i] == x) throw i;  // found x
  } catch (int i) {
      return i;
  }
  return -1;   // not found
}

More Don’ts

Regel E.14 [9] fordert, weder Standardausnahmen noch Built-in-Datentypen als Ausnahmen zu verwenden: “Use purpose-designed user-defined types as exceptions (not built-in types).” Die C++ Core Guidelines bieten auch Beispiele zu den zwei Don’ts an; das erste verwendet einen Built-in-Datentyp als Ausnahme.

In Listing 2 dient ein »int« als Ausnahme. In diesem Fall ist die Ausnahme vom Datentyp »int« ohne jegliche Semantik. Für was die Zahl 7 steht, kann man nur dem Kommentar entnehmen; stattdessen sollte aber ein selbsterklärender Datentyp zum Einsatz kommen. Der Kommentar kann auch in die Irre führen. Um sicherzugehen, muss man die Dokumentation konsultieren.

Listing 2

Built-in-Datentyp als Ausnahme

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"
    // ...
  }

Darüber hinaus lässt sich einer Ausnahme vom Datentyp »int« keine zusätzliche Information hinzufügen. Dient die Zahl 7 als Ausnahme, darf man wohl davon ausgehen, dass auch die Zahlen 1 bis 6 für Ausnahmen zur Verfügung stehen; 1 signalisiert dabei wohl einen unspezifischen Fehler. Das ist viel zu kompliziert, fehleranfällig, schwierig zu lesen und zu pflegen.

Das zweite Beispiel zeigt: Verwendet man anstelle eines Built-in-Datentyps eine Standardausnahme, ist das zwar besser, aber noch lange nicht gut. Listing 3 zeigt, dass sich in eine Standardausnahme zusätzliche Kontextinformation verpacken lässt. Warum ist eine solche Ausnahme nicht gut? Sie ist zu unspezifisch.

Es handelt sich lediglich um einen »runtime_error«. Fängt der Aufrufer der Funktion die Ausnahme mittels »std::runtime_error«, erhält er keinerlei Hinweis darauf, ob es sich um einen allgemeinen Fehler der Art input buffer too small handelt oder um einen spezifischen Fehler etwa des Input-Subsystems.

Listing 3

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"
    // ...
  }

Um dieses Problem zu lösen, sollte man die spezifische Ausnahme als benutzerdefinierte Ausnahme von »std::exception« ableiten. In Listing 4 kann der Aufrufer des Input-Subsystems ganz spezifisch die Ausnahme mittels »catch(const InputSubSystemException& ex)« fangen. Zudem lässt sich die Ausnahmehierarchie durch Ableiten von »InputSubSystemException« verfeinern.

Listing 4

Benutzerdefinierte Ausnahme

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

Ausblick

Das Deklarieren von Objekten und Methoden als konstant besitzt zwei große Vorteile: Zuerst einmal beschwert sich der Compiler, wenn der Vertrag bricht. Zusätzlich teilt das Interface dem Anwender mit, dass die Funktion ihre Argumente nicht verändert. Mit der Unveränderlichkeit von Objekten beschäftigen sich auch die C++ Core Guidelines und damit der nächste Artikel dieser Serie zu Best Practices für modernes C++. (jlu)

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben