C++ Core Guidelines: goto considered evil

Wer keine Ausnahme werfen kann und wem auch final_action (finally) von der Guideline Support Library nicht zur Verfügung steht, hat ein Problem. Ausnahmezustände verlangen Ausnahmeaktionen: goto. Wirklich?

In Pocket speichern vorlesen Druckansicht 45 Kommentare lesen
Lesezeit: 7 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Wer keine Ausnahme werfen kann und wem auch final_action (finally) von der Guideline Support Library nicht zur Verfügung steht, hat ein Problem. Ausnahmezustände verlangen Ausnahmeaktionen: goto. Wirklich?

Um ehrlich zu sein, war ich sehr überrascht, dass die Guidelines goto exit; als letzte Rettung beschreiben. Hier sind die verbleibenden Regeln zur Fehlerbehandlung der C++ Core Guidelines.

Da die ersten drei Regeln stark zusammenhängen, werde ich sie zusammen beschreiben.

Die zentrale Idee von RAII ist recht einfach. Wenn du auf eine Ressource aufpassen sollst, verpacke diese in eine Klasse. Verwende den Konstruktor der Klasse zum Initialisieren und den Destruktor der Klasse zum Freigeben der Ressource. Falls du nun ein lokales Objekt der Klasse auf dem Stack erzeugst, passt die C++-Laufzeit automatisch auf die Ressource und du bist die Verantwortung los. Mehr Details zu RAII gibt es in meinem Artikel "Garbage Collection – No Thanks".

Was heißt es nun, RAII für Ressourcenmanagement zu simulieren? Stelle dir vor, du hast eine Funktion func, die eine Ausnahme wirft, wenn Gadget nicht erzeugt werden kann:

void func(zstring arg)
{
Gadget g {arg};
// ...
}

Wenn du keine Ausnahme werfen kannst, lässt sich RAII simulieren, indem du eine Methode valid für Gadget implementierst:

error_indicator func(zstring arg)
{
Gadget g {arg};
if (!g.valid()) return gadget_construction_error;
// ...
return 0; // zero indicates "good"
}

In diesem Fall muss der Aufrufer der Funktion deren Rückgabewert prüfen.

Die Regel E.26 ist sehr naheliegend. Falls es keine Möglichkeit wie beim Speichermangel gibt, dass sich das Programm von einem Fehler erholt, sollte es schnell fehlschlagen. Falls du keine Ausnahme in diesem Fall werfen kannst, rufe std::abort auf. Dies führt zur abnormal-Programmbeendigung:

void f(int n)
{
// ...
p = static_cast<X*>(malloc(n, X));
if (!p) abort(); // abort if memory is exhausted
// ...
}

Die abnormal-Programmbeendigung wird dann auftreten, wenn du keinen Signal Handler installierst hast, der das Signal SIBABRT fängt.

Die Funktion f verhält sich äquivalent zur folgenden Funktion:

void f(int n)
{
// ...
p = new X[n]; // throw if memory is exhausted (by default, terminate)
// ...
}

Jetzt schreibe ich über das Unwort goto in der Regel E.27.

Im Falle eines Fehlers müssen die folgenden Probleme gemäß der Guidelines gelöst werden:

  1. Wie signalisiert die Funktion ihren Fehlerfall?
  2. Wie können alle Ressourcen freigegeben werden, bevor die Funktion beendet wird?
  3. Was soll als Fehlerindikator verwendet werden?

Im Allgemeinen sollte deine Funktion zwei Rückgabewerte besitzen: den Wert und den Fehlerindikator. Dies lässt sich schön mit std::pair abbilden. Das Freigeben der Ressourcen wird aber sehr schnell zum Wartungsalbtraum. Dies gilt selbst dann, wenn die Aufräumarbeit in Funktionen gekapselt wird:

std::pair<int, error_indicator> user()
{
Gadget g1 = make_gadget(17);
if (!g1.valid()) {
return {0, g1_error};
}

Gadget g2 = make_gadget(17);
if (!g2.valid()) {
cleanup(g1);
return {0, g2_error};
}

// ...

if (all_foobar(g1, g2)) {
cleanup(g1);
cleanup(g2);
return {0, foobar_error};
// ...

cleanup(g1);
cleanup(g2);
return {res, 0};
}

Schaut doch gut aus. Oder?

Weißt du, für was DRY steht? Don't Repeat Yourself. Obwohl in dem Beispiel die Aufräumarbeit in Funktionen verpackt ist, besitzt der Code ein deutliches Geschmäckle, da die Funktionen mehrfach an verschieden Stellen aufgerufen werden. Wie lässt sich die Wiederholung vermeiden? Verschiebe die Aufräumarbeit an das Ende der Funktion und springe hin:

std::pair<int, error_indicator> user()
{
error_indicator err = 0;

Gadget g1 = make_gadget(17);
if (!g1.valid()) {
err = g1_error; // (1)
goto exit;
}

Gadget g2 = make_gadget(17);
if (!g2.valid()) {
err = g2_error; // (1)
goto exit;
}

if (all_foobar(g1, g2)) {
err = foobar_error; // (1)
goto exit;
}
// ...

exit:
if (g1.valid()) cleanup(g1);
if (g2.valid()) cleanup(g2);
return {res, err};
}

Zugegeben, mit der Hilfe von goto wird die Struktur der Funktion deutlich übersichtlicher. Im Falle eines Fehlers wird lediglich der Fehlerindikator (1) gesetzt. Ausnahmezustände verlangen Ausnahmeaktionen.

Zuerst einmal, hier ist ein Beispiel für eine Ausnahmespezifikation:

int use(int arg)
throw(X, Y)
{
// ...
auto x = f(arg);
// ...
}

Das bedeutet, dass die Funktion use eine Ausnahme vom Typ X oder Y werfen kann. Falls eine andere Ausnahme geworfen wird, führt dies direkt zu std::terminate.

Die dynamische Ausnahmespezifikationen mit Argument throw(X, Y) und ohne Argument throw() ist deprecated seit C++11. Die dynamische Ausnahmespezifikation mit Argument wird mit C++17, die ohne Argument mit C++20 entfernt. throw() ist äquivalent zu noexcept. Hier gibt es mehr Details dazu: "C++ Core Guidelines: Der noexcept-Spezifier und -Operator".

Wenn du die letzte Regel nicht kennst, kann dies zu unangenehmen Überraschungen führen.

Eine Ausnahme wird auf Grundlage der Best-fit-Strategie gefangen. Das heißt, die erste Ausnahmebehandlung, die zur aktuellen Ausnahme passt, wird verwendet. Dies ist der Grund, dass die Ausnahmebehandlung vom Speziellem zum Allgemeinem strukturiert werden soll. Wenn nicht, kommt die spezifische Ausnahmebehandlung nie zum Einsatz. Im folgenden Beispiel ist DivisionByZeroException von std::exception abgeleitet.

try{
// throw an exception (1)
}
catch(const DivisionByZeroException& ex){ .... } // (2)
catch(const std::exception& ex{ .... } // (3)
catch(...){ .... } // (4)

In diesem Fall, kommt die Ausnahmebehandlung in DivisionByZeroException (2) zuerst zum Einsatz, falls die Ausnahme (1) geworfen wird. Falls die spezielle Ausnahmebehandlung nicht passt, werde alle Ausnahme, die von std::exception abgeleitet sind, in der folgenden Zeile (3) gefangen. Die letzte Ausnahmebehandlung besitzt eine Ellipse (4) und kann damit alle Ausnahme fangen.

Wie bereits angekündigt, werde ich mich im nächsten Artikel mit den fünf Regeln zur Konstanz und Immutablilität beschäftigen.

()