Ausnahmezustand

Wie in vielen anderen Sprachen terminiert auch in Java das Abfangen von Fehlern den Programmteil, der eine Exception verursacht. Es geht aber auch anders, mit einer einfachen Programmiertechnik.

In Pocket speichern vorlesen Druckansicht
Lesezeit: 8 Min.
Von
  • Axel T. Schreiner
  • Bernd Kühl

Exceptions enthält man Anfängern gerne vor, nach dem Motto ‘C kam doch auch prima ohne sie aus’. Andererseits ist die Behandlung von Fehlern ein wesentliches Ausdrucksmittel in objektorientierten Sprachen wie Java. Immer wenn sich eine Methode ‘ärgert’, reagiert sie mit einer Ausnahme. Wenn diese nicht von RuntimeException abstammt, ist der Aufrufer der Methode definitiv gezwungen, sich mit dem Problem auseinander zu setzen - entweder indem er die Exception mit try ... catch abfängt oder indem er in seinem eigenen Methodenkopf mit throws davor warnt, dass er die Exception verursachen könnte.

Allerdings sind diese Ausnahmen ‘recht’ endgültig. Schickt man eine mit throw ab, wird die Programmausführung unbedingt dort fortgesetzt, wo try++... catch sie auffängt. Waren zwischen dem try-Block und der throw-Anweisung noch Methodenaufrufe verschachtelt, werden diese bei catch entsorgt. Aus dieser Perspektive entsprechen Exceptions in Java der setjmp-Konstruktion in C.

Kann man eine Exception probehalber abschicken, das heißt, sie von einer Methode aus entlang der Aufrufverschachtelung von innen nach außen anbieten, sodass jede beteiligte Methode die Ausnahme selbst abfangen oder nach außen weiterleiten kann? Die Methode kann sogar verlangen, dass die Exception wieder entlang der Aufrufverschachtelung nach innen bis hin zum ursprünglichen Verursacher zurückgeschickt wird. Dabei könnten die Beteiligten globalere Randbedingungen zwischenzeitlich so modifizieren, dass die Exception möglicherweise nicht mehr nötig ist. Im Jargon spricht man von resume, Fortsetzung am Punkt, nach dem ein Fehler aufgetreten ist, und retry, Wiederholung einer Operation, die einen Fehler verursacht hat. In einem Artikel über das Design von C++ schrieb Bjarne Stroustrup, dass er sich für Terminierung bei Exceptions entschied, da er die korrekte Implementierung der anderen Möglichkeiten für seine mitstreitenden Compilerentwickler für zu schwierig hielt.

Zwar hat sich auch James Gosling in Java für Terminierung entschieden, aber man kann die Alternative relativ leicht selbst schaffen. Will eine Methode die Kontrolle über eine Exception endgültig übernehmen, muss sie natürlich try ... catch bemühen, um etwaige Trümmer zu entsorgen. Damit man sich zuvor entlang der Aufrufverschachtelung nach außen und zurück nach innen bewegen kann, müssen die Aufrufe mit rückwärts verketteten Objekten modelliert sein, die entlang und während der Aufrufe aufgebaut werden. Nach außen bewegt man sich durch eine Nachricht an ein vorhergehendes Objekt; zurück nach innen bewegt man sich, wenn der Methodenaufruf dieser Nachricht beendet wird. Besteht die Kette aus Throwable-Objekten, kann jedes das Auf und Ab mit throw this abwürgen. Jedes modelliert einen Methodenaufruf; in den Methoden kann man try ... catch so organisieren, dass der zum Objekt zugehörige Methodenaufruf die Kontrolle endgültig wiedergewinnt. Das alles funktioniert auch, wenn Rekursion, verschiedene Methoden und verschiedene Objekte gleicher oder verschiedener Klassen beteiligt sind.

Listing 1 zeigt eine Basisklasse Activation, mit der sich Methodenaufrufe als Kette modellieren lassen. Ein Activation-Objekt kapselt einen Zeiger caller auf seinen Vorgänger in der Kette. raise(info) leitet ein beliebiges Objekt info, zum Beispiel eine Exception, an den Vorgänger in der Kette weiter (sofern vorhanden) und ist die einzige Zugriffsmöglichkeit auf diesen. handle(info) speichert einen Verweis, der mit info() wieder abgeholt werden kann; diese beiden Methoden sind dazu vorgesehen, dass der vom Kettenelement modellierte Methodenaufruf endgültig die Kontrolle erhält und info übernimmt.

Mehr Infos

LISTING 1

Basisklasse zum Modellieren von Methodenaufrufen

public abstract class Activation extends Throwable {
private final Activation caller;
protected Activation (Activation caller) { this.caller = caller; }
public Object raise (Object info)
throws Activation, NullPointerException {
return caller.raise(info);
}
protected final void handle (Object info) throws Activation {
this.info = info; throw this;
}
private Object info;
public final Object info () { return info; }
}

Wie eine Methode strukturiert sein muss, damit das funktioniert, ist an Listing 2 ersichtlich. method() erhält ein Activation-Objekt caller, das den Aufrufer beschreibt, und setzt seinerseits die Kette mit self fort, das zu einer lokalen Unterklasse von Activation gehört, die raise() überschreibt.

Mehr Infos

LISTING 2

Struktur zur kontrollierten Verarbeitung von Problemen

 ... method (Activation caller, ...) throws Activation {

final class MyActivation extends Activation {
MyActivation (Activation caller) { super(caller); }

... lokale Variablen fuer method() mit Zugriff fuer raise()

public Object raise (Object info) throws Activation {
... entscheidet, wie mit info verfahren werden soll
}
};
final Activation self = new MyActivation(caller);
try {
... hier koennen andere Methoden aufgerufen werden und Probleme auftreten
} catch (Activation a) {
if (a != self) throw a; Object info = self.info();
... hier wird info endgueltig verarbeitet, wenn raise() das mit handle() verlangt
}
}

In einer lokal definierten Klasse bestehen Zugriffsmöglichkeiten auf Instanzvariablen sowie auf final vereinbarte lokale Variablen und Parameter der Methode, die die Klasse enthält. Macht man die Klasse, wie hier MyActivation, nicht anonym, lassen sich dort lokale Variablen, die method() und raise() gemeinsam bearbeiten sollen, als paket-öffentliche, aber nur lokal erreichbare Instanzvariablen unterbringen. final ist in diesem Fall nicht nötig.

try ... catch umgibt Bereiche von method(), in denen mit Fehlern/Ausnahmen zu rechnen ist. Dort kann der Entwickler andere Methoden aufrufen, wobei er die Modellierung der Aufrufverschachtelung fortsetzen sollte, indem er den beteiligten Methoden self als Argument übergibt. Tritt ein Fehler auf, schickt das Programm (in diesem Fall das Objekt) die nötige Information mit raise() an sich selbst oder seinen Aufrufer; im Falle von method() also an self oder caller.

raise() empfängt die Information und entscheidet, was geschehen soll. Im Wesentlichen gibt es drei Möglichkeiten: super.raise() schickt die Information entlang der Aufrufkette weiter nach außen, handle() leitet sie an den zugehörigen Methodenaufruf weiter - im Listing an den catch-Bereich in method() -, und return schickt sie entlang der Aufrufkette wieder zurück nach innen. handle() entspricht folglich der konventionellen Bearbeitung von Exceptions mit Terminierung; return führt letztlich zu resume oder retry, je nachdem, wie sich der ursprüngliche Erzeuger der Information zum Schluss selbst entscheidet. Per Konvention könnte man das auch von außen steuern, indem das Programm null statt der Information zurückschickt.

handle() verwendet eine throw-Anweisung (siehe Listing 1). Damit info im richtigen catch-Block verarbeitet wird, muss jeweils kontrolliert werden, ob nur das eigene Activation-Objekt berücksichtigt wird; andere reicht man mit throw weiter (siehe Listing 2). Da jeder Aufruf von method() ein eigenes Activation-Objekt self erzeugt, wird info selbst bei rekursiven Aufrufen der korrekten Aktivierung zugestellt.

Als Test kann man in method() und raise() anfragen und interaktiv entscheiden lassen, was geschehen soll. siehe Listing 3 zeigt ein Beispiel, in dem einige Objekte angelegt wurden und zunächst method() für das erste Objekt aufgerufen wurde. Interaktiv wird anschließend method() für das erste Objekt rekursiv aufgerufen und schließlich für das zweite Objekt aufgerufen und beendet. Im rekursiven Aufruf wirft self.raise() danach ein Problem auf und bietet es beiden verschachtelten Aufrufen an. Damit ist der Anfang der Kette erreicht, ein nochmaliges super.raise() endet mit einem Fehler, der hier ignoriert wird. Im weiteren Verlauf reicht raise() das Problem zurück, und es wird dann doch durch handle() in den catch-Bereich des rekursiven Aufrufs von method() beim ersten Objekt geliefert. Im Testprogramm enthält method() eine Schleife; resume und retry sind hier nicht zu unterscheiden.

Mehr Infos

LISTING 3

Ablaufbeispiel am Terminal

A       method  A.. or raise, else return: A
AA method A.. or raise, else return: B
AAB method A.. or raise, else return:
method quits
AA method A.. or raise, else return: raise
AA raise super or handle, else return: super
A raise super or handle, else return: super Fehler!
A raise super or handle, else return:
raise quits
AA raise super or handle, else return: handle
method handling AA
AA method A.. or raise, else return:
method quits
A method A.. or raise, else return:
method quits

Ablaufbeispiel mit Oberfläche: in jedem der drei ‘Objekte’ sind die möglichen Aktivitäten freigeschaltet.

Entwirft man den Test strikt nach dem Model-View-Controller-Schema, kann man ihn unverändert im main-Thread einer Java-Applikation ablaufen lassen und die Eingaben durch Synchronisation mit dem event-Thread von einer Oberfläche beschaffen. Abbildung 1 zeigt ein Beispiel, bei dem in jedem der drei ‘Objekte’ nur die aktuell möglichen Aktivitäten freigeschaltet sind. Durch Druck auf einen Knopf in einem Objektbereich wird die entsprechende Methode bei dem Objekt aufgerufen. method() und raise() teilen sich hier noch eine lokale Variable: eine laufende Summe, die durch Eingaben im Textfeld innerhalb von beiden Methoden beeinflussbar ist. Damit lassen sich zum Beispiel die rekursiven Aufrufe von method() beim gleichen Objekt unterscheiden.

Im Prinzip klappt diese Programmiertechnik auch in anderen Sprachen, zumindest wenn man wenigstens wie in C mit den in setjmp.h definierten Makros die Möglichkeit hat, eine Aufrufverschachtelung abzuschneiden. Javas innere Klassen sind allerdings syntaktisch besonders elegant, um gemeinsamen Zugriff auf lokale Variablen zwischen method() und raise() einzurichten, und Javas Thread-Modell für Anwendungen mit grafischer Oberfläche macht es besonders leicht, ein Testprogramm auch grafisch bedienen zu lassen. Java hat bekanntlich viele Aspekte eher von Objective C als von C++ übernommen, aber die Implementierung der analogen Funktionen ist in Objective C deutlich unschöner. Interessenten können den Code für Java und Objective C vom FTP-Server der iX beziehen; .class-Dateien liegen bei, denn auch JDK 1.1.7 kann einen trivialen Aspekt des Tests nicht korrekt übersetzen.

AXEL-TOBIAS SCHREINER
BERND KÜHL

laufen Ski in der Ramsau und schreiben dabei Compiler und Artikel.
(hb)