12 Debugging und Ausnahmebehandlung

Kommen wir nun zu einem Kapitel, das für Sie im idealen Fall vollkommen überflüssig ist. Sie wundern sich über die Einleitung? Nun, »ideal« ist das Stichwort in dem Satz, aber wann läuft schon einmal etwas ideal? Überflüssig ist das Kapitel nämlich dann, wenn Sie niemals Fehler machen und ebenso Ihr Programm oder Applet so perfekt ist, dass dort niemals Fehler auftreten können (weder durch Fehler im Programm, noch durch die Plattform oder gar den Anwender). Wenn Sie in einer so perfekten Umgebung leben und arbeiten, dann haben Sie wahrscheinlich die Welt der Realität verlassen und sich in die Traumwelt begeben. Leider wird in der Realität das absolute Gegenteil der Fall sein. Es gibt da die berühmten Gesetze von Murphy, die im Wesentlichen besagen, dass alles, was im Prinzip schiefgehen kann, auch in der Tat schiefgehen wird. Oder noch genauer - egal, wie schlimm es ist, es wird noch schlimmer!

Die Gesetze von Murphy sind zwar recht allgemein auf Naturwissenschaft und Technik bezogen, werden aber gerade in der Welt der Computerei zur Perfektion gebracht. Sie gipfeln in einigen (unverbindlichen) Erkenntnissen:

Man kann diese Aussagen (die auf unumstößlichen Erfahrungen beruhen) noch beliebig erweitern, aber Sie merken schon, um was es geht: Fehler und deren Vermeidung (unmöglich) oder zumindest Reduzierung (sowas geht schon eher)! Und für die Fälle, wo dennoch Fehler oder Ausnahmesituationen eintreten, müssen wir unser Programm so instruieren, dass es vernünftig (vor allem nicht mit einem Absturz) reagiert.

Wir müssen uns dabei mit verschiedenen Situationen auseinander setzen:

1. Typografische Fehler beim Schreiben des Programms
2. Syntaktische Fehler beim Schreiben des Programms
3. Programmfehler zur Laufzeit, die auf logische Fehler im Programmaufbau zurückzuführen sind
4. Programmfehler zur Laufzeit, die auf äußere Umstände (Situationen, die erst zur Laufzeit entstehen, die Umgebung des Programms oder den Anwender) zurückzuführen sind

Die Beseitigung der Fehlerpunkte 1-3 sind hauptsächlich das, was man unter Debugging versteht. Punkt 4 zählt teilweise ebenfalls dazu, jedoch sollte im Wesentlichen bereits vor der Entstehung eines Fehlers in extra »Fangzäunen« aufgefangen werden - mit der so genannten Ausnahmebehandlung.

12.1 Debugging

Der Begriff Debugging geht auf das englische Wort Bug zurück, was übersetzt »Wanze« bedeutet. Es gibt einige Anekdoten, warum die Fehlerbereinigung bei einem Programm Debugging genannt wird. Sie bekannteste Anekdote hat zum Inhalt, dass in Zeiten seliger Röhrengroßrechner (Vierzigerjahre) ein Programmfehler die damaligen Entwickler zur Verzweiflung getrieben haben soll. Der Fehler zur Laufzeit war absolut unlogisch, da im Quelltext kein Fehler zu finden war. Die Lösung fand sich erst, als man den Rechner selbst auseinander schraubte und zwischen den Röhren eine tote Wanze fand, die mit ihrem Körper Schaltkreise störte.

So viel zur Namengebung - schauen wir uns Maßnahmen zu Fehlerbehandlung an.

12.1.1 Fehler im Vorfeld abfangen

Es ist banal, aber dennoch die wirkungsvollste Maßnahme - versuchen Sie, durch sorgfältige Vorbereitung (Programmplanung, Bereitlegung von Nachschlagewerken und genügend Infomaterial, vernünftige Entwicklungswerkzeuge (etwa ein Editor mit farblicher Unterscheidung von Schlüsselworten und Quelltextstrukturen - leider nicht im JDK vorhanden) und vor allem sorgfältige und konzentrierte Eingabe Fehler (vor allem Flüchtigkeitsfehler) erst gar nicht entstehen zu lassen. Aber auch ein durchdachtes Konzept und genügend Programmiererfahrung werden die Anzahl der potenziellen Fehler in einem Programm bereits im Vorfeld reduzieren. So werden erfahrene Programmierer beispielsweise bei Divisionen immer misstrauisch und stellen sicher, dass der Teiler nie den Wert 0 annehmen kann (dazu gleich mehr bei einem Beispiel).

12.1.2 Vernünftige Sicherungsmaßnahmen

Nehmen wir uns noch einmal die (ironischen) Aussagen vor:

Es gibt einen ernsten Hintergrund. Oft kommt man in die Situation, dass ein Programm läuft, aber noch einige Dinge erweitert oder verändert werden müssen. Man nimmt eine Änderung oder mehrere Änderungen in einem Schritt vor, und das Programm hat einen Fehler. Leider ist es so, dass man sich normalerweise nicht jede Änderung merkt (obwohl dies - oder noch besser eine Dokumentation - sinnvoll wäre). Oft kann deshalb diese Veränderung dann nicht mehr rückgängig gemacht werden. Hat man dann den letzten lauffähigen Stand nicht gesichert (Source!), bleibt oft nur ein aufwändiges Try-and-Error-Verfahren um etwas wiederherzustellen, was man schon einmal hatte.

12.1.3 Fehler finden und beseitigen

Wenn nun aber dennoch ein Fehler aufgetreten ist und sich der fehlerfreie Zustand nicht durch einfaches Laden wiederherstellen lassen kann (oder soll, falls aufwändige Veränderungen stattgefunden haben), bleibt nur die Fehlerbeseitung. Dabei stellt sich zuerst das Problem, den Fehler überhaupt zu lokalisieren. Dies ist der größte und wichtigste Teil dessen, was Debugging genannt wird - das Lokalisieren und Identifizieren eines Fehlers. Diese »Wanzenjagd« kann in einem Softwareprojekt bis zu 40 % und mehr der Entwicklungszeit in Anspruch nehmen. Dies hängt von der Art des zu entwickelnden Programms, der Größe und Komplexität des Projekts, der Anzahl der beteiligten Personen (je mehr Personen, desto mehr Fehlerquellen alleine durch Missverständnisse) und nicht zuletzt von der verwendeten Programmiersprache ab. Wir wissen ja bereits, dass es Programmiersprachen gibt, die äußerst flexibel sind, aber damit Fehler extrem großzügig einladen (unser beliebtes Beispiel C/C++). Dort können mit fehlgeleiteten Pointern, Zeigerarithmetik, Array- und sonstigen Indexüberschreitungen, ungeprüfter Typumwandlung, Speicherallokation und anschließender manueller Freigabe oder ähnlichen Techniken so viele Wanzen in ein Programm einziehen, dass sich die Zeit für deren vollständige Beseitigung gegen unendlich geht und in der Praxis nie realisiert wird (oder kennen Sie etwa keine Schreibschutzverletzung von Windows-Programmen?). Andere Programmiersprachen wie Visual Basic sind halbwegs fehlersicher, da sie systemfern auf dem Ring 3 des Prozessors arbeiten und nicht so gravierende Fehler durch den Programmierer zulassen. Java steht am oberen Ende der Fehlersicherheit durch den Verzicht auf viele extreme Systembefehle und diverse weitere Sicherungsmaßnahmen (wir sind bereits darauf eingegangen oder werden sie im Rahmen der Java-Sicherheit noch explizit behandeln). Dennoch können sogar Java-Programme Fehler enthalten. Manche Fehler kann ein Programmierer sogar grundsätzlich nicht verhindern. Die Behandlung dieser Fehler werden wir gleich anschauen, zuerst wollen wir sie suchen.

Sie können mit oder ohne Hilfsmittel auf die Wanzenjagd gehen. Beide Verfahren haben Vor- und Nachteile.

12.1.4 Fehlerlokalisierung ohne Debugger

Zunächst schauen wir uns an, welche Maßnahmen Sie ergreifen können, ohne auf weitergehende Hilfsmittel - einen Debugger- zurückzugreifen. Dieses Verfahren hat die wesentlichen Vorteile, dass sie neben dem Compiler kein zusätzliches Programm benötigen und sich vor allem nicht in dessen Bedienung einarbeiten müssen. Außerdem ist die Arbeit ohne zusätzlichen Debugger manchmal schneller und auch einfacher. Wir unterteilen die Problemstellung nach den Typen möglicher Fehler, die wir oben aufgelistet haben.

Da sind einmal die typografischen Fehler beim Schreiben des Programms. Sie haben sich einfach vertippt oder ein Schlüsselwort, eine Variable oder sonst etwas Entscheidendes falsch geschrieben. Nun, sofern Sie es richtig falsch geschrieben haben, ist die Situation oft leicht in der Griff zu bekommen. Aber was soll das heißen - richtig falsch? Richtig oder falsch? Was denn nun?

Es soll bedeuten, dass Sie nicht zufällig einen solchen Schreibfehler gemacht haben, sodass der falsche Begriff wieder einen sinnvollen Token ergibt und keinen syntaktischen Widerspruch darstellt. Nehmen wir ein Beispiel aus der Textverarbeitung. Sie wollten beispielsweise Salz schreiben und haben Satz getippt - einen ebenfalls sinnvollen Token. In diesem Fall haben Sie ohne Hilfsmittel nur die Chance, den Fehler im Quelltext durch aufmerksames Lesen oder Analyse des Resultates von dem Programm zur Laufzeit zu finden.

Wenn Sie jedoch einen Schreibfehler gemacht haben, der keinen vernünftigen Token ergibt, sind Sie in der besseren Situation. Der Token wird einen syntaktischen Widerspruch innerhalb der Programmiersprache erzeugen und der Compiler wird bei seiner Arbeit den Fehler entdecken, den Quelltext nicht übersetzen und eine Fehlermeldung mit Adressangabe zurückbringen. Falls Sie direkt an dieser Adressangabe den Fehler finden, haben Sie allerdings Glück, denn leider ist diese Adressangabe der Wirkungsort des Fehlers und meist nicht der Entstehungsort. Dies muss nicht immer identisch sein. Aber zumindest ist der Fehler schon einmal eingegrenzt.

Der zweite Fall sind die syntaktischen Fehler beim Schreiben des Programms. Dies sind Fehler bei der Verwendung der Programmiersprachensyntax. Der Compiler wird dies beim Übersetzen merken und eine Fehlermeldung zurückliefern. Viele syntaktische Fehler beruhen auf typografischen Fehlern (etwa, wenn Sie buplic statt public schreiben). Dieser Fehlertyp beinhaltet auch die falsche Verwendung von korrekten Token (z.B. for (i==0; i < 256;i++) - ein Vergleich statt einer Zuweisung der Zählvariable). Leider gilt auch hier, dass nicht unbedingt die Adressangabe des Fehlers der Entstehungsort des Fehlers sein muss.

Der dritte Fall sind Programmfehler zur Laufzeit, die auf logische Fehler im Programmaufbau zurückzuführen sind. Hierbei haben Sie ein syntaktisch vollkommen korrektes Programm geschrieben, das aber Fehler zur Laufzeit zulässt. Dies geschieht meist, weil eine Variable einen falschen Wert bekommt. Beispielsweise setzt irgendeine Programmsituation eine Variable auf 0 und im Folgeschritt wollen Sie durch diese Variable teilen. Sie müssen zur Beseitigung der Fehlersituation die Stelle im Quelltext suchen, an der diese Variable (Methode usw.) verwendet wird und versuchen, den Aufbau und mögliche Fehler noch einmal durchzudenken. Kontrollausgaben, die im endgültigen Source beseitigt werden, erleichtern dies erheblich. Gängige Praxis ist, vor einem vermuteten Fehler eine Bildschirmausgabe der »Wanze« (also der Variable, in der man die Fehlerursache vermutet) zu erzeugen. Dies könnte so aussehen (die Variable Teiler ist die vermutete Wanze):

System.out.println(Teiler);// Debug-Ausgabe
  a=b/Teiler;// vermutete Fehlerstelle im Source

Vor der Entstehung des Laufzeitfehlers sehen Sie auf dem Bildschirm den Wert der Variablen. Sie können selbstverständlich an den unterschiedlichsten Stellen im Programm solche Debug-Ausgaben einfügen, um eine Variable über mehrere Schritte hinweg zu verfolgen. Oft sinnvoll ist ebenso, den Programmfluss direkt vor dem vermuteten Fehler anzuhalten, also eine Schleife einfügen, die auf einen Tastendruck wartet, bis sie beendet wird und das Programm weiter läuft.

Die Grenzen dieser Analyse ohne einen Debugger sind dann erreicht, wenn tiefergehende Informationen (etwa über den Stack bei Programmüberläufen oder den Speicherbedarf von Programmen) ausgewertet werden sollen.

Der vierte Fall sind Programmfehler zur Laufzeit, die auf äußere Umstände (Umgebung des Programms oder den Anwender) zurückzuführen sind. Diese Fehlerkonstellation kann durch verschiedene Dinge entstehen. Sie greifen etwa im Programm hardcodiert auf eine bestimmte Schriftart zu, die auf der Plattform des Anwenders nicht vorhanden ist. Oder das Programm lässt Bedienfehler durch den Anwender zu (etwa Speicherversuch auf ein Diskettenlaufwerk, obwohl keine Diskette eingelegt ist). Es handelt sich also um Fehler, die mehr auf konzeptioneller Seite zu suchen sind, und weniger um technische Fehler. Aber auch hier sind die unter Punkt 3 angeführten Maßnahmen zu einer Analyse (mit sehr engen Grenzen) sinnvoll.

Die beiden Fehlerpunkte 3 und 4 sind das, was man in der Literatur als Laufzeitfehler bezeichnet. Es handelt sich also um einen Fehler, den ein Compiler zum Zeitpunkt der Übersetzung nicht erkennen kann (etwa eine Division durch 0 zur Laufzeit) und auf den ein Programmierer unter Umständen nur bedingt Einfluss hat (etwa bei fehlender Diskette im Laufwerk). Einige Fehlerkonstellationen kann man recht gut umgehen, andere muss man zulassen, weil man darauf keinen Einfluss hat (fehlende Diskette), wieder andere Laufzeitfehler gelten als nicht auffangbar (so genannte untrappable Errors oder oft auch nur als Errors bezeichnet), da sie durch die Hardware verursacht werden und nicht durch Software aufgefangen werden können. Auf jeden Fall sollten alle denkbaren und technisch auffangbaren Fehler irgendwie behandelt werden, denn in der Regel führt ein unbehandelter Laufzeitfehler zu einem Programmabsturz und dabei gehen die evtl. in dem Programm erfassten und noch nicht gespeicherten Daten verloren. Zumindest ist das neue Starten eines Programms lästig. Das Abfangen von Laufzeitfehlern in Java werden wir in Kürze angehen (Ausnahmebehandlung). Wenden wir uns vorher dem Auffinden von Fehlern mit einem Debugger zu.

12.1.5 Fehlerlokalisierung mit Debugger

Sie haben an den eben beschriebenen Maßnahmen gesehen, dass man auch ohne Hilfsmittel Fehler unter Umständen lokalisieren kann. In vielen Fällen stößt man jedoch dabei an Grenzen. Für eine tiefergehende Analyse und komfortablere Suche (ohne Programmierung von eigenen Debug-Ausgaben) verwendet man einen Debugger, wie er etwa bei den JDK-Werkzeugen im Anhang beschrieben wird. Wenn Sie sich dort die erlaubten Befehle an den Debugger anschauen, erkennen Sie schon, was man damit machen kann. Ein Debugger ist kein Hilfsmittel, das logische Fehler in einem Programm anzeigt, sondern eine Unterstützung für Sie, diese selbst zu finden. Manche Debugger sind Teil einer IDE (also einer so genannten integrierten Umgebung) und können die Fenstertechnik und die Maus nutzen. Diese sind natürlich am komfortabelsten.

Andere müssen sich als Teil eines Programmaufrufs quasi an diesen mit anhängen - so wie der Debugger des JDK. Diese können dann auch nur im Ausgabefenster des Systems Informationen zurückgeben. Dies ist zwar unkomfortabel, aber dennoch sehr brauchbar. Im Anhang bei der Beschreibung der erweiterten JDK-Tools finden Sie dazu eine Beschreibung des JDK-Programms samt der grundsätzlichen Schritte zur Arbeit damit).

Die Arbeit mit einem Debugger lässt sich auf einige wesentliche Schritte zusammenfassen:

1. Einen Haltepunkt (Breakpoint) setzen
2. Einen Ausdruck analysieren
3. Ein Programm in einzelnen Schritten durchlaufen (Steppen)
4. Ein Programm in größeren Schrittgruppen (Methodenaufrufen) durchlaufen
5. Ein Programm bei bestimmten Bedingungen anhalten lassen
6. Tiefergehende Informationen über den Programmzustand analysieren

Setzen wir uns Punkt für Punkt mit den Techniken auseinander.

Einen Haltepunkt setzen bedeutet, ein Programm an einer beliebigen Stelle zu unterbrechen. Wir haben bereits oben angedeutet, wie dies mit zusätzlich programmierten Debug-Code möglich ist. In einem Debugger ist dies ohne zusätzlich programmierten Debug-Code durchführbar. Sie können an beliebigen (im Source sinnvollen) Stellen einen Haltepunkt setzen, ihn wieder löschen und einen Ausdruck im Source an dem Zeitpunkt analysieren, an dem das Programm bei Erreichen des Haltepunktes angekommen ist. Daneben werden Sie in gewissen Grenzen Änderungen in dem unterbrochenen Programm vornehmen können (etwa den Wert einer Variable von Hand ändern).

Kommen wir zu Punkt zwei. Wenn Sie einen Ausdruck analysieren wollen, können Sie über zusätzlichen Debug-Code eine Ausgabe auf den Bildschirm erzeugen. Ein Debugger lässt so ein Hineinschauen in einen Ausdruck auch ohne zusätzlichen Debug-Code zu. Sie geben den Ausdruck, den Sie analysieren wollen, im Debugger an und der Debugger zeigt Ihnen dessen Wert an. Zu welchem Zeitpunkt dies im Programmablauf geschieht, hängt davon ab, wann die Ausgabe erfolgen soll. Entweder, nachdem Sie Ihr Programm angehalten haben (Punkt 1 oder 5), oder sogar permanent an vorgegebenen Stellen, wenn Sie durch Ihr Programm steppen (Punkt 3 und 4).

Wenn Sie ein Programm in einzelnen Schritten durchlaufen (Steppen), wird der Programmablauf nach jedem einzelnen Schritt des Programms unterbrochen, und Sie können an dieser Stelle Ihre Analysen durchführen. Danach wird einen Schritt weitergegangen und ggf. wieder analysiert. Wenn Sie durch ein großes Programm von Anfang bis Ende steppen, wird es ziemlich aufwändig. Das ist praxisfern. Man nutzt diese Technik aber oft im Zusammenhang mit einem Haltepunkt. Kurz vor einer kritischen Stelle setzen Sie einen Haltepunkt und ab diesem steppen Sie dann schrittweise weiter, bis der Fehler auftritt.

Ein Programm in größeren Schrittgruppen (Methodenaufrufen) zu durchlaufen dient zur groben Lokalisierung eines Fehlers (wir sind bei Punkt vier). Wenn man die Anweisung entdeckt hat, die den Fehler produziert, setzt man vor deren Aufruf einen Haltepunkt, startet den Debug-Vorgang erneut und steppt ab dem Haltepunkt in Einzelschritten weiter.

Wenn Sie ein Programm bei bestimmten Bedingungen anhalten lassen, bedeutet dies, dass das Programm so lange normal arbeitet, bis die angegebene Bedingung eingetreten ist (etwa eine bestimmte Ausnahme ausgeworfen wird). Dann hält das Programm an und Sie können die Situation analysieren. Ein Java-Debugger muss bei bestimmten Ausnahmen die Programmausführung unterbrechen und Ihnen die Möglichkeit zur Analyse geben (dies kann der JDK-Debugger natürlich auch).

Der sechste Punkt betrifft das Auswerten von tiefergehenden Informationen über einen Programmzustand. Gute Debugger erlauben die Verfolgung von einzelnen Threads in einem Programm, Analysen von Speicherzuständen, Klassen- und Methodenaufrufen oder dem Stack. Je nach Qualität des Debuggers kann man sich da alles mögliche vorstellen, was beim Ablauf eines Programms von Interesse sein könnte.

Wir wollen nun die Problemstellung erneut nach den möglichen Fehlertypen unterteilen. Dabei schauen wir uns an, wie man mit einem Debugger darauf reagieren kann.

1. Bei typografischen Fehlern hilft Ihnen der Debugger nur begrenzt, da Sie zur Auswertung eines Ausdrucks dessen konkreten Namen wissen sollten und der ist ja falsch. Allerdings hilft das Steppen durch den Quelltext recht gut, um diese typografischen Fehler zu finden, sofern der Debugger eine Ausführung eines fehlerhaften Programms (bis zum Crash) unterstützt. Vor allem, wenn Sie vom Compiler eine Fehlermeldung mit Adressangabe bekommen. Dann setzen Sie in ausreichendem Abstand davor einen Haltepunkt und steppen durch oder der Debugger kann direkt die Fehlermeldung des Compilers nutzen. Wenn Sie einen richtig falschen Token verwenden und Sie - ohne den richtigen Fehler zu bemerken - diesen Ausdruck analysieren, wird Ihnen auffallen, dass der Ausdruck nie eine Wertveränderung erfährt, obwohl dies so sein müsste.
2. Bei syntaktischen Fehlern gilt das Gleiche wie unter Punkt 1.
3. Hier schlägt nun die Stunde des Debuggers - Programmfehler zur Laufzeit, die auf logische Fehler im Programmaufbau zurückzuführen sind, Haltepunkte setzen und wieder beseitigen, Steppen, Wertanalysen, Wertveränderungen, einen Ausdruck über mehrere Schritte hinweg zu verfolgen - alles, was zur Fehlerlokalisierung nötig ist, sollte ein Debugger leisten. Aber auch tiefergehende Informationen lassen sich von guten Debuggern auswerten und teilweise verändern. Wenn Sie sich die Kommandos an den JDK-Debugger jdb anschauen, werden Sie Befehle entdecken, mit denen Sie den Stack und den Speicherbedarf eines Programms, aber auch einzelne Threads oder Ausnahmesituationen analysieren und teilweise manipulieren können. Sie können sogar im Falle von Multithreading einzelne Threads oder Threadgruppen gezielt behandeln. Mit einem Debugger bekommen Sie ebenfalls die Namen und IDs der Klassen, die bereits geladen worden sind, können ganz gezielt Klassen laden und Methoden anzeigen oder erhalten Informationen über alle Felder (Werte der Variablen) einer Klasse oder der Instanz. Sie können in Java-Debuggern ebenso die direkte Ausführung des Garbage Collectors erzwingen.
4. Programmfehlern zur Laufzeit, die auf äußere Umstände zurückzuführen sind, kann mit einem Debugger begrenzt vorgebeugt werden. Durch die Simulation solcher Fehler - Wertveränderungen von kritischen Ausdrücken im Debug-Modus nach einer Programmunterbrechnung - kann die Reaktion des Programms getestet werden. Anschießend lassen sich Gegenmaßnahmen ergreifen. Der Debugger unterstützt Sie also auch bei der Vermeidung von Fehlern, die mehr auf konzeptioneller Seite zu suchen sind. Diese Hilfe ist aber begrenzt, da Sie so gut wie nie alle denkbaren Fehlerkonstellationen im Vorfeld durchspielen können. Die technisch auffangbaren Laufzeitfehler in einem Programm sollten auch tatsächlich aufgefangen werden. Java realisiert dies im Wesentlichen durch die Ausnahmebehandlung.

12.2 Ausnahmebehandlung und andere Fehlerbehandlungen

Die Ausnahmebehandlung und auch die anderen Techniken zur Fehlerbehandlung in diesem Abschnitt betreffen ausschließlich die auffangbaren Laufzeitfehler (trappable Errors), also Fehler, die bei der Übersetzung eines Programms nicht entdeckt werden können und erst unter bestimmten Konstellationen während der Laufzeit des Programms auftreten. Fehler, die durch höhere Gewalt (Hardwarefehler, Stromausfall, mechanische Gewalt durch einen Wutanfall des Anwenders) ausgelöst werden (die bereits angesprochenen untrappable Errors), können naturbedingt nicht abgefangen werden.

Sie haben zwei grundsätzlich verschiedene Möglichkeiten zur Fehlerbehandlung:

12.2.1 Die individuell programmierte Fehlerbehandlung

In vielen Programmiersprachen bleibt Ihnen zur Behandlung von Laufzeitfehlern kein anderer Weg, als jeden denkbaren Fehler an der Stelle zu behandeln, wo er auftreten kann. Wenn wir uns noch einmal das Beispiel von der Division durch eine Variable mit dem Wert 0 anschauen, können wir dort eine solche Fehlerbehandlung durchführen. Der einzig mögliche Fehler an dieser Stelle ist, dass die Variable den Wert 0 zugewiesen bekommt. Also müssen wir für diesen Fall vorsorgen. Dies kann mit den traditionellen Entscheidungsstrukturen (while, do, if) recht einfach erfolgen. Wir setzen die kritische Aktion - die Division durch die Variable - in eine Art Schutzanzug, indem wir eine Entscheidungsstruktur vorschalten, die den Fehler erst gar nicht zulässt. Das kann so aussehen:

if (Teiler == 0) {
// tue etwas, etwa eine Fehlermeldung mit anschließendem 
// Sprung an eine unkritische Stelle oder setze die 
// Variable auf einen unkritischen Wert (sofern sinnvoll) 
// wie hier Teiler = 1;
}
a=b/Teiler;

Wir haben hier einfach den kritischen Kanditaten (den Divisor) für den potenziellen Fehlerfall auf einen unkritischen Wert gesetzt, aber bereits in den Kommentarzeilen angedeutet, dass eine Sprunganweisung ebenfalls sinnvoll wäre. Und nicht nur das - fast jede Fehlerbehandlungsroutine wird normalerweise als Sprunganweisung konzipiert. Im Falle eines Fehlers soll an eine Stelle gesprungen werden, wo der Fehler behandelt wird. Wir haben dies aber absichtlich hier noch nicht in Java umgesetzt, da Java dafür einen Standardmechanismus zur Verfügung stellt, den wir gleich durchsprechen. Das Prinzip und die Struktur können Sie aber im Hinterkopf behalten.

Sie können auf diese Art jeden Fehler auffangen, den Sie als potenzielle Fehlerquelle erkennen. Und da haben wir den entscheidenden Schwachpunkt - Sie müssen die potenzielle Fehlerquelle erkennen, was oft nahezu unmöglich ist. Gerade in komplexen Programmen können Sie nie alle kritischen Zusammenhänge (auch auf Grund von Verschachtelungen) übersehen. Es werden immer Fehlerquellen existieren, die an vorher kaum beachteten Stellen auftreten. Außerdem ist es sehr viel Arbeit, bei jeder Anweisung zu überlegen, ob da eine Gefahr lauert und diese dann sicher abzufangen. Es ist sogar so, dass Sie in der überwiegenden Anzahl von Fehlern das Rad neu erfinden, denn es gibt sehr oft schon Standardmaßnahmen. Zu guter Letzt setzt diese Technik der Lauffehlerbehandlung oft voraus, dass der Fehler selbst nicht eintritt, sondern vorher erkannt und umgebogen wird. Zwar kann man in Java diese Fehlerbehandlungstechnik durchaus verwenden und sie ist in Fällen wie in unserem Beispiel auch sicher sinnvoll. Java stellt jedoch einen Mechanismus zur Verfügung, der viel mächtiger und dennoch mit weniger Aufwand verbunden ist - die Ausnahmebehandlung. Sie erlaubt sowohl eine Fehlerbehandlung »vor Ort« als auch eine globale Fehlerbehandlung an einer zentralen Stelle im Programm, an die Ausnahmen »weitergereicht« werden. Dabei ist von besonderer Bedeutung, dass das Java-Konzept ein Programm bei einer solchen Ausnahmesituation dennoch stabil weiterlaufen lässt, zumindest, wenn es einen weiteren sinnvollen Ablauf gibt.

12.2.2 Die Ausnahmebehandlung von Java

Wir sind schon an verschiedenen Stellen darauf eingegangen, dass Java zur Behandlung von Fehlern (zumindest den auffangbaren) einen sehr mächtigen Mechanismus zur Verfügung stellt - die Ausnahmen oder Exceptions.

Mittels dieses Ausnahmekonzepts kann Java eines seiner Grundprinzipien - die maximale Sicherheit - gewährleisten. Eine Ausnahme ist eine Unterbrechung des normalen Programmauflaufs auf Grund einer besonderen Situation, die eine isolierte und unverzügliche Behandlung notwendig macht. Der normale Programmablauf wird erst fortgesetzt, wenn diese Ausnahme behandelt wurde. Ein klassisches Beispiel, das eine solche Situation provoziert, ist der bereits angedeutete Versuch, auf ein Diskettenlaufwerk zuzugreifen, in dem keine Diskette eingelegt ist. Eine Java-Klasse, die diesen Mechanismus zur Verfügung stellt, sollte vor einem Zugriff auf ein Diskettenlaufwerk überprüfen, ob eine Diskette eingelegt ist und andernfalls mit einer Ausnahme auf den Versuch reagieren. Diese Ausnahme gibt Informationen darüber zurück, welcher Fehler genau vorliegt und auf Grund dieser Information kann der Programmierer Gegenmaßnahmen ergreifen.

Eine Ausnahme ist in Java ein eigenes Objekt, entweder eine Instanz der Klasse Throwable oder einer seiner Unterklassen. Dies erlaubt es Programmierern, leicht eigene Ausnahmen zu definieren, indem einfach eine Unterklasse der Klasse Throwable oder einer bereits vorhandenen Ausnahme abgeleitet wird. Eine so als Subklasse erstellte Ausnahmeklasse oder eine Standardausnahmeklasse wird von Methoden mit der Anweisung throw ausgeworfen. Das ist eine Sprunganweisung mit Rückgabewert - eben das Ausnahme-Objekt. Verwandt ist die Situation mit einer return-Ausweisung. Eine Methode, die eine Ausnahme auswirft, muss das in einer Erweiterung der Methodenunterschrift dokumentieren, um dies dem Aufrufer der Methode kenntlich zu machen. Dies geschieht mit dem Schlüsselwort throws, was nicht mit throw verwechselt werden darf. Das ganze Verfahren werden wir noch genauer ansprechen - mit Beispiel. Ein halbwegs professionelles Java-Programm sollte flexibel auf Benutzerfehler und andere Probleme reagieren können und die erzeugten Ausnahmen mit entsprechenden Fehlerbehandlungs-Routinen behandeln.

Da Java einen Mechanismus zur standardisierten Erzeugung von Ausnahmen bereitstellt, ist es naheliegend, dass es auch einen standardisierten Mechanismus zu deren Auswertung und Behandlung besitzt. Man muss dabei zwei Szenarien unterscheiden.

Globales Exception-Handling

Java realisiert wie gesagt das globale Handling von Ausnahmen über die Erweiterung einer Methodendeklaration um die throws-Anweisung zur Dokumentation und die throw-Anweisung zum konkreten Auswerfen. Damit wird eine Liste mit Ausnahmen dokumentiert, die die Bewältigung von diversen Laufzeitproblemen ermöglicht.

Wenn eine Methode irgendetwas tun kann, was zu Fehlern oder kritischen Situationen führen kann, kann man dies normalerweise in einer Dokumentation niederlegen. Wer die Methode verwenden will, muss sicherstellen, dass die kritischen Fälle nicht eintreten.

Wenn beispielsweise eine Methode eine Division durch 0 zulässt, muss derjenige, der eine Eingabemaske erstellt, die diese Methode aufruft, sicherstellen, dass keine Werte eingegeben werden, die dazu führen können. Das System selbst kann dies nicht sicherstellen, da es sich bis zum Zeitpunkt der kritischen Wertekonstellation um keinen Fehler handelt. Wir haben diese individuelle Fehlerbehandlung ohne Ausnahme-Technik eben als Beispiel behandelt.

Das Fehler-Handling ohne Ausnahmen ist ein in vielen Systemen übliches Verfahren, aber sicher nicht optimal. Java bietet das bessere Verfahren - eben die throws-Klausel (throw - auswerfen).

Beispiel:

public class  MeineErsteKlasse_mitExceptions {
public void eineMethode_mitExceptions() throws MeineException {
   ...
}  }

Hiermit wird dem Compiler mitgeteilt, dass der in der Methode enthaltene Code eine Ausnahme (MeineException) erzeugen kann. Damit weiß der Compiler, dass die Methode gefährliche Dinge tun kann. Er wird entsprechend reagieren, d.h. er wird diese Methode nur dann übersetzen, wenn die ausgeworfene Ausnahme an geeigneter Stelle im Programm behandelt wird.

Diese Beschreibung der Gefahr ist eine Art Vertrag zwischen der Methode und demjenigen, der die Methode verwendet. Die Beschreibung enthält Angaben über die Datentypen der Argumente einer Methode und ihre allgemeine Semantik. Außerdem werden die gefährlichen Dinge, die bei der Methode auftreten können, spezifiziert. Als Anwender der Methode müssen Sie sich darauf verlassen, dass diese Angaben die Methode korrekt charakterisieren.

An Stellen, wo Sie die Methode verwenden, können Sie die Ausnahmebedingungen dann explizit bezeichnen.

Wenn Sie also bei einer Ihrer Methoden wissen, dass diese bestimmte Fehler- oder Ausnahmesituationen auslösen kann, müssen Sie diese entweder selbst abfangen (etwa wie wir oben durch eine Entscheidungsstruktur sichergestellt haben, dass ein Teiler nicht 0 werden kann oder wie wir gleich in der expliziten Fehlerbehandlung durchsprechen) oder eben potenziellen Aufrufern mittels der throws-Erweiterung diese Fehler- oder Ausnahmesituationen der aufrufenden Stelle mitteilen.

Wenn nun in einer mit der throws-Erweiterung gekennzeichneten Methode eine Ausnahme auftritt, was passiert dann? In diesem Fall wird die aufrufende Methode nach einer Ausnahmebehandlungsmethode durchsucht (wie die aussieht, folgt gleich). Enthält die aufrufende Methode eine Ausnahmebehandlungsmethode, wird mit dieser die Ausnahme bearbeitet, ist dort keine Routine vorhanden, wird deren aufrufende Methode duchsucht und so fort. Das Spiel geht so lange weiter, bis eine Ausnahmebehandlungsmethode gefunden ist oder die oberste Ebene des Programms erreicht ist. Die Ausnahme wird von der Hierarchieebene, wo sie aufgetreten ist, jeweils eine Hierarchieebene weiter nach oben gereicht. Sofern sie nirgends aufgefangen wird, bricht der Java-Interpreter normalerweise die Ausführung des Programms ab. Dieses Weiterreichen der Behandlung über verschiedene Hierarchieebenen erlaubt sowohl die unmittelbare Behandlung ganz nahe am Ort des Problems als auch eine entferntere Behandlung, wenn dies sinnvoll ist - etwa in einer mehrere potenzielle Probleme umgebenden Struktur.

Natürlich müssen nicht alle denkbaren Fehler und Ausnahmen explizit aufgelistet werden. Die Klasse Throwable besitzt zwei große Subklassen, Exception und Error. In den beiden Ausnahmeklassen sind die wichtigsten Ausnahmen und Fehler der Java-Laufzeitbibliothek bereits enthalten. Diese beiden Klassen bilden zwar zwei getrennte Hierarchien, werden aber ansonsten gleichwertig als Ausnahmen behandelt.

Die Ausnahmen der Klasse Error und ihrer Subklasse RuntimeError müssen Sie jedoch nicht extra abfangen oder dokumentieren. Dies geschieht automatisch, d.h., wenn keine Behandlung durch Sie erfolgt, wird die Ausnahme auf oberster Ebene behandelt und in der Systemausgabe erfolgt eine Meldung. In die Ausnahmentypen dieser Klassen fallen u.a. Situationen wie Speicherplatzmangel oder StackOverflowError, aber auch Array-Probleme oder mathematische Fehlersituationen. Wenn Sie sich die Dokumentation von Java anschauen, werden Sie dort Hunderte von Standard-Ausnahmen entdecken. Sie sollten diese Standard-Problemfälle auch nicht mehr explizit auflisten, denn damit zwingen Sie einen Aufrufer der Methode, diese irgendwie zu handhaben (was sonst automatisch über die Java-Umgebung gehandelt wird).

Es gibt eigentlich nur fünf allgemeine Typen von Ausnahmen, die in einer throws-Klausel aufgelistet werden müssen. Man kann dies aus der Beschreibung der Klasse java.lang entnehmen. Es sind:

ClassNotFoundException
IllegalAccessException
InstantiationException
InterruptedException
NoSuchMethodException

Dazu kommen diverse weitere Ausnahmen, die aus den verschiedenen Java-Paketen stammen. Sie können zum Teil der Klasse Error oder ihrer Subklasse RuntimeError zugeordnet sein und müssen nicht extra abgefangen oder dokumentiert werden (z.B. die Ausnahme ArrayStoreException aus dem Paket java.util), zum Teil werden sie jedoch der Klasse Exception zugeordnet und müssen behandelt werden (z.B. die gesamten Ausnahmen von java.io).

Viele Methoden der Java-API verfügen über throws-Klauseln. Die bisherigen Erklärungen verdeutlichen Ihnen sicher, dass viele Ausnahmen irgendwie in Java behandelt werden müssen. Aber wie? Die throws-Klauseln dokumentieren nur das potenzielle Problem und verlagern es weiter. Explizit behandelt sind die Ausnahmen damit noch lange nicht. Das tun wir aber jetzt.

Explizites Ausnahme-Handling

Wenn Java schon einen so leistungsstarken Mechanismus zur Verfügung stellt, der Ausnahmen erzeugen und die Hierarchie nach oben weiterreichen lässt, so muss Java natürlich auch einen Mechanismus zur Verfügung stellen, um die geworfenen Ausnahme-Objekte zu behandeln. Entweder, Sie behandeln potenzielle Ausnahmen einer Methode direkt innerhalb der Methode selbst, oder Sie geben dem Aufrufer genügend Informationen, wie dieser die Ausnahmen behandeln kann, ggf. auch erst in der obersten Ebene der Hierarchie, wie gerade beschrieben. In jedem Fall erfolgt die Behandlung mit der gleichen Struktur. In der Regel verwendet man zu dem Aufruf einer Ausnahme-auswerfenden Methode die umgebende try-catc>-Struktur.

try {
// Innerhalb des try-Blocks werden diejenigen kritischen 
// Aktionen durchgeführt, die Ausnahmen erzeugen können.
} 
catch (Exception e) { 
// Behandlung der Ausnahme
// Das Ausnahmeobjekt e wird behandelt
}

Kritische Aktionen in einem Java-Programm sollten immer innerhalb des try-Blocks durchgeführt werden. Falls Sie die Behandlung relativ weit oben in der Hierarchie durchführen, sollte dort die Methode stehen, die sich dann explizit um die Ausnahmebehandlung kümmert. Der Begriff »try« sagt bereits sehr treffend, was dort passiert. Es wird versucht, den Code innerhalb des try-Blocks auszuführen. Wenn ein Problem auftauchen sollte (sprich, es wird eine Ausnahme ausgeworfen), wird dieses sofort entsprechend im passenden catch-Block gehandhabt und alle nachfolgenden Schritte im try-Block werden nicht mehr durchgeführt. Wenn also eine der Anweisungen innerhalb des try-Blocks ein Problem erzeugt, wird dieses durch die passenden catch-Anweisungen aufgefangen und entsprechend behandelt (sofern die catch-Anweisung dafür die passende Behandlung enthält).

Am Ende eines try-Blocks können beliebig viele catch-Klauseln mit unterschiedlichen Ausnahmen (möglichst genau die Situation beschreibend) stehen. Sie werden einfach nacheinander notiert. Ein Verschachteln von try-catch-Strukturen ist auch möglich, wobei dann auch außerhalb angesiedelte catch-Blöcke im Inneren nicht explizit aufgefangene Exceptions auffangen können. Damit können unterschiedliche Arten von Ausnahmen auch verschiedenartig - und damit sehr qualifiziert - gehandhabt werden. Anstatt alle möglichen Ausnahmen explizit aufzulisten, die eventuell erzeugt werden könnten, können Sie auch einfach einen etwas allgemeineren Ausnahme-Typ auflisten (wie beispielsweise java.lang.Exception). Damit würde jede Ausnahme abgefangen, die aus java.lang.Exception abgeleitet wurde. Das lässt dann aber keine qualifizierte (d.h. der Situation genau angepasste) Reaktion zu.

Eine Methode, die Ausnahmen erzeugen kann und sich selbst darum kümmert, könnte also so aussehen:

Beispiel:

public void eineMethode_mitExceptions() throws MeineException {
MeineExceptionKlasse  ausnahmenMöglich = new MeineExceptionKlasse();
try {
 ausnahmenMöglich.eineMethodeMitPotenziellenAusnahmen();
} 
catch (MeineException m) {
     // Fange die Ausnahme ab
    throw m;  // Gibt die Exception weiter
}  }

Von besonderer Bedeutung ist die throw-Anweisung in dem Beispiel. Sie wird in Java immer zum direkten Auslösen einer Exception verwendet und übergibt als Argument eine Instanz der Ausnahme, die ausgelöst werden soll, an die nächsthöhere Hierarchieebene. Die throw-Anweisung ist vom Programmablauf her mit der break-Anweisung vergleichbar. Was in dem Block danach folgt, wird nicht mehr ausgeführt.

Wir werden die throw-Anweisung etwas weiter unten (bei den benutzerdefinierten Ausnahmen) in einem praktischen Beispiel verwenden.

Eine solche Ausnahmenbehandlung ist die sicherste Variante, weil die Methode selbst die potenziellen Ausnahmen behandelt. Wenn eine solche Ausnahme jedoch nicht direkt behandelt wird, wird sie nach oben in der Hierarchie der Methodenaufrufe weitergereicht.

Falls nirgendwo eine Behandlung erfolgt, wird sich letztendlich das System selbst der Ausnahme annehmen und entweder eine Fehlermeldung ausgeben und/oder das Programm beenden.

Was tun im catch-Teil?

Sie haben nun eine Ausnahme in einem catch-Teil aufgefangen. Dort erfolgt die eigentliche Behandlung der Ausnahme. Wir wissen bereits, dass der catch-Teil unter gewissen Umständen (nicht immer) optional ist und auch weggelassen werden kann. Genauso ist es möglich, mehrere catch-Anweisungen zu benutzen, die dann sequenziell dahingehend überprüft werden, ob sie für die aufgetretene Ausnahme zuständig sind (wenn keine entsprechende catch-Anweisung gefunden wird, wird die Ausnahme an den nächsthöheren Programmblock weitergereicht). Aber was tun Sie damit konkret? Nun, das bleibt vollkommen Ihnen überlassen. Diese Aussage hilft Ihnen so nicht viel, soll aber deutlich machen, dass es Ihrem Konzept überlassen ist, eine Ausnahme so zu behandeln, wie es für das Programm sinnvoll erscheint. Wir werden eine potenzielle Behandlung allerdings durchsprechen - die Ausgabe einer Fehlermeldung. Und was noch erklärt werden muss, ist, wie Sie überhaupt an die Ausnahme herankommen. Schauen wir uns nochmals die Syntax einer beispielhaften catch-Klausel an:

catch (ArithmeticException m) {
...
}

In den runden Klammern nach dem Schlüsselwort catch steht ein Verweis auf den Ausnahmetyp, der in der betreffenden catch-Klausel behandelt werden soll. Sie können dort eine der unzähligen Standard-Ausnahmen von Java verwenden oder auch selbst definierter Ausnahmen. Das Ausnahme-Objekt, das damit übergeben wird, besitzt einige Methoden, die Sie dann in der Behandlung nutzen können, etwa die Methode getMessage(). Diese Methode gibt die Fehlermeldungen der Ausnahme zurück. Sie wird von der Klasse Throwable (der Mutter aller Ausnahmen) definiert und ist daher in allen Exception-Objekten vorhanden. Wir nehmen unser Beispiel mit der potenziellen Division durch einen Wert 0 und packen es in eine try-catch-Struktur. Daran sieht man, wie eine solche Ausnahme behandelt werden kann. Dabei seien a, b und Teiler irgendwelche int-Werte, die irgendwoher Werte zugewiesen bekommen:

try {
a=b/Teiler;
}
catch (ArithmeticException m) {
System.out.println("Fehler bei der Berechnung: " + m.getMessage());
}

Wenden wir diese Erkenntnisse jetzt einmal in einer konkreten Übung an. Geben Sie Folgendes ein:

class Division {
 public static void main(String args[]) {
/* Die Wahl zweier Integerwerte als Divisoren ist nur für ein Beispiel zur Erzeugung einer 
Ausnahme sinnvoll. Sofern etwa der Zähler als float vereinbart oder ein Casting wie ergebnis 
= (float)n/m; durchgeführt wird, wird die hier gewünschte Ausnahme bei Division durch 0 nicht 
mehr ausgelöst. Statt dessen kommt der Wert Infinity zurück. */
 int n;
 int m;
 float ergebnis=0;
 try {
 Integer ueber1 = new Integer(args[0]);
 Integer ueber2 = new Integer(args[1]);
 n = ueber1.intValue();
 m = ueber2.intValue();
 try{
 ergebnis = n/m;
 System.out.println(
"Das abgerundete Ergebnis der Division von " + n + " geteilt durch " + m + ": " + 
 ergebnis);
 }
/* catch-Teil der äußeren try-catch-finally-Konstruktion */
 catch (ArithmeticException meineEx) {
/* Die Meldung verwendet eine Standardmethode des Ausnahmeobjekts */
// - getMessage()
 System.out.println("Achtung Division durch 0! " + meineEx.getMessage());
}  }
catch (ArrayIndexOutOfBoundsException uebergabeEx) {
// Keine zwei Übergabeparameter.
// Beachten Sie, dass das konkrete Ausnahmeobjekt 
// in der Behandlung der 
// Ausnahme nicht verwendet wird.
 System.out.println(
"Das Programm benoetigt zwei Uebergabeparameter.");
}
catch (NumberFormatException uebergabeEx) {
// Keine numerischen Übergabeparameter
// Beachten Sie, dass das konkrete Ausnahmeobjekt 
// in der Behandlung der 
// Ausnahme hier wieder verwendet wird.
 System.out.println("Das Programm benoetigt zwei numerische Uebergabeparameter. "  + 
uebergabeEx.getMessage());
}
finally {
 System.out.println("Bis bald.");
}
 System.out.println("Das kommt auch noch");
}  }

Das Beispiel erfordert zwei Übergabeparameter, die jeweils einem Integer-Objekt zugewiesen und über die Methode intValue() dann als int-Werte verwendet werden. Wenn Sie als Teiler (der zweite Übergabeparameter) eine 0 übergeben, wird eine ArithmeticException ausgeworfen und im catch-Teil aufgefangen. Fehlt ein Übergabewert, wird eine ArrayIndexOutOfBoundsException ausgeworfen und im entsprechenden catch-Teil behandelt. Wird keine Ganzzahl als Übergabewert angegeben, wird eine NumberFormatException ausgeworfen und qualifiziert behandelt.

Ansonsten wird das Ergebnis der Division ausgegeben. Dabei sollte zur Kenntnis genommen werden, dass sowohl der Teil, der mit dem Schlüsselwort finally eingeleitet wird, als auch der danach noch folgende Teil des Programms auf jeden Fall ausgeführt werden. Das zeigt deutlich, dass eine auftretende Ausnahme ein Programm nicht beendet (es sei denn, sie programmieren es explizit) oder abstürzen lässt.

Beachten Sie bitte, dass wir bewusst zwei Integerwerte teilen (auch kein Casting auf einen Nachkommatyp). Sofern Sie etwa den Zähler als float vereinbaren oder so etwas wie ergebnis = (float)n/m; durchführen, werden Sie die für unser Beispiel gewünschte Ausnahme bei Division durch 0 nicht mehr auslösen. Statt dessen wird der Wert Infinity zurückgeliefert.

Abbildung 12.1:  Division durch 0

Abbildung 12.2:  Falsches Format der Übergabewerte

Abbildung 12.3:  Fehlende  Übergabewerte

Abbildung 12.4:  Korrekter  Programmablauf

Die finally-Klausel

Die finally-Anweisung erlaubt die Abwicklung wichtiger Abläufe wie zum Beispiel das Schließen von Dateien, das saubere Unterbrechen von Netzwerkverbindungen oder das Freigeben von Ressourcen, bevor die Ausführung des gesamten try-catch-finally-Blocks unterbrochen wird. Dies kann dann so aussehen:

try { 
//Hier stehen Anweisungen, wovon ein oder mehrere 
// Ausnahmen werfen können 
} 
catch (ExceptionKlasse1 ausnahme1) { 
// Behandlung der ersten Ausnahme mit Zugriff auf das
// per throws-Klausel erzeugte Ausnahmeobjekt ausnahme1
} 
catch (ExceptionKlasse2 ausnahme2) { 
//Behandlung der ersten Ausnahme mit Zugriff auf das
// per throws-Klausel erzeugte Ausnahmeobjekt ausnahme2
} 
catch (ExceptionKlasse3 ausnahme3) { 
// Behandlung der ersten Ausnahme mit Zugriff auf das
// per throws-Klausel erzeugte Ausnahmeobjekt ausnahme3
} 
finally { 
// Der hier stehende Code wird in jedem Fall
// ausgeführt. Dies ist unabhängig von potenziell
// auftretenden Ausnahmen 
}

Der Block finally ist optional und kann durchaus weggelassen werden. Unabhängig davon, ob innerhalb des try-Blocks eine Ausnahme auftritt oder nicht, werden die Anweisungen in dem Block finally ausgeführt.Tritt eine Ausnahme auf, wird der jeweilige catch-Block ausgeführt und im Anschluss daran erst der der finally-Anweisung folgende Block.

Nun sollte aber aus dem letzten Beispiel hervorgegangen sein, dass eine Ausnahme ein Programm in der Regel nicht abbricht und auch Anweisungen außerhalb der gesamten Struktur auf jeden Fall ausgeführt werden. Wozu dann die finally-Klausel? Könnte man nicht alle auf jeden Fall auszuführenden Anweisungen außerhalb der gesamten Struktur notieren? Oft ist das wirklich auch möglich. Es gibt jedoch einige Situationen, wo man nicht so argumentieren kann. Wenn etwa innerhalb einer try-catch-Struktur eine Sprunganweisung wie break ausgelöst wird und damit eine umgebende Schleife verlassen werden soll, wird der finally-Block dennoch vorher ausgeführt - außerhalb der gesamten Struktur, innerhalb der Schleife notierter Quelltext jedoch noch nicht. Also hat diese Klausel immer dann ihre Bedeutung, wenn der Programmfluss umgeleitet wird und bestimmte Schritte vor der Umleitung unumgänglich sind.

12.2.3 Benutzerdefinierte Exceptions

Obwohl Java in seinen Standardbibliotheken über unzählige vorgefertigte Ausnahmen für fast alle wichtigen Standardsituationen (z.B. bei Dateioperationen auf Basis der IOException) verfügt, ist es gelegentlich der Fall, dass ein Programm selbst definierte Ausnahmen benötigt. Um eine solche Ausnahme zu erstellen, muss nur eine Unterklasse von Throwable oder eine ihrer Unterklassen wie Exception implementiert werden. Die Auslösung dieser selbst definierten Ausnahme erfolgt wie bei den Standardausnahmen über die throw-Anweisung und die Dokumentation mit throws.

Benutzerdefinierte Exceptions verfügen normalerweise (Konvention) über zwei Konstruktoren. Der eine Konstruktor erzeugt eine neue Instanz mit einer Fehlermeldung, der andere ohne Fehlermeldung.

Schauen wir uns ein Beispiel an, das auf Basis von ArithmeticException eine eigene Ausnahme definiert:

class MeineAusnahme extends ArithmeticException {
MeineAusnahme(String msg) // Konstruktor 1
{
super(msg);
}
MeineAusnahme() // Konstruktor 2
{
super();
}  }

Die immer als erste Direktive notierte Anweisung super() greift explizit auf den Konstruktor der Superklasse zu. Dazu können noch - falls notwendig - nachfolgende zusätzliche Schritte hinzugefügt werden.

Wenn diese Ausnahme nun mit der throws-Anweisung von einer Methode, die diese Ausnahme implementiert hat, ausgeworfen wird, kann diese in einem catch-Teil aufgefangen werden. Etwa so:

catch (MeineAusnahme m) {
System.out.println("Fehler bei der Berechnung: " + m.getMessage());
}

Wir wollen die benutzerdefinierten Ausnahmen in Verbindung mit der throw-Anweisung in zwei vollständigen Beispiel üben.

Das erste Beispiel fängt über eine selbst definierte Ausnahme den Start eines Programms ab. Das Programm darf nur gestartet werden, wenn als Übergabewert ein korrektes Passwort angegeben wird (dann gibt es die Lottozahlen der nächsten Woche aus - etwas Motivation muss sein ;-)). Andernfalls wird eine Ausnahme ausgeworfen und die im try-Block nachfolgenden Anweisungen nicht ausgeführt. Außerdem wird eine Standard-Ausnahme abgefangen (kein Übergabewert).

class MeineAusnahme extends Throwable {
MeineAusnahme(String msg) {
super(msg);
}  }
public class Passwort {
static void werfeAusnahme2(String a) throws MeineAusnahme {
MeineAusnahme m = new MeineAusnahme(a);
if (!a.equals("geheim")) {
System.out.println("Nur mit Passwort");
throw m; 
}  }
public static void main(String args[]){
 try  {  
 werfeAusnahme2(args[0]); 
 System.out.println(
"Und hier sind die LOOOTTTTOOOO-Zahlen von naechster Woche: " 
+ "4,5,6,16,23,43");
 }
 catch (MeineAusnahme meineEx)  {
 System.out.println("Nix is: " + meineEx.getMessage());
 }
 catch(ArrayIndexOutOfBoundsException e)  {
System.out.println("Bitte einen Uebergabewert an das Programm eingeben");
 }
 }
 }

Abbildung 12.5:  Korrekter Programmablauf

Abbildung 12.6:  Falsches Passwort

Abbildung 12.7:  Der Übergabewert wurde nicht angegeben.

Testen wir nun, was passiert, wenn Sie die Methode ohne umgebenden try-Konstrukt mit passendem catch-Auffangblock verwenden wollen.

Kommentieren Sie einfach das try-Schlüsselwort und den gesamten catch-Anteil aus (auf der Buch-CD ist das die Datei PasswortFehler1.java).

Der Compiler wird das Programm nicht übersetzen. Es kommt eine Fehlermeldung der Art:

PasswortFehler1.java:19: unreported exception MeineAusnahme; must be caught or declared to be 
thrown
 werfeAusnahme2(args[0]);
 ^
1 error

Grund ist, dass die Methode werfeAusnahme2() in der Deklaration eine Ausnahme der Klasse MeineAusnahme per throws auflistet. Das bedeutet nicht mehr und nicht weniger, als dass diese bei der Verwendung der Methode unbedingt aufgefangen oder an eine höhere Ebene (hier nicht vorhanden) weitergereicht werden muss. Es genügt aber auch nicht, die Methode in einen try-Block zu packen und zu hoffen, die Ausnahme würde bis zur Systemebene durchgereicht. Konsequenz wäre folgende (PasswortFehler2.java):

PasswortFehler2.java:18: 'try' without 'catch' or 'finally'
 try  {
 ^
1 error

Wir arbeiten hier mit einer selbst definierten Ausnahme als Ableitung von Throwable. Ein catch-Block der Form

catch(Exception e){}

funktioniert deshalb auch nicht, denn unsere selbst definierte Ausnahme ist ja direkt von Throwable abgeleitet. Es müsste schon so etwas sein:

catch(Throwable e){}

Sinnvoll ist dieses maximal allgemeine Abfangen aber selten, denn man möchte ja qualifiziert auf verschiedene Ausnahme reagieren.

Testen wir nun noch eine andere Form der Fehlverwendung. Lassen Sie einfach einmal die throws-Anweisung bei der Methodendeklaration weg (der Rest soll wieder in Ordnung sein - PasswortFehler2.java). Auch das wird der Compiler nicht machen, denn wenn in einer Methode eine Ausnahme ausgeworfen wird, muss sie auch dokumentiert werden. Sie erhalten folgende Fehlermeldung:

PasswortFehler3.java:24: exception MeineAusnahme is never thrown in body of corresponding try 
statement
 catch (MeineAusnahme meineEx)  {
 ^
2 errors

Sie sehen also, dass das Exception-Konzept nicht nur ein Sicherungsverfahren ist, sondern auch Anwender einer Methode führen kann, sodass diese sinnvoll eingesetzt wird. Umgekehrt kann eine Methode keine gefährlichen Dinge mit Ausnahmen tun, ohne es zu dokumentieren.

Erstellen Sie noch ein zweites Beispiel.
/* Verwendung einer selbst definierten Ausnahme und der throw-Anweisung*/
 // Die selbst definierte Ausnahme
class MeineAusnahme extends Throwable {
MeineAusnahme(String msg) // Konstruktor 1
{
super(msg);
}
MeineAusnahme() // Konstruktor 2
{
super();
}  }
public class TueWas {
// Eine erste Methode, die die selbst definierte
// Ausnahme auswirft.
static void werfeAusnahme1() throws MeineAusnahme {
// Erzeugen eines Ausnahmeobjekts mit Konstruktur 1
MeineAusnahme m = new MeineAusnahme();
System.out.println("Gleich wird mit throw eine Ausnahme ausgeworfen.");
throw m; // Auswerfen der Ausnahme
}
// Eine zweite Methode, die die selbst definierte
// Ausnahme auswirft.
static void werfeAusnahme2(int test) throws MeineAusnahme {
// Erzeugen eines Ausnahmeobjekts mit 
// Konstruktur 2, eine Meldung, die später mit 
// getMessage() abgefragt werden kann, 
// wird definiert.
MeineAusnahme m = new MeineAusnahme("Das ist meine Meldung fuer den Fall einer Ausnahme");
System.out.println("Wenn die Bedingung fuer das Eintreten einer Ausnahme erfuellt ist,");
System.out.println("wird gleich mit throw eine Ausnahme ausgeworfen. Dieses Mal mit 
Meldung.");
System.out.println("Index der For-Schleife: " + test);
// Bedingung fuer die Ausloesung der Ausnahme
if (test >1 ) throw m;
System.out.println("Hier steht noch ne Menge Zeug, das ignoriert wird, wenn vorher die 
Bedingung");
System.out.println("fuer throw erfuellt war. Sonst wird es angezeigt.");
}
public static void main(String args[]) {
 int i = 0;
  // 1. Try-catch-Block
 try  {
  werfeAusnahme1(); // Methode 1
 }
 catch (MeineAusnahme meineEx) {
 System.out.println("Wir werfen die Ausnahme hoch in die Luft:  "
+ meineEx.getMessage());
 }
  // 2. Try-catch-Block
 try {
  for (i=0;i<3;i++)  {
  // Methode 2. Sie wird 3 x aufgerufen und nur 
  // beim 3. Mal wird eine Ausnahme ausgelöst
   werfeAusnahme2(i);
  }
 }
 catch (MeineAusnahme meineEx) {
  System.out.println(
  "Ausnahme mit Konstruktor 2 und der Meldung: " + 
   meineEx.getMessage());
 }
 }  }

Das Ergebnis dieses Programms wird so aussehen:

Abbildung 12.8:  Die Ausgabe des Beispiels

Als Erstes haben wir in dem Beispiel eine selbst definierte Ausnahme als direkte Ableitung der Klasse Throwable erzeugt. Diese hat zwei Konstruktoren.

Beide in unserer public-Klasse definierten Methoden werfen diese selbst definierte Ausnahme aus. Die erste Methode am Ende jedes Aufrufs, die zweite Methode nur, wenn bestimmte Bedingungen erfüllt sind. In unserem Beispiel ist das der triviale Fall, dass eine Variable einen bestimmten Wert hat. Beachten Sie, dass die erste Methode ein Ausnahme-Objekt mit dem ersten Konstruktor (ohne Meldung) erzeugt, während die zweite Methode den zweiten Konstruktor (mit Meldung) verwendet. Sie sehen das Resultat, wenn Sie dann später die Methode getMessage() auf das jeweilige Ausnahme-Objekt anwenden.

Der Aufruf der Methoden erfolgt jeweils innerhalb eines try-catch-Blocks. Die zweite Methode besitzt dabei einen Übergabeparameter, der mit einer for-Schleife verändert wird und die Bedingung für das Auslösen der Ausnahme festlegt.

12.2.4 Die RuntimeException

Wir haben mittlerweile gesehen, dass Anweisungen, die Ausnahmen auslösen können, entweder direkt in eine try-Anweisung eingefasst werden (mit optionalen catch-Anweisungen) oder mittels der throws-Klausel an den übergeordnenten Programmblock weitergereicht werden. Falls weder eine Behandlung vor Ort noch eine throws-Klausel verwendet wird, liefert der Compiler im Allgemeinen Fall bereits bei der Übersetzung eine entsprechende Fehlermeldung. Eine besondere Ausnahme ist jedoch die Klasse RuntimeException (public class RuntimeException extends Exception) samt ihrer Unterklassen. Sie ist die Superklasse aller Exceptions, die durch eine normale Operation von der Java Virtual Machine ausgeworfen werden können. Für sie ist weder eine lokale Fehlerbehandlung noch eine Weiterleitung mit throws notwendig (aber immer möglich). Schauen Sie sich die Liste der Unterklassen einmal an - Sie werden Einige der Exceptions kennen.

Die Subklassen von RuntimeException:

ArithmeticException
ArrayStoreException
CannotRedoException
CannotUndoException
ClassCastException,
CMMException
ConcurrentModificationException
EmptyStackException
IllegalArgumentException,
IllegalMonitorStateException
IllegalPathStateException
IllegalStateException
ImagingOpException,
IndexOutOfBoundsException
MissingResourceException
NegativeArraySizeException,
NoSuchElementException
NullPointerException
ProfileDataException
ProviderException,
RasterFormatException
SecurityException
SystemException
UndeclaredThrowableException,
UnsupportedOperationException

Wir werden im nachfolgenden Kapitel zur Ein- und Ausgabe unter Java ein paar umfangreichere Programmierbeispiele durchsprechen. In diesem Rahmen wird die Ausnahmebehandlung auch eine Rolle spielen und Ihnen anhand dieser praktischen Beispiele noch einmal vorgeführt.

12.3 Zusammenfassung

Fehlervermeidung unter Java ist nahezu unmöglich, aber man kann Fehler reduzieren. Wenn man nun aber einen Fehler gemacht hat, benötigt man zum Debuggen nicht unbedingt einen Debugger. Er ist aber hilfreich und erlaubt die Auswertung von tiefergehenden Informationen. Außerdem erspart er die Erstellung von Debug-Code.

Um nun auffangbare Fehlersituationen in einem Programm zu behandeln, liefert Java ein geniales, leistungsfähiges, stabiles und sehr leicht einzusetzendens Konzept - die Ausnahmebehandlung. Um eine Methode zu erstellen, die mit Ausnahmen sinnvoll umgehen kann, muss in zwei Stufen vorgegangen werden.

1. Festlegen der Arten von Ausnahmen, die erzeugt werden können, indem diese Ausnahmen in der Ausnahmenliste in der Methodendeklaration per throws aufgelistet werden. Davon ausgenommen sind Standard-Ausnahmen, die immer aufgefangen werden können.
2. Das eigentliche Auslösen der Ausnahme erfolgt durch die Benutzung der throw-Anweisung, gefolgt von einer entsprechenden Ausnahme. Jede Ausnahme muss aus der Klasse java.lang.Throwable abgeleitet sein.

Jede Ausnahme kann entweder eine ursprüngliche Java-Ausnahme sein oder von Ihnen selbst erstellt werden. Anstatt alle möglichen Ausnahmen aufzulisten, die eventuell erzeugt werden könnten, können Sie auch einfach einen etwas allgemeineren Ausnahme-Typ auflisten (wie beispielsweise java.lang.Exception). Damit würde jede Ausnahme abgefangen, die aus java.lang.Exception abgeleitet wurde. Dies gilt allerdings als nicht sonderlich anwenderfreundlich, da die Fehlerbehandlung recht allgemein gehalten werden muss.


© Copyright Markt+Technik Verlag, ein Imprint der Pearson Education Deutschland GmbH
Elektronische Fassung des Titels: Java 2 Kompendium, ISBN: 3-8272-6039-6 Kapitel: 12 Debugging und Ausnahmebehandlung