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:
Viele Unterprogramme benötigen aber schon ohne das Auftreten von Fehlern das gesamte Spektrum der möglichen Rückgabewerte für ihren Wertebereich, sodaß keine speziellen Werte frei sind. Ein Beispiel dafür ist double tan( double arg ); jeder mögliche Rückgabewert kann im Standard auch ein gültiger Wert sein (es sei denn, die konkrete Implementierung bietet etwas wie NAN=not a number an, worauf man sich aber nicht verlassen kann).
Dies ist immer unelegant und unflexibel, und wird schnell aufwendig, ohne viel zu erreichen.
Allein dies führt zu wirren Programmen, die schwer zu verstehen und anfällig für weitere Fehler sind.
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!