9 Ausnahmen müssen sein
Warum nicht Ausnahmen zur Regel machen, wenn Ausnahmen die Regel bestätigen?
– Georg-Wilhelm Exler
Programmfehler sind unvermeidlich; Eingaben könnten falsch sein, Dateien könnten verschwinden, Netzwerkverbindungen zusammenbrechen. Eine besondere Herausforderung sind die unerwarteten Fehler – Java bietet die elegante Methode der Exceptions, um Ausnahmen abzufangen, sodass sich Programme aus fast jeder Situation wieder retten können.
In den frühen Programmiersprachen gab es für Routinen keine andere Möglichkeit, als über den Rückgabewert einen Fehlschlag anzuzeigen – in der Programmiersprache C ist das auch heute noch der Fall. Damit gibt es zwei Probleme:
-
Der Fehlercode ist häufig ein »magischer« Wert wie -1, aber auch NULL oder 0. Allerdings kann die Null auch Korrektheit anzeigen. Irgendwie ist das willkürlich. Die Abfrage dieser Werte ist zwingend nötig, und es könnte sich eine Annahme einschleichen wie: »Das wird immer gelingen, ein Fehler ist unmöglich.« Wenn das Programm diesen Fehler dann nicht erkennt und weitermacht, kann das zu bösen Überraschungen führen.
-
Abfragen der Rückgabewerte unterbrechen den Programmfluss unangenehm, zumal der Rückgabewert, wenn er nicht gerade einen Fehler anzeigt, weiterverwendet wird. Der Rückgabewert ist also im weitesten Sinne überladen, da er zwei Zustände anzeigt. Häufig entstehen mit den Fehlerabfragen kaskadierte if-Abfragen, die den Quellcode schwer lesbar machen. Dann wandert der eigentliche Algorithmus im Code immer weiter nach rechts.
[zB] Beispiel
Die Java-Bibliothek geht bei den Methoden delete(), mkdir(), mkdirs() und renameTo(…) der Klasse File nicht mit gutem Beispiel voran. Anstatt über eine Ausnahme anzuzeigen, dass die Operation nicht geglückt ist, liefern die genannten Methoden false. Das ist unglücklich, denn viele Entwickler verzichten auf den Test, und so entstehen Fehler, die später schwer zu finden sind.
9.1 Problembereiche einzäunen
Bei der Verwendung von Exceptions wird der Programmcode nicht durch Abfrage des Rückgabestatus unterbrochen. Ein besonders ausgezeichnetes Programmstück überwacht die ordentliche Ausführung und ruft im Fehlerfall speziellen Programmcode zur Behandlung auf.
9.1.1 Exceptions in Java mit try und catch
Den überwachten Programmbereich (Block) leitet das Schlüsselwort try ein. Dem try-Block folgt in der Regel[ 182 ](In manchen Fällen auch ein finally-Block, sodass es dann ein try-finally wird. ) ein catch-Block, in dem Programmcode steht, der die Ausnahme behandelt. Kurz skizziert, sieht das so aus:
try {
// Programmcode, der eine Ausnahme ausführen kann
}
catch ( ... ) {
// Programmcode zum Behandeln der Ausnahme
}
// Es geht ganz normal weiter, denn die Ausnahme wurde behandelt
Fehler führen zu Ausnahmen, und diese Ausnahmen behandelt ein catch-Block. Hinter catch folgt der Programmblock, der beim Auftreten einer Ausnahme ausgeführt wird, um den Fehler abzufangen (daher der Ausdruck catch). Der Fehler kann behandelt, auf der Kommandozeile gemeldet oder etwa in einen Logger geschrieben werden. Ob es zur Laufzeit wirklich zu einem Fehler kommt, ist nicht bekannt, aber wenn, dann ist eine Behandlung vorhanden.
Es ist nach der Fehlerbehandlung nicht mehr so einfach möglich, an der Stelle fortzufahren, an der der Fehler auftrat. Auch lässt sich im Nachhinein nicht wirklich feststellen, an welcher Stelle genau der Fehler aufgetreten ist, wenn es in einem großen try-Block mehrere ausnahmenauslösende Stellen gibt. Andere Programmiersprachen erlauben das durchaus.
9.1.2 Geprüfte und ungeprüfte Ausnahmen
Java unterscheidet zwischen zwei Gruppen von Ausnahmen: geprüften und ungeprüften Ausnahmen.
-
Die geprüften Ausnahmen müssen zwingend behandelt werden, indem sie entweder aufgefangen oder weitergeleitet werden. Sie können von Methoden oder Konstruktoren im Fehlerfall ausgelöst werden und finden sich der Regel bei Ein-/Ausgabeoperationen.
-
Ungeprüfte Ausnahmen müssen nicht unbedingt aufgefangen werden. Treten die Ausnahmen jedoch auf und werden sie nicht aufgefangen, so führt das zum Ende des ausführenden Threads.
Vorkommen von ungeprüften Ausnahmen (RuntimeException) in der Java-Bibliothek
Einige Arten von Ausnahmen können potenziell an vielen Programmstellen auftreten, etwa eine ganzzahlige Division durch null[ 183 ](Fließkommadivisionen durch 0,0 ergeben entweder ± unendlich oder NaN. ) oder ungültige Indexwerte beim Zugriff auf Array-Elemente. Treten solche Ausnahmen beim Programmlauf auf, liegt ihnen in der Regel ein Denkfehler des Programmierers zugrunde, und das Programm sollte normalerweise nicht versuchen, die ausgelöste Ausnahme aufzufangen und zu behandeln. Daher gibt es in der Java-API mit der Klasse RuntimeException eine Unterklasse von Exception, die Programmierfehler aufzeigt, die behoben werden müssen. (Der Name »RuntimeException« ist jedoch seltsam gewählt, da alle Ausnahmen immer zur Runtime, also zur Laufzeit, erzeugt, ausgelöst und behandelt werden. Doch drückt er aus, dass der Compiler sich für diese Ausnahmen nicht interessiert, sondern erst die JVM zur Laufzeit.)
[»] Hinweis
Es funktioniert gut, eine RuntimeException als Selbst-schuld-Fehler zu sehen. Durch sachgemäße Prüfung, z. B. der Wertebereiche, würden viele RuntimeExceptions nicht entstehen.
9.1.3 Eine NumberFormatException fliegt (ungeprüfte Ausnahme)
Über die Methode Integer.parseInt(…) haben wir an verschiedenen Stellen schon gesprochen. Sie konvertiert eine Zahl, die als Zeichenkette gegeben ist, in eine Dezimalzahl:
int vatRate = Integer.parseInt( "19" );
In diesem Beispiel ist eine Konvertierung möglich, und die Methode führt die Umwandlung ohne Ausnahme aus. Anders sieht das aus, wenn der String keine Zahl repräsentiert:
package com.tutego.insel.exception; /* 1 */
public class MissNumberFormatException { /* 2 */
public static int getVatRate() { /* 3 */
return Integer.parseInt( "19%" ); /* 4 */
} /* 5 */
public static void main( String[] args ) { /* 6 */
System.out.println( getVatRate() ); /* 7 */
} /* 8 */
} /* 9 */
Die Ausführung des Programms bricht mit einer Ausnahme ab, und die virtuelle Maschine gibt uns automatisch eine Meldung aus:
Exception in thread "main" java.lang.NumberFormatException: For input string: "19%"
at java.base/java.lang.NumberFormatException.forInputString(
NumberFormatException.java:65)
at java.base/java.lang.Integer.parseInt(Integer.java:652)
at java.base/java.lang.Integer.parseInt(Integer.java:770)
at c.t.i.e.MissNumberFormatException.getVatRate(MissNumberFormatException.java:4)
at e.t.i.e.MissNumberFormatException.main(MissNumberFormatException.java:7)
In der ersten Zeile sehen wir, dass eine java.lang.NumberFormatException ausgelöst wurde. In der letzten Zeile steht, welche Stelle in unserem Programm zu der Ausnahme führte. (Fehlerausgaben wie diese haben wir schon im Abschnitt »Auf null geht nix, nur die NullPointerException« in Abschnitt 3.7.1, »null-Referenz und die Frage der Philosophie«, beobachtet.)
Eine NumberFormatException auffangen
Dass ein Programm einfach so abbricht und die JVM endet, ist üblicherweise keine Lösung. Ausnahmen sollten aufgefangen und gemeldet werden. Um Ausnahmen aufzufangen, ist es erst einmal wichtig, zu wissen, was genau für eine Ausnahme ausgelöst wird. In unserem Fall ist das einfach abzulesen, denn die Ausnahme ist ja schon aufgetaucht und klar einem Grund zuzuordnen. Die Java-Dokumentation nennt diese Ausnahme auch, und weil ohne die aufgefangene Ausnahme das Programm abbricht, soll nun die NumberFormatException aufgefangen werden. Dabei kommt wieder die try-catch-Konstruktion zum Einsatz:
String stringToConvert = "19%";
double vat = 19;
try {
vat = Integer.parseInt( stringToConvert );
}
catch ( NumberFormatException e ) {
System.err.printf( "'%s' kann man nicht in eine Zahl konvertieren!%n",
stringToConvert );
}
System.out.printf( "Weiter geht's mit MwSt=%g%n", vat );
Die gesamte Ausgabe ist:
'19%' kann man nicht in eine Zahl konvertieren!
Weiter geht's mit MwSt=19,0000
Integer.parseInt("19%") führt, da der String keine Zahl ist, zu einer NumberFormatException, die wir behandeln, und danach geht es mit der Konsolenausgabe weiter.
Der try-Anweisung folgt ein Block, genannt try-Block. Wir nutzen ihn in Kombination mit einer catch-Klausel. Der Code catch(NumberFormatException e) deklariert einen Exception-Handler – er fängt alles auf, was vom Ausnahmetyp NumberFormatException ist. Die Variable e ist ein Exception-Parameter. Die Nutzung von var ist nicht erlaubt. Da Ausnahmen Objekte sind, referenziert die Variable e dieses Ausnahmeobjekt.
9.1.4 UUID in Textdatei anhängen (geprüfte Ausnahme)
Wir wollen ein zweites Programm mit Ausnahmebehandlung schreiben, das eine UUID – einen 128 Bit langen Universally Unique Identifier – in Textform an eine Textdatei anhängt. Die UUID lässt sich einfach über die Klasse UUID ermitteln. In Dateien können wir mit der Methode Files.writeString(…) schreiben – die Methode ist Bestandteil der Files-Klasse seit Java 11.
Der relevanten Zeilen:
String content = UUID.randomUUID() + "\n";
Files.writeString( Path.of( "uuids.txt" ),
content, StandardOpenOption.APPEND );
Die Zeilen können so nicht übersetzt werden, und es gibt einen Compilerfehler. Der Grund ist Files.writeString(…), denn diese Methode kann eine IOException auslösen. IOException ist eine geprüfte Ausnahme, das heißt, wir müssen uns der Tatsache stellen, dass es knallen könnte, und uns für diesen Fall wappnen.
Dokumentierte Ausnahmen in der Javadoc
Eine Ausnahme kommt nicht wirklich überraschend, und Entwickler müssen sich darauf vorbereiten, dass, wenn sie etwas Falsches an Methoden oder Konstruktoren übergeben, diese schimpfen. Im besten Fall erklärt die API-Dokumentation, welche Eingaben gültig sind und welche nicht. Zur »Schnittstelle« einer Methode gehört auch das Verhalten im Fehlerfall. Die API-Dokumentation sollte genau beschreiben, welche Ausnahme – oder Reaktion wie spezielle Rückgabewerte – zu erwarten ist, wenn die Methode ungültige Werte erhält. Die Java-Dokumentation bei Files.writeString(…) macht das, siehe Abbildung 9.3:
Die Beschreibung »IOException – if an I/O error occurs writing to or creating the file, or the text cannot be encoded using the specified charset« ist vielleicht ein bisschen vage, denn sie erklärt nicht, warum genau der Fehler auftrat. Aber es nützt nichts, wir müssen den Fehler behandeln. Wir sehen auch, dass IOException nicht der einzige Fehler ist, der in der Liste steht. Es könnte auch zu einer IllegalArgumentException, UnsupportedOperationException oder SecurityException kommen, doch hier zwingt uns keiner, diese aufzufangen. Das ist genau der Unterschied zwischen einer geprüften und einer ungeprüften Ausnahme.
try-catch-Behandlung
Da Files.writeString(…) eine IOException auslösen kann und das eine geprüfte Ausnahme ist, die behandelt werden muss, gibt es zwei Lösungen: erstens mit try-catch den Fehler behandeln oder zweitens mit throws den Fehler an die Aufrufstelle weiterleiten.
Lösen wir das Problem in unserem Programm mit der IOException zunächst durch eine direkte try-catch-Behandlung:
public class UuidWriter {
public static void writeUuid() {
String content = UUID.randomUUID().toString();
try {
Files.writeString( Path.of( "uuids.txt" ),
content, StandardOpenOption.APPEND );
}
catch ( IOException e ) {
e.printStackTrace();
}
}
public static void main( String[] args ) {
writeUuid();
}
}
Nach dem Auffangen ist der Fehler wie weggeblasen, und alles geht ganz normal weiter.
Stack-Trace
Die virtuelle Maschine merkt sich auf einem Stapel, welche Methode welche andere Methode aufgerufen hat. Dies nennt sich Stack-Trace. Wenn also die statische main(…)-Methode die Methode writeUuid() aufruft und diese wiederum writeString(…), so sieht der Stapel zum Zeitpunkt von writeString(…) so aus:
writeString
writeUuid
main
Ein Stack-Trace ist im Fehlerfall nützlich, da wir in ihm ablesen können, dass writeString(…) die Ausnahme ausgelöst hat und nicht irgendeine andere Methode.
Oftmals werden im Programm Stack-Traces geloggt. Hierbei hilft eine Methode, die schon im catch-Block steht: e.printStackTrace(). Sie setzt den Stack-Trace standardmäßig auf den System.err-Kanal.
9.1.5 Wiederholung abgebrochener Bereiche *
Es gibt in Java bei Ausnahmen bisher keine von der Sprache unterstützte Möglichkeit, an den Punkt zurückzukehren, der die Ausnahme ausgelöst hat. Das ist aber oft erwünscht, etwa dann, wenn eine fehlerhafte Eingabe zu wiederholen ist.
Wir werden auf der Konsole nach einem String fragen und versuchen, diesen in eine Zahl zu konvertieren. Dabei kann natürlich etwas schiefgehen. Wenn ein Benutzer eine Zeichenkette eingibt, die keine Zahl repräsentiert, löst parseInt(…) eine NumberFormatException aus. Wir wollen in diesem Fall die Eingabe wiederholen:
int number;
while ( true ) {
try {
System.out.println( "Bitte Zahl eingeben:" );
String input = new java.util.Scanner( System.in ).nextLine();
number = Integer.parseInt( input );
break;
}
catch ( NumberFormatException e ) {
System.err.println( "Das war keine Zahl!" );
}
}
System.out.printf( "%d ist eine %s Zahl%n", number,
Math.random() > 0.5 ? "heiße" : "lahme" );
Die gewählte Lösung ist einfach: Wir programmieren den gesamten Teil in einer Endlosschleife. Geht die problematische Stelle ohne Ausnahme durch, so beenden wir die Schleife mit break. Kommt es zu einer NumberFormatException, dann wird break nicht ausgeführt, und der Programmfluss führt wieder in die Endlosschleife.
Im Übrigen würde auch new java.util.Scanner(System.in).nextInt() unsere Ganzzahl einlesen können, nur wenn die Methode keine Zahl bekommt, löst sie eine InputMismatchException aus – auch das ist eine ungeprüfte Ausnahme.
9.1.6 Bitte nicht schlucken – leere catch-Blöcke
Java schreibt vor, dass Ausnahmen in einem catch behandelt (oder nach oben geleitet) werden, aber nicht, was in catch-Blöcken zu geschehen hat. Sie können eine sinnvolle Behandlung beinhalten oder auch einfach leer sein. Ein leerer catch-Block ist in der Regel wenig sinnvoll, weil dann die Ausnahme klammheimlich unterdrückt wird. (Das wäre genauso wie ignorierte Statusrückgabewerte von C-Funktionen.)
Das Mindeste ist eine minimale Fehlerausgabe via System.err.println(e) oder das informativere e.printStackTrace(…) für eine Exception e oder das Loggen dieser Ausnahme. Noch besser ist das aktive Reagieren, denn die Ausgabe selbst behandelt diese Ausnahme nicht! Im catch-Block ist es durchaus legitim, wiederum andere Ausnahmen auszulösen und somit die Ausnahme umzuformen und nach oben weiterzureichen.
[»] Hinweis *
Wenn wie bei einem Thread.sleep(…) die InterruptedException wirklich egal ist, kann natürlich auch der Block leer sein, doch gibt es dafür nicht so viele sinnvolle Beispiele.
9.1.7 Mehrere Ausnahmen auffangen
In einem Programmblock kann es mehrere Stellen geben, die eine Ausnahme auslösen. In den folgenden Zeilen wird zweimal etwas geschrieben:
try {
Path p = Path.of( "uuids.txt" );
Files.writeString( p, UUID.randomUUID() + "\n", StandardOpenOption.APPEND );
Files.writeString( p, UUID.randomUUID() + "\n", StandardOpenOption.APPEND );
}
catch ( IOException e ) { ... }
Lösen mehrere Stellen den gleichen Ausnahmetyp aus, so lässt sich in der Ausnahmebehandlung später nicht mehr so einfach nachvollziehen, welche der beiden Anweisungen zur Ausnahme geführt hat. Wenn das wichtig ist, sollten zwei getrennte try-Blöcke eingesetzt werden, entweder hintereinander oder ineinander geschachtelt.
Anders sieht es aus, wenn unterschiedliche Ausnahmen ausgelöst werden, etwa eine IOException auf der einen Seite und zum Beispiel eine NumberFormatException auf der anderen Seite.
Jetzt gibt es mehrere Möglichkeiten:
-
Alle Ausnahmen werden aufgefangen.
-
Einige Ausnahmen werden aufgefangen, andere werden an den Aufrufer weitergeleitet.
-
Alle Ausnahmen werden nach oben weitergeleitet.
Ein Beispiel: Von der Kommandozeile soll eine Ganzzahl eingelesen und genauso viele UUIDs sollen in eine Textdatei geschrieben werden:
Path p = Path.of( "uuids.txt" );
try {
int count = new Scanner( System.in ).nextInt();
for ( int i = 0; i < count; i++ ) {
Files.writeString( p, UUID.randomUUID() + "\n",
StandardOpenOption.APPEND );
}
}
catch ( InputMismatchException e ) {
System.err.println( "Eingabe war keine Ganzzahl" );
}
catch ( IOException e ) {
System.err.println( "Fehler beim Schreiben in die Datei" );
}
IOException ist eine geprüfte Ausnahme und muss zwingend behandelt werden. InputMismatchException ist eine ungeprüfte Ausnahme und müsste nicht unbedingt behandelt werden, doch wenn dann als Eingabe etwa 12ABC geparst wird, crasht das Programm mit einem Strack-Trace, und das wollen wir vermeiden. Auch beim Schreiben in die Datei kann es Ausnahmen geben. Wir müssen uns diesen potenziellen Problemen stellen und daher die Problemzonen in einen try-Block schreiben.
Kommt es dann im try-Block zu einer Ausnahme, fängt der catch-Teil sie ab. Im Code lässt sich ablesen, dass einem try-Block mehrere catch-Klauseln zugeordnet sein können, die verschiedene Typen von Ausnahmen auffangen.
9.1.8 Zusammenfassen gleicher catch-Blöcke mit dem multi-catch
Greift ein Programm auf Teile zurück, die scheitern können, so ergeben sich in komplexeren Abläufen schnell Situationen, in denen unterschiedliche Ausnahmen auftreten können. Wir sollten tendenziell versuchen, den Programmcode in einem großen try-Block zu schreiben und dann in catch-Blöcken auf alle möglichen Ausnahmen zu reagieren, die den Block vom vollständigen Durchlaufen abgehalten haben.
Oftmals kommt es zu dem Phänomen, dass die aufgerufenen Programmteile unterschiedliche Ausnahmetypen auslösen, aber die Behandlung der Ausnahmen gleich aussieht. Um Quellcode-Duplizierung zu vermeiden, sollte der Programmcode zusammengefasst werden. In Java können mehrere Ausnahmen gleich behandelt werden; diese Schreibweise heißt multi-catch. In der abgewandelten Variante von catch steht dann nicht mehr nur eine Ausnahme, sondern eine Sammlung von Ausnahmen, die ein | trennt. Der senkrechte Strich ist schon als Oder-Operator bekannt und wurde daher auch hier eingesetzt, denn die Ausnahmen sind ja auch als eine Oder-Verknüpfung zu verstehen.
Path p = Path.of( "uuids.txt" );
try {
int count = new Scanner( System.in ).nextInt();
for ( int i = 0; i < count; i++ ) {
Files.writeString( p, UUID.randomUUID() + "\n",
StandardOpenOption.APPEND );
}
}
catch ( InputMismatchException | IOException e ) {
System.err.println( "Programmfehler" );
e.printStackTrace();
}
Die Exception-Variable ist implizit final.