Ausnahmebehandlung

Ein wesentlicher Gesichtspunkt beim Konstruieren von Programmen ist die Frage, wie eine angemessene Fehlerbehandlung erfolgen soll.

Wenn das Auftreten eines Fehlers in einem Programmteil erfolgt, in dem auch die Behandlung sinnvoll möglich ist, dann hat man in keiner der herkömmlichen Programmiersprachen nennenswerte Probleme.

Schwieriger wird es, wenn das Auftreten eines Fehlers und eine sinnvolle Behandlung im Programm weit auseinanderliegen, und die betroffenen Programmteile nichts voneinander wissen, weil sie womöglich von verschiedenen Personen oder gar von verschiedenen Firmen stammen.

Beispielsweise könnte vom Hauptprogramm aus bald nach dem Programmstart eine Funktion init() aufgerufen werden, die alle Initialisierungen machen soll (Konfigurationsdateien lesen, Benutzeroberfläche initialisieren, Aufbau einer Verbindung zu einer Datenbank etc.). Einige Unterprogrammebenen tiefer tritt dabei ein Fehler auf: möglicherweise in einer zugekauften Bibliothek während der Initialisierung einer Datenbank. Beim Auftreten des Fehlers kann nicht sinnvoll reagiert werden (Wenn überhaupt wäre nur die Ausgabe einer Fehlermeldung sinnvoll, aber wohin? Ob das Programm unter einer grafischen Benutzeroberfläche läuft, wenn ja unter welcher, kann der Entwickler der Datenbankbibliothek nicht wissen. Wie sollte also ein Fehler ausgegeben werden? Mehr als eine Fehlerausgabe kann aber hier gar nicht stattfinden). Innerhalb von init() könnte sinnvoller reagiert werden, indem beispielsweise eine Fehlermeldung angemessen dem Benutzer gezeigt wird, und anschließend von Benutzer nach dem Namen einer anderen Datenbank gefragt wird, um damit einen zweiten Versuch zu unternehmen. Wenn also in init() auf einen Fehler reagiert werden kann, wie kommt dann die Information über den aufgetretenen Fehler dahin?

Der klassische Weg wäre, in jedem Unterprogramm das Auftreten von Fehlern vorzusehen, und das Unterprogramm mit einem speziellen Rückgabewert zu beenden. Das wirft aber Probleme auf:

Eine Lösung aller erwähnten Probleme auf einen Schlag hat man mit dem Konzept der Ausnahmen zur Verfügung.

Die Verwendung ist sehr elegant und einfach: Beim Auftreten eines Fehlers wird eine Ausnahme ,,geworfen`` (mit throw).
Beispiel:
throw "so gehts nicht!"
Dieses Werfen einer Ausnahme bedeutet, daß ein Objekt eines beliebigen Typs hinter throw angegeben wird, und damit der aktuelle Block beendet wird (und dabei alle automatischen Variablen sauber zerstört werden), dann der umgebende Block ebenso beendet wird, und so weiter. Alle auf dem Stack liegenden Variablen werden also von unten weg eine nach der anderen aufgeräumt (in der umgekehrten Reihenfolge ihres Erzeugens), und alle so beendeten Unterprogramme werden verlassen, um dann beim aufrufenden Unterprogramm ebenso zu verfahren. Dieser Vorgang heißt auch oft stack unwinding.

Das Aufräumen geht solange weiter, bis ein Block erreicht wird, der ausdrücklich eine geworfene Ausnahme ,,auffängt`` (wenn es keinen solchen Block gibt, dann endet das Aufräumen mit dem Beenden des aktuellen Programms!).

Ein Auffangen von Ausnahmen kann man mit einen try-Block erreichen, beispielsweise etwa so:
try
{
auszuführender Code, Funktionsaufrufe etc.
}
catch( int i )
{
Fehlerbehandlung, falls ein int geworfen wurde
}
catch( double d )
{
Fehlerbehandlung, falls ein double geworfen wurde
}
catch( const char * text )
{
Fehlerbehandlung, falls ein const char* geworfen wurde
}
catch( CPKW &f )
{
Fehlerbehandlung, falls ein CPKW geworfen wurde
}
catch( CFahrzeug &f )
{
Fehlerbehandlung, falls ein CFahrzeug geworfen wurde
}
catch( ... )
{
Fehlerbehandlung, falls etwas anderes geworfen wurde
}
Jedenfalls: hier geht es weiter, egal ob Fehler oder nicht

Wenn also im Teil auszuführender Code, Funktionsaufrufe etc. irgendwo ein Fehler auftritt (möglicherweise mehrere Unterprogrammebenen tiefer), dann wird solange der Stack aufgeräumt, bis ein try-Block erreicht wird. Hier endet das Aufräumen zumindest vorläufig, und es werden alle catch-Zweige nacheinander geprüft, ob einer zum Typ der geworfenen Ausnahme paßt. Falls vorhanden, trifft der catch( ... )-Zweig auf alle nicht anderweitig gefangenen Ausnahmen zu. Der Code in dem gefundenen catch-Zweig wird ausgeführt, danach geht es in dem Jedenfalls-Teil weiter mit der Programmausführung (falls nicht der verwendete catch-Zweig mit einem weiteren throw oder return das verhindert).

Damit ein catch-Zweig zutrifft, muß die geworfene Ausnahme entweder exakt den angegebenen Typ haben, oder davon abgeleitet sein (und zwar durchgehend public, weil sonst vom catch aus die Basisklasse nicht sichtbar ist). Im catch( CPKW &f ) können also alle Ausnahmen vom Typ CPKW gefangen werden, sowie allen davon public abgeleiteten Typen. Um das Casten eines abgeleiteten Typs in den angegebenen zu vermeiden, sollten zumindest Klassenobjekte per Referenz gefangen werden (wie oben gezeigt).

Im übrigen werden die catch-Zweige in der angegebenen Reihenfolge geprüft, bis der erste zutrifft; alle weiteren werden ignoriert. Ein catch( ... ) ist also nur am Ende sinnvoll, weil alle danach folgenden Zweige nie erreicht werden können. Trifft keiner der Zweige zu, dann wird die Ausnahme einfach weitergeworfen.

Damit beschränkt sich die Fehlerbehandlung auf zwei Stellen: an einer Stelle wird der Fehler erkannt, und eine Ausnahme geworfen; und an einer anderen Stelle kann auf den Fehler reagiert werden, indem ein try mit einem passenden catch geschrieben wird.

Besonders schick ist die Tatsache, daß sich alle dazwischen liegenden Unterprogrammebenen überhaupt nicht um ein Weiterleiten des Fehlers bemühen müssen, sondern sich beliebig ,,dumm`` stellen können!

Durch die Konzentration der Fehlerbehandlung auf das Werfen und Fangen von Ausnahmen wird der gesamte dazwischen liegende Quelltext von jeglicher Fehlerbehandlung befreit, und wird dadurch kompakter und wesentlich leichter lesbar (sowohl für den menschlichen Leser des Quelltextes als auch für den Compiler, der ohne die vielen Fallunterscheidungen zur Fehlerbehandlung besser optimierten Maschinencode erzeugen kann).

Oft kommt es vor, daß man an einer Stelle zwar einen Fehler fangen möchte, aber hier nur bedingt darauf reagieren kann. Dann kann man aus dem catch heraus den gefangenen Fehler erneut auswerfen (oder einen anderen Fehler, wenn das sinnvoll erscheint; das heißt dann Abbilden von Ausnahmen oder exception mapping). Vor dem erneuten Auswerfen kann man den gefangenen Wert natürlich bei Bedarf manipulieren (beispielsweise an einen darin enthaltenen Text etwas anhängen).

Wie kann man aber in einem catch( ... ) den gefangenen Fehler erneut auswerfen, wenn man gar keinen Namen hat, um das gefangene Objekt anzusprechen? Dafür gibt es die Möglichkeit, throw ohne Parameter zu verwenden. Dann wird das gefangene Objekt unverändert weitergeworfen.

Trotz der einfachen Verwendung kann eine beliebige Informationsmenge übertragen werden, indem ein passender Datentyp für das zu werfende Objekt gewählt wird. Das kann wie hier ein einfacher Typ sein. Oft werden aber Klassen nur zu dem Zweck geschaffen, Objekte davon als Ausnahmen zu werfen (beispielsweise die Ausnahmen der Standardbibliothek). Durch geschickten Aufbau einer Hierarchie solcher ,,Fehlerklassen`` kann man verschiedene Fehler voneinander abgeleiteter Klassen in einem catch fangen.

Beispielsweise wird von new eine Ausnahme vom Typ bad_alloc geworfen, wenn das Allokieren von Speicher gescheitert ist. Damit spart man sich viel Schreibarbeit und das Programm wird deutlich aufgeräumter, weil (im Gegensatz zum NULL-Ergebnis bei malloc()) nicht mehr jedes Allokieren von Speicher einzeln geprüft werden muß. Siehe dazu auch Freier Speicher new und delete.

Wie gesagt werden automatische Variablen beim Aufräumen des Stacks sauber entfernt. Ein Problem hat man allerdings, wenn zwischen dem Allokieren einer sonstigen Resource (Speicher mit new oder malloc(), oder Öffnen einer Datei mit fopen()) und der zugehörigen Freigabe eine Ausnahme auftritt, die in dieser Ebene nicht gefangen wird. Dann wird ja das zugehörige Freigeben der Resource nicht mehr erreicht. Als Abhilfe kann man bei der Verwendung von Dateien auf streams ausweichen (Streams) anstatt FILE* zu verwenden, beziehungsweise allokierten Speicher über Autopointer auto_ptr<T> verwalten.

In C++-Programmen ist es übrigens nicht zulässig, setjmp() und longjmp() zu verwenden. Damit lassen sich zwar ähnliche Sprünge von einer tieferen Unterprogrammebene zu einer höheren durchführen; ebenso wird hierbei der Stack entsprechend freigegeben. Aber: es werden für die freigegebenen automatischen Variablen keine Destruktoren aufgerufen!



Unterabschnitte
AnyWare@Wachtler.de