Kolumne

Java-Trickkiste: Von Checked und Unchecked Exceptions

Arno Haase

© Software & Support Media

Ungewöhnliche Bibliotheken, überraschende Lösungen, skurrile Codeschnipsel, idiomatische Leckerbissen, Tipps und Tricks und der Blick hinter die Kulissen der verbreitetsten Programmiersprache der Welt. Das ist das Thema dieser neuen Kolumne, die sowohl für Neulinge als auch alte Hasen Wissenswertes bereithält.

Checked Exceptions scheiden die Geister – manche lieben sie, andere hassen sie. In jedem Fall erfordern sie Aufmerksamkeit beim Design eines Systems. Grund genug, sie in der ersten Ausgabe dieser neuen Kolumne unter die Lupe zu nehmen.

Checked und Unchecked

Ein Throwable darf eine Methode nur dann verlassen, wenn es eine Subklasse von Error oder von RuntimeException ist, oder es in der throws-Klausel der Methode steht. So sagt es die Sprachspezifikation [1] in Kapitel 11.2.

Als Java im letzten Jahrtausend entworfen wurde, galt diese Unterscheidung als chic und modern. Man wollte Fehler danach unterscheiden, ob der Aufrufer sie hätte vermeiden können. Fehler wie NullPointerException oder IllegalArgumentException liegen an fehlerhafter Verwendung einer Klasse und können quasi überall auftreten. Sie zu deklarieren bringt also quasi keinen Nutzen.

Wenn ein Fehler für den Aufrufer aber unvorhersehbar ist – I/O, Validierung etc. – dann sollte er zu einem expliziten Teil des Aufrufkontrakts werden [2].

Java ist meines Wissens die einzige Sprache, die diesen Weg gegangen ist – C++ als Vorgänger, aber auch C# als kurze Zeit später entwickelte Konkurrenz verzichten darauf, ebenso andere JVM-Sprachen wie Scala oder Groovy. Auch Frameworks wie Spring verpacken viele Exceptions in Unchecked Exceptions, sodass man dort mit dem Problem kaum in Berührung kommt.

Aber wie dem auch sei, Checked Exceptions sind ein integraler Bestandteil von Java und seinen Standardbibliotheken.

Checks sind im Weg

Ich persönlich halte Checked Exceptions für einen fundamentalen Fehler im Sprachdesign von Java. Ohne da in Bashing und religiöse Auseinandersetzungen einzusteigen, möchte ich kurz erklären, warum ich das so sehe – bevor ich dann Strategien für den Umgang mit ihnen zeige. Die folgenden Probleme sind wohlgemerkt lösbar, aber die Lösungen setzen sich eben über die eigentliche Idee von Checked Exceptions hinweg.

Zunächst einmal bringt eine Throws-Klausel Implementierungsdetails in die Schnittstelle. Die Methoden von java.util.Map deklarieren z. B. keine Checked Exceptions. Aber was passiert, wenn ich eine dateibasierte Map-Implementierung schreiben will, die IOException werfen kann? Oder wenn ich einen Service von einer relationalen Datenbank auf MongoDB umstelle, sodass jetzt IOException statt SQLException auftreten kann?

In beiden Fällen koppeln Checked Exceptions (bzw. ihre Abwesenheit) die Schnittstelle an eine Implementierung. Außerdem sind Checked Exceptions bei der Verwendung von Callbacks im Weg. Betrachten wir z. B. eine (extrem vereinfachte) Methode, die die Ausführungsdauer eines Stücks Code misst (Listing 1a).

public void measure(Runnable code) {
  final long start = System.nanoTime();
  code.run();
  final long end = System.nanoTime();
  System.out.println((end - start) + "ns");
}
measure(new Runnable() {
  public void run() {
    new File(".").getAbsolutePath();
  }
});

Der zu messende Code wird als Runnable hineingereicht. Listing 1b misst z. B., wie lange Java braucht, den absoluten Pfad zum Arbeitsverzeichnis zu ermitteln.

So lange der Code keine Checked Exceptions wirft, funktioniert auch alles. Wenn wir aber z. B. File.getCanonicalPath() aufrufen, da es eine IOException werfen kann, lehnt der Compiler den Code ab.

Wir könnten statt Runnable ein Interface verwenden, das Exception deklariert, aber dann müsste die Zeitmessung ihrerseits Exception deklarieren (Listing 1c). Jetzt muss jeder, der eine Zeitmessung aufruft, sich um throws Exception kümmern, egal wie harmlos der gemessene Code ist.

public void measure(Callable code) throws Exception {
  final long start = System.nanoTime();
  code.call();
  final long end = System.nanoTime();
  System.out.println((end - start) + "ns");
}

[ header = Seite 2: Generische Exceptions ]

Generische Exceptions

Damit kommen wir zur ersten Lösungsidee: der generifizierten throws-Klausel. Der Exception-Typ einer throws-Klausel kann nämlich auch ein generischer Typ sein (Listing 2a). Dieser Typ muss natürlich auf Subtypen von Exception eingeschränkt sein (Throwable würde reichen, aber hier ist Exception handlicher).

interface Stoppable<E extends Exception> {
  void doIt() throws E;
}
public <E extends Exception> void measure(Stoppable<E> code) throws E {
  final long start = System.nanoTime();
  code.doIt();
  final long end = System.nanoTime();
  System.out.println((end - start) + "ns");
}
measure(new Stoppable<IOException>() {
    public void doIt() throws IOException {
      new File(".").getCanonicalPath();
    }
});

Die measure()-Methode bekommt ebenfalls einen generischen Parameter (Listing 2b). Dieser Parameter bestimmt, welche Exception der Callback-Code werfen kann, und legt gleichzeitig die Throws-Klausel der Methode selbst fest (Listing 2c). Wenn man keine Checked Exception benötigt, kann man RuntimeException als generischen Parameter angeben. Das liest sich etwas holprig, funktioniert aber einwandfrei.

Das Ganze hat – abgesehen von der etwas umständlichen Syntax – vor allem eine Beschränkung: Die Anzahl der Checked Exceptions in der Signatur ist ein für alle Mal festgelegt. Oder anders gesagt: Wenn unser Code-Block sowohl IOException als auch SQLException werfen will, sind wir aufgeschmissen. Das ist für Callbacks in der Praxis meist kein Problem, es lohnt sich aber, es im Hinterkopf zu behalten.

Unchecker mit Generics

Es gibt aber auch Wege, Checked Exceptions zu werfen, ohne sie zu deklarieren. Das war von den Java-Machern natürlich nicht vorgesehen, es gibt aber einige Löcher im Typsystem von Java, die man dafür ausnutzen kann.

Möglich wird das Ganze dadurch, dass Checked Exceptions ausschließlich vom Java-Compiler überprüft werden – die JVM als Laufzeitumgebung kennt dieses Konzept überhaupt nicht. Wir müssen also „nur“ den Compiler überlisten.

public class Unchecker {
  public static void rethrow (Exception th) {
    Unchecker.<RuntimeException>hide (th);
  }

  private static <T extends Exception> void hide (Exception exc) throws T {
    throw (T) exc;
  }
}

Eine Möglichkeit dazu bieten Generics, genauer generische Typen in einer throws-Klausel. Listing 3 zeigt eine Klasse Unchecker, die eine beliebige Exception nimmt und wirft, ohne dass sie deklariert wäre.

Das Herzstück der Klasse ist die private Methode hide(). Sie hat einen generischen Parameter T, der in ihrer throws-Klausel steht. Ihre Implementierung castet die übergebene Exception auf eben diesen Typ T und wirft sie anschließend.

Die öffentliche Methode rethrow() ist das API der Unchecker-Klasse und wird von Anwendungscode aufgerufen. Sie delegiert den Aufruf an hide(), allerdings mit dem festen generischen Parameter RuntimeException. Damit hat der hide()-Aufruf nur RuntimeException in seiner throws-Klausel, und rethrow() muss keine Exception deklarieren.

Listing 4 zeigt den Aufruf: Der Unchecker wird mit einer IOException aufgerufen und wirft sie auch, wie man durch einfaches Ausprobieren verifizieren kann. Die main-Methode deklariert sie allerdings nicht, und der Compiler baut das Programm trotzdem ohne zu murren.

public static void main(String[] args) {
  Unchecker.rethrow (new IOException ("Hallo"));
}

Aber warum funktioniert der Down-Cast in hide(), warum gibt es da zur Laufzeit keine ClassCastException? Der Grund liegt in der Type Erasure: Zur Laufzeit dagegen weiß die Methode hide() dank Type Erasure gar nicht, mit welchem generischen Parameter sie aufgerufen wurde. Der Typ-Cast erscheint auch nicht im Byte-Code – wie überhaupt alle Typ-Casts auf generische Parameter.

Voila, eine beliebige Exception wird geworfen, ohne dass sie in einer throws-Klausel erscheint.

Fangen mit Hindernissen

Ein solcher Unchecker ist besonders dann nützlich, wenn ein System ohnehin alle Exceptions gleich behandelt. Wenn also z. B. in einem Server die Transaktion zurückgerollt und die Exception geloggt wird, oder im Browser eine Fehlermeldung „Der Server ist gerade nicht verfügbar.“ angezeigt wird.

Schwieriger wird es, wenn man einzelne Exception-Typen fangen und gesondert behandeln will. Dann „merkt“ nämlich der Compiler, dass die entsprechende Exception gar nicht auftreten kann, und er meldet einen Fehler (Listing 5).

try {
  Unchecker.rethrow (new IOException("Hallo"));
}
catch (IOException exc) { // Compiler-Fehler!
}

Es gibt Lösungen dafür, z. B. kann man Exception als Basistyp fangen und die Unterscheidung mittels instanceof vornehmen. Das verschlechtert aber natürlich die Lesbarkeit des Codes und soll deshalb hier nicht verschwiegen werden.

[ header = Seite 3: Tunneling Exception ]

Tunneling Exception

Eine dritte Lösung für den Umgang mit Checked Exceptions ist das „Tunneln“: Man wickelt die eigentliche Exception in eine TunnelingException ein, die von RuntimeException erbt (Listing 6a).

public class TunnelingException extends RuntimeException {
  TunnelingException(Exception inner) {
    super(inner);
  }
}

Das Erzeugen und Werfen sollte man noch kapseln, damit jede Exception nur einmal eingewickelt wird (Listing 6b). Wenn man die Sichtbarkeit des Konstruktors der TunnelingException auf Package Visible reduziert, läuft die Erzeugung dieser Exception automatisch durch die ExceptionThrower-Klasse.

public class ExceptionThrower {
  public static void handle(Exception exc) {
    if(exc instanceof TunnelingException) {
      throw (TunnelingException) exc;
    }
    throw new TunnelingException(exc);
  }
}

Man kann die TunnelingException um zusätzliche Attribute erweitern, z. B. einen Übersetzungsschlüssel für die Anzeige im GUI oder Informationen über den Schweregrad der Exception (fatal oder nicht). Oder man kann einen fachlichen Enum hinzufügen, der eine Fallunterscheidung bei der Behandlung erleichtert.

Schwierigkeiten bekommt man aber immer dann, wenn eine Bibliothek Annahmen über Exceptions macht oder sie behandelt. So führen z. B. in EJBs Exceptions je nach Typ zu einem Transaktions-Rollback oder auch nicht (s. Kapitel 14 in [3]), sodass ein Einwickeln das Verhalten verändert.

Probleme gibt es aber auch schon, wenn eine Bibliothek ihrerseits Exceptions aus einem Callback fängt und „einpackt“.

Fazit

Welche Strategie sollte man also wählen? Das hängt von der Situation ab. Jeder der Ansätze hat seine Stärken und Schwächen – umso besser, wenn man sie alle im Hinterkopf behält und je nach Anwendungsfall einen auswählen kann.

Geschrieben von
Arno Haase
Arno Haase
Arno Haase ist freiberuflicher Softwareentwickler. Er programmiert Java aus Leidenschaft, arbeitet aber auch als Architekt, Coach und Berater. Seine Schwerpunkte sind modellgetriebene Softwareentwicklung, Persistenzlösungen mit oder ohne relationaler Datenbank und nebenläufige und verteilte Systeme. Arno spricht regelmäßig auf Konferenzen und ist Autor von Fachartikeln und Büchern. Er lebt mit seiner Frau und seinen drei Kindern in Braunschweig.
Kommentare

Hinterlasse einen Kommentar

1 Kommentar auf "Java-Trickkiste: Von Checked und Unchecked Exceptions"

avatar
400
  Subscribe  
Benachrichtige mich zu:
Skeptiker
Gast

Hm? Die IOException lässt sich doch auch in run() abfangen:

import java.io.File;
import java.io.IOException;

import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class temp {

final private static Logger LOGGER = LoggerFactory.getLogger(temp.class);

public void measure(final Runnable code) {
final long start = System.nanoTime();
code.run();
final long end = System.nanoTime();
System.out.println((end – start) + "ns");
}

@Test
public void test() {
measure(new Runnable() {
@Override
public void run() {
try {
new File(".").getCanonicalPath();
} catch (IOException ex) {
LOGGER.error("caught", ex);
}
}
});
}
}