Blöder Fehler

Wären Programmierer unfehlbar, gäbe es das Problem mit der Fehlerbehandlung nicht. Da dem aber nicht so ist, stellt sich die Frage, wie man eigentlich mit Programmierfehlern umgehen muss.

In Pocket speichern vorlesen Druckansicht 4 Kommentare lesen
Lesezeit: 16 Min.
Von
  • Michael Wiedeking

Neulich habe ich für eine Werkzeugfamilie ein kleines Framework schreiben wollen, um deren Gemeinsamkeiten und Unterschiede besser überblicken zu können. Ich wollte diese Applikationen in Form von Objekten mit Lebenszyklus definiert wissen, bei denen das Auswerten der Kommandozeilenparameter und das Starten der Applikationen derart vereinheitlicht würden, sodass man sich auch bei Erweiterungen möglichst wenig um ständig wiederkehrende Aufgaben kümmern muss. Bei der Gelegenheit bin ich (wieder einmal) auf das Problem gestoßen, wie denn die Fehlerbehandlung auszusehen hat, für den Fall, dass auch Fremde dieses Framework einsetzen wollen. (Wie immer sind die Beispiele in Java gehalten, können aber sehr leicht auf andere Programmiersprachen und -umgebungen übertragen werden.)

Will man sich nur um Programmierfehler kümmern, ist die Sache meist ganz einfach: Wann immer etwas nicht stimmt, wirft man eine Ausnahme. Das ist auch relativ einfach zu bewerkstelligen, indem man etwa bei der Definition einer Methode, noch bevor man sich an die eigentliche Arbeit macht, einen Wächter installiert. Auf diese Weise stellt man nämlich sicher, dass auch wirklich alle Bedingungen erfüllt sind, die notwendig sind, um die Aufgabe zufriedenstellend zu erledigen.

void f(int v) {
if (v < 0) {
throw new IllegalArgumentException(
"The velocity v must not be negative")
);
}

}

Losgelöst davon, ob nun die Meldung in der Ausgabe mit einem Groß- oder Kleinbuchstaben beginnt, einen echten Satz formuliert oder mit einem Satzzeichen beendet werden soll, kann damit sicher gestellt werden, dass der Programmierer – in der Annahme, dass er das Programm an dieser Stelle getestet hat – von seinem Fehler erfährt. Leider wird diese Eigenschaft nicht automatisch dokumentiert, weil die meisten Sprachen keine formalen Aussagen über den Gültigkeitsbereich machen können, obwohl eine formale Vorbedingung (Precondition) hier Abhilfe schaffen könnte. Nebenbei bemerkt würde es hier leider auch nichts helfen, eine unsigned-Variable zu wählen, denn das wäre etwas völlig anderes und würde bei beliebigen Bedingungen auch nicht weiter helfen.

Mit der geworfenen Ausnahme bekommt man in der Regel auch den Stack-Trace geschenkt – wenn einem so etwas gefällt. Der Stack-Trace hat ja den unschlagbaren Vorteil, dass man wenigstens die Stelle ausfindig machen kann, an der das Problem aufgetreten ist und welche Aufrufhierarchie dabei durchlaufen wurde. Das ist der sprichwörtliche Strohhalm, an den man sich klammert, wenn man überhaupt keine Ahnung hat, wie etwas schief laufen konnte. Denn eigentlich hat, wenn ein Stack-Trace nötig ist, meist überhaupt keine vernünftige Fehlerbehandlung stattgefunden.

Fehler zu behandeln ist auch tatsächlich nicht ganz einfach, muss man sich doch mit Szenarien auseinandersetzen, die einen weder interessieren noch erwünscht sind. Hat man sich aber für eine umfassende Fehlerbehandlung entschieden, stellt sich die Frage, wie ausführlich diese sein muss. Der oben verwendete Wächter ist einfach und sehr effektiv, denn er schützt den Code vor unsachgemäßer Nutzung. Das bewahrt einen zwar nicht vor Fehlern in der bewachten Implementierung, kann aber bei leichtfertigen Schuldzuweisungen sehr entlastend wirken.

Um mein Applikations-Framework benutzen zu können, muss man von der Klasse Application ableiten und eine spezielle statische Methode mit den Kommandozeilenparametern aufrufen – ganz ohne main geht es also doch nicht. Dann wird ein Objekt dieser Klasse instanziiert, das danach den Applikationszyklus durchläuft: Konstruktion, Initialisierung, Lauf, Beenden. Dazu muss die Klasse entweder geeignete Methoden überschreiben (etwa run) oder besondere Eigenschaften mitbringen (beispielsweise einen öffentlichen Konstruktor).

public class MyApplication extends Application {

@Argument(repetition = REQUIRED)
private String name;

@Override
public void run() {
System.out.println("Hello " + name + "!");
}

public static void main(String[] args) {
Application.start(MyApplication.class, args);
}

}

Diese kleine Applikation verlangt genau einen Kommandozeilenparameter, der als String der Instanzvariablen name zugewiesen wird. Wird der gewünschte Parameter nicht angegeben oder werden zu viele davon übergeben, wird das mit einer entsprechenden Fehlermeldung quittiert und die run-Methode erst gar nicht durchlaufen. Die Applikation läuft also nur dann, wenn die korrekte Anzahl an Parametern und Optionen übergeben wurde und die init-Methode – die hier nicht überschrieben wurde – die so initialisierten Instanzvariablen akzeptiert und die Applikation als fortsetzungswürdig akzeptiert.

> java MyApplication
Fehler: Der Applikation fehlen Kommandozeilenargumente.
> java MyApplication World
Hello World!
>

Nebenbei bemerkt wäre mir natürlich lieber gewesen, die main-Methode zu "erben", um so auf diesen expliziten start-Aufruf verzichten zu können. Aber unter Java gibt es leider kein Verfahren herauszufinden, welche Klasse tatsächlich gestartet wurde, da hier (wegen der Stack-Traces für die ProtectionDomains) immer nur die Klasse der ausgeführten, originalen Methode erkannt wird.

Um nun in der start-Methode die angegebene Klasse application_class zu instanziieren, muss eigentlich nur der folgende Code ausgeführt werden (der erste Parameter soll an dieser Stelle erst einmal ignoriert werden):

private static Application createApplication(
Class<? extends ApplicationBase> application_type,
Class<? extends Application> application_class
) {
return application_class.newInstance();
}

Das macht einen sehr geradlinigen Eindruck – wären da nicht die vielen Möglichkeiten, warum dies fehlschlagen kann. In Java beispielsweise können in dieser simplen Anweisung die Ausnahmen InstantiationException, IllegalAccessException, ExceptionInInitializerError, SecurityException und ClassCastException auftreten, zuzüglich der Ausnahmen, die im Konstruktor der Klasse geworfen werden können. Dabei sind fast alle Ausnahmen, die auftreten können, die Folge echter Programmierfehler. Lediglich die SecurityException könnte auch durch einen Konfigurationsfehler hervorgerufen werden.

Die einfachste Methode, mit diesen Programmierfehlern umzugehen, wäre die Ausnahme einfach auszugeben und das Programm zu beenden, da ohne entsprechende Instanz die Applikation einfach nicht laufen kann. Jeder dieser Fehler wäre also ein fataler Fehler, der einen sofortigen Abbruch rechtfertigen würde. Die Ausgabe würde auch den Stack-Trace ausgeben, der allerdings keinerlei Hilfe leisten würde. Dieser könnte nämlich beispielsweise so aussehen:

java.lang.IllegalAccessException: Class 
de.mathema.util.applet.implement.ApplicationBase
can not access a member of
class MyApplication with modifiers "public"
at sun.reflect.Reflection.ensureMemberAccess(Reflection.java:95)
at java.lang.Class.newInstance0(Class.java:366)
at java.lang.Class.newInstance(Class.java:325)
at de.mathema.util.applet.implement.ApplicationBase.createApplication
(ApplicationBase.java:222)
at de.mathema.util.applet.implement.ApplicationBase.run
(ApplicationBase.java:106)
at de.mathema.util.applet.implement.ApplicationBridge.start
(ApplicationBridge.java:29)
at de.mathema.util.applet.Application.start(Application.java:82)
at ApplicationTest.testMyApplication(ApplicationTest.java:43)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke
(NativeMethodAccessorImpl.java:57)
at sun.reflect.DelegatingMethodAccessorImpl.invoke
(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:601)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall
(FrameworkMethod.java:44)
at org.junit.internal.runners.model.ReflectiveCallable.run
(ReflectiveCallable.java:15)
at org.junit.runners.model.FrameworkMethod.invokeExplosively
(FrameworkMethod.java:41)
at org.junit.internal.runners.statements.InvokeMethod.evaluate
(InvokeMethod.java:20)
at org.junit.runners.BlockJUnit4ClassRunner.runNotIgnored
(BlockJUnit4ClassRunner.java:79)
at org.junit.runners.BlockJUnit4ClassRunner.runChild
(BlockJUnit4ClassRunner.java:71)
at org.junit.runners.BlockJUnit4ClassRunner.runChild
(BlockJUnit4ClassRunner.java:49)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:193)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:52)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:191)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:42)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:184)
at org.junit.runners.ParentRunner.run(ParentRunner.java:236)
at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run
(JUnit4TestReference.java:50)
at org.eclipse.jdt.internal.junit.runner.TestExecution.run
(TestExecution.java:38)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests
(RemoteTestRunner.java:467)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests
(RemoteTestRunner.java:683)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run
(RemoteTestRunner.java:390)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main
(RemoteTestRunner.java:197)

Eine Fehlermeldung dieser Art ist einfach nicht akzeptabel, für den Programmierer nicht und für den Benutzer des Programms schon gar nicht. Wenn Sie sich gefragt haben, warum ich so unglaublich viel Platz verschwendet habe, dann müssten Sie eigentlich wissen, was ich meine. Darüber hinaus wissen Sie jetzt, dass Klasse ApplicationBase, die im Programm überhaupt nicht vorkommt, ein Problem hatte; irgendwie noch eine ApplicationBridge zuständig ist und hier mit JUnit 4 gearbeitet wurde. Das ist alles Information, die ihnen nicht nur nicht weiterhilft, sondern auch für den Software-Lieferanten ein hohes Sicherheitsrisiko darstellt. (Das gilt übrigens auch für Websites, über die etwa Transaktionen abgewickelt werden. Hier darf auf keinen Fall – also niemals und unter keinen Umständen – ein Stack-Trace auf der Client-Seite ausgegeben werden, da dies für Hacker von unschätzbarem, aufschlussreichem Wert ist.)

Deshalb habe ich die Ausnahme ProgrammingError ins Leben gerufen, die zwar grundsätzlich den gleichen Zweck wie eine IllegalArgumentException hat, allerdings keinen Stack-Trace enthält. Mein ProgrammingError ist allerdings nicht von IllegalArgumentException abgeleitet, weil das suggerieren könnte, man hätte irgendwo ein fehlerhaftes Argument angegeben. Hat man aber nicht! Der Programmierer hat einfach an einer völlig anderen Stelle etwas falsch gemacht: Wahrscheinlich hat er die Dokumentation nicht gelesen.

Deshalb wird jetzt obige java.lang.IllegalAccessException abgefangen und stattdessen ein ProgrammingError geworfen, der die folgende Meldung enthält:

The class 'MyApplication' and its nullary constructor must be accessible 
(i.e. public) (see de.mathema.util.applet.Application)

Mit dieser Fehlermeldung kann der Entwickler hoffentlich etwas mehr anfangen. Der vollständige Code für den ehemaligen "Einzeiler" sieht mit ordentlicher Fehlerbehandlung also wie folgt aus:

private static Application createApplication(
Class<? extends ApplicationBase> application_type,
Class<? extends Application> application_class
) {

if (!application_type.isAssignableFrom(application_class)) {
throw new ProgrammingError(
Application.class,
"The given application class '" + application_class.getName() +
"' is not derived from the required application type '" +
application_type.getName() + "'"
);
}

Application application = null;

try {

application = application_class.newInstance();

} catch (InstantiationException e) {

throw new ProgrammingError(
Application.class,
"The class '" + application_class +
"' must not be an abstract class and must " +
"have a nullary constructor"
);

} catch (IllegalAccessException e) {

throw new ProgrammingError(
Application.class,
"The class '" + application_class.getName() +
"' and/or its nullary " +
"constructor must be accessible (i.e. public)"
);

} catch (ExceptionInInitializerError e) {

throw new ProgrammingError(
Application.class,
"The constructor of class '" + application_class.getName() +
"' has produced an initialization error",
e.getCause()
);

} catch (SecurityException e) {

throw new ConfigurationError(
Application.class,
"The underlying SecurityManager denied the construction of the " +
"class '" + application_class.getName() + "'",
e
);

} catch (Exception e) {

throw new ProgrammingError(
Application.class,
"The application class '" + application_class.getName() +
"' produced an initialization error",
e
);

}

return application;

}

Dort enthält der erste Parameter der createApplication-Methode den Applikationstyp, der sicherstellt, dass nicht etwa eine normale Application als SwingApplication verwendet wird oder umgekehrt. Wer glaubt, dass man derartige Überprüfungen doch dem Compiler überlassen könnte, dem soll nur die folgende Zeile in
Erinnerung gerufen werden, die sich problemlos übersetzen lässt:

Integer i = (Integer) (Object) "1";

Die ProgrammingError-Ausnahme enthält als ersten Parameter die Hauptklasse des Frameworks, die dessen Dokumentation enthält; damit ist sicher gestellt, dass bei einem Refactoring nicht versehentlich der Hinweistext (see de.mathema.util.applet.Application) falsch werden kann. Wird optional eine Exception angegeben, so wird auch der Stack-Trace ausgegeben, allerdings wird dabei alle Framework-Information entfernt:

Programming Error: The nullary constructor of application class 
'MyApplication'
produced an initialization error (see de.mathema.util.applet.Application)
Caused by: java.lang.IllegalArgumentException: …
at MyApplication.<init>(MyApplication.java:10)
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance
(NativeConstructorAccessorImpl.java:57)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance
(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:525)
at java.lang.Class.newInstance0(Class.java:372)
at java.lang.Class.newInstance(Class.java:325)

Selbst das ist mir noch zu lang. Im Falle dieser reflektiven Erzeugung von Objekten könnte man noch auf die Ausgabe aller java- und sun-Klassen verzichten, aber das gilt nicht allgemein. Also habe ich mich in der ersten Implementierung noch damit zufrieden gegeben, nur "meine" Klassen zu entfernen. Und so ist die Meldung doch länger als die folgende ideale Variante:

Programming Error: The nullary constructor of application class 
'MyApplication'
produced an initialization error (see de.mathema.util.applet.Application)
Caused by: java.lang.IllegalArgumentException: …
at MyApplication.<init>(MyApplication.java:10)

Bei dem verwendeten ConfigurationError im Falle der SecurityException bin ich mir nicht sicher, wer denn tatsächlich dafür zuständig ist. Aber die Erfahrung sagt, dass eine Applikation in der Regel über ein Skript oder sogar Programm gestartet wird und damit die Verantwortung beim Deployer liegt. Der ist eher ein Programmierer, bekommt auch eine entsprechend "einfache" Meldung. Hat man es im Falle der Konfiguration nämlich mit einem unwissenden Normalsterblichen zu tun, hat man meist keine Freude daran, ihm zu erklären, wie man die VM-Parameter korrekt einstellt. Allerdings kommt der zum Glück auch nicht von alleine darauf, einen SecurityManager zu installieren.

Unter der Annahme, dass man mit der Installation eines SecurityManagers rechnet, reicht die angegebene Fehlermeldung natürlich nicht aus. In diesem speziellen Fall müsste der Entwickler nämlich neben der Fehlermeldung erfahren, was er tun muss, um einen solchen Fehler zu umgehen:

Der SecurityManager muss die RuntimePermission("accessDeclaredMembers") 
und/oder die RuntimePermission("accessClassInPackage.pkg") gewähren
(wobei pkg das Paket der zu instanziierenden Klasse ist).

Das wäre für den Entwickler sehr bequem, denn er kann das (fast) einfach abtippen. Möglicherweise reicht hier aber auch ein Hinweis auf die verantwortliche Java-Methode Class.newInstance.

Für mich stellt sich nur noch die Frage, wie solche Fehlermeldungen auszusehen haben. Dabei ist es eigentlich irrelevant, ob diese an den Endnutzer oder an den Entwickler gerichtet sind. Im ersten mehr als im letzten Fall muss eine solche Meldung ggf. auch internationalisierbar sein. Für diese einfachen Meldungen habe ich mich dazu entschieden, sie immer derart zu formulieren, dass sie den Missstand und die Lösung beschreiben: "Die Klasse tut dieses oder jenes nicht" oder umgekehrt als Anforderung "Die Klasse muss dieses oder jenes tun".

Für den Programmierer, der dieses kleine Framework verwenden will, ist diese Art der Fehlerbehandlung hoffentlich ausreichend hilfreich. Aber es müssten eigentlich noch andere Wünsche erfüllt werden. So stört mich, dass sich eine FileNotFoundException nicht einfach mit dem Dateinamen zufrieden gibt, sondern einen String verlangt – was immer das bedeutet. Demnach kann ich nur dem String entnehmen, warum die Datei nicht gefunden werden konnte. Ist diese Zeichenkette nur der Dateiname, dann ist der Name der Exception-Klasse Bestandteil der Meldung, was im Zusammenhang mit der Internationalisierbarkeit Schwierigkeiten bereiten könnte.

Zudem lassen sich "gröbere" Ausnahmen wie eine IOException überhaupt nicht voneinander unterscheiden, da sie nur in dem darin enthaltenen Text die eigentliche Information enthalten. Sollte eine andere Ausnahme die Ursache für diese sein, liegt auch diese meist auch nur als String vor. Das ist nicht akzeptabel, wenn diese Information benutzergerecht aufbereitet werden soll. Dann können für einen Anfänger derartige Meldungen zu kurz und unverständlich sein. Es wäre also wünschenswert, wenn Meldungen mit unterschiedlichem Detaillierungsgrad ausgegeben werden könnten.

Das Thema Fehlerbehandlung verdient also eine gehörige Portion Aufmerksamkeit und kommt allzu oft bei der Entwicklung von Software viel zu kurz. Über dieses Thema möchte ich also noch ein paar Runden drehen. Und vielleicht findet sich dann dabei auch eine vernünftige Lösung.

PS: Die obigen Fehlermeldungen selbst sind in (meinem mäßigen) Englisch gehalten, wenngleich diese auch wie oben angedeutet durchaus internationalisiert werden könnten. Als ich neulich die main-Methode aus einer Klasse entfernt hatte, erhielt ich ohne eigenes Zutun die folgende Meldung:

Fehler: Hauptmethode in Klasse de.mathema.MyClass nicht gefunden. 
Definieren Sie die Hauptmethode als:
public static void main(String[] args)

Ich weiß nicht, ob ich die main-Methode mit Hauptmethode übersetzt hätte, aber irgendwie ist sie das ja, eine Hauptmethode. ()