Try-Catch

Beispiel: Ausnahme

public class ExceptionExample {

    public static void main(String[] args) {

        String[] namen = { "Franz", "Hans", "Alfons" };

        for (int i = 0; i < 4; i++) {
            System.out.print(namen[i] + ", ");
        }
    }
}
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 3
    at pr2.ExceptionExample.main(ExceptionExample.java:10)
Franz, Hans, Alfons,

Das Beispiel zeigt, was passiert, wenn in Java eine Ausnahme auftritt. Das Programm versucht hinter das letzte Element des Arrays zu greifen, was von der Laufzeit erkannt wird und zum Auslösen einer ArrayIndexOutOfBoundsException führt.

Man sieht an dem Beispiel schon sehr schön die Vorteile der Fehlerbehandlung in Java: Der Typ der Ausnahme weist direkt durch seinen Namen darauf hin, welchen Fehler man gemacht hat. Zusätzlich enthält die Ausnahme die Information, welchen ungültigen Array-Index man verwendet hat (3). Der Call-Stack zeigt die Datei und die Zeilennummer an, in der der Fehler aufgetreten ist.

Fehlerbehandlung in Java

  • Fehler werden durch eine Ausnahmen (exceptions) signalisiert
    • Exceptions sind Java Objekte, die im Fehlerfall erzeugt werden
    • Alle Exceptions sind (indirekt) von Throwable abgeleitet
  • try-Block enthält Code, der mit Fehlerbehandlungsroutine(n) versehen werden soll
    • Tritt ein Fehler auf, wird die Fehlerbehandlungsroutine angesprungen (try-Block wird abrupt beendet)
    • Tritt kein Fehler auf, läuft das Programm nach dem try/catch weiter (try-Block wird normal beendet)
  • catch-Blöcke enthalten die Fehlerbehandlungsroutine (exception handler) zum try-Block
    • Fehler wird als Objekt an catch übergeben

Das Exception-Konzept von Java ist stark an die C++-Exceptions angelehnt.

Jeder Fehler (Exception) wird durch ein Objekt repräsentiert, dessen Klasse von Throwable abgeleitet ist. Der Typ des Objektes liefert eine genaue Information, welche Art von Fehler aufgetreten ist.

Die Fehlerbehandlung wird von der normalen Programmlogik dadurch getrennt, dass man zwei unterschiedliche Blöcke verwendet. Der eine Block wird mit try eingeleitet und enthält die Anweisungen, die möglicherweise zu einem Fehler führen könnten, z. B. Zugriff auf eine Datei. Der andere Block wird mit catch gekennzeichnet und enthält die Fehlerbehandlung. Er wird nur dann angesprungen, wenn im try-Block ein Fehler aufgetreten ist. Wenn ein solcher Fehler auftritt, wird der try-Block abrupt beendet, d. h. er wird nicht weiter ausgeführt, sondern die Programmausführung springt zu dem catch-Block. Man sagt auch, dass der catch-Block die Ausnahme fängt (Fangen einer Ausnahme). Wenn kein Fehler auftritt, läuft der try-Block ganz normal zu Ende und der catch-Block wird ignoriert. D. h. die Programmausführung geht nach dem catch-Block weiter.

Über einen Parameter wird dem catch-Block das Ausnahmeobjekt übergeben, auf das er dann über eine lokale Referenzvariable zugreifen kann. Nachdem der catch-Block am Ende angekommen ist, wird die Programmausführung nach dem catch-Block fortgesetzt.

Beispiel: try und catch

Connection connection = getConnection();

try {
    ResultSet rs = readData(connection);

    while (rs.next()) {
        // Daten bearbeiten
    }
} catch (SQLException ex) {
    // Datenbank Problem behandeln
    String sqlStatus = ex.getSQLState();
    ex.printStackTrace();
    ...
}

...

Dieses Beispiel zeigt, wie eine potenziell auftretende SQLException behandelt werden kann – hierbei handelt es sich um einen Fehler, der bei der Kommunikation mit einer Datenbank auftreten kann. Im try-Block befindet sich der Datenbankzugriff, der sich hinter der Methode readData() und den Zugriffen auf das ResultSet mit rs.next() verbirgt. Bei jedem Datenbankzugriff kann potenziell ein Problem entstehen, z. B. ist die Datenbank nicht verfügbar, das dann durch eine SQLException signalisiert wird.

Die Behandlung des Datenbankfehlers erfolgt in diesem Beispiel im entsprechenden catch-Block, der die SQLException fängt. Obwohl die eigentliche Fehlerbehandlung hier weggelassen wurde, kann man eine weitere wichtige Eigenschaft von Exceptions erkennen: Dadurch, dass sie echte Java-Objekte sind, kann man über die Ausnahme zusätzliche Daten transportieren. Hier den Fehler-Code der Datenbank, der mit getSQLState() ausgelesen wird und den Stack-Trace, der mit printStackTrace() ausgedruckt wird.

Die hier dargestellte Fehlerbehandlung ist nur als Beispiel zu verstehen und natürlich nicht realistisch. Vor allem das Ausdrucken des Stack-Traces ergibt in echten Systemen nur wenig Sinn.

Mehrere catch-Blöcke

  • Man kann beliebig viele catch-Blöcke angeben
  • Die Laufzeit wählt den Block aus, bei dem die angegebene Ausnahme am besten zur tatsächlich geworfenen passt
  • Die Auswahl geschieht anhand der Vererbungshierarchie der Ausnahmen
  • Die Blöcke müssen in der Reihenfolge vom Spezifischen zum Allgemeinen angegeben werden

In einem try-Block können durchaus verschiedene Ausnahmen auftreten, die man unterschiedlich behandeln möchte. Daher ist es möglich, einem try- mehrere catch-Blöcke zuzuordnen.

Die Auswahl des catch-Blockes erfolgt dann zur Laufzeit anhand des Typs der Ausnahme, die im try-Block geworfen wurde. Ähnlich zu überladenen Methoden wird also der am besten passenden Block ausgewählt. (Anders als bei überladenen Methoden aber erst zur Laufzeit und nicht bereits zur Compilezeit).

Da die Laufzeit die catch-Blöcke bei der Suche nach dem richtigen Block strikt von oben nach unten durchgeht, ist es wichtig die Blöcke vom Spezifischen zum Allgemeinen hin anzuordnen. D. h. man muss zuerst den catch-Block mit der Ausnahme schreiben, die in der Vererbungshierarchie am weitesten unten steht.

Beispiel: Mehrere catch-Blöcke

Connection connection = getConnection();

try {
    ResultSet rs = readData(connection);

    while (rs.next()) {
        storeToFile(rs.getString(0), rs);
    }
} catch (SQLException ex) {
    // Datenbank-Problem
    ex.printStackTrace();
} catch (FileNotFoundException ex) {
    // Datei nicht gefunden
    System.out.println(ex.getMessage());
} catch (IOException ex) {
    // Allgemeiner IO-Fehler
    System.out.println(ex.getMessage());
}

Man erkennt im Beispiel deutlich die Regel, dass die catch-Blöcke vom Speziellen zum Allgemeinen hin angeordnet werden müssen. SQLException und IOException stehen in keiner Beziehung zueinander, sodass hier die Anordnung egal ist. Da FileNotFoundException spezifischer als IOException ist (siehe Abbildung), muss ihr catch-Block jedoch zuerst aufgeführt werden.

Multi-Catch

  • Für einen catch-Block können beliebig viele Ausnahmen (durch | getrennt) angegeben werden
  • Die Ausnahmen dürfen nicht Subklassen voneinander sein
  • Jeder Ausnahme-Typ darf nur einmal genannt werden
try {
    openFile();
    writeToDatabase();
}
catch (FileNotFoundException | SQLException e) {
    System.out.println("Ohje, alles kaputt...:" + e);
}

Es kommt häufig vor, dass eine Methode viele verschiedene Ausnahmen werfen kann. Dies führt beim Verwender zu einer ganzen Reihe von catch-Blöcken, die oft alle dasselbe tun. Eine Möglichkeit wäre natürlich, anstelle vieler catch-Blöcke nur einen catch-Block der Superklasse Exception zu verwenden. Dies gilt aber als sehr schlechter Stil (siehe oben) und ist daher keine befriedigende Lösung.

Seit Java 7 besteht die Möglichkeit, mehrere Ausnahmen in einem einzigen catch-Block zu fangen. Die Ausnahmen werden durch | getrennt und dürfen in keiner Vererbungsbeziehung zueinander stehen.

Der statische Typ der Referenz auf die Ausnahme (e im Beispiel) in einem multi-catch ist dann allerdings Exception. Will man auf spezielle Attribute der Ausnahmen zugreifen, muss man einen Cast auf ihren dynamischen Typ durchführen.

So nicht!

public static void printArray(int[] array) {

    try {
        for (int i = 0;;i++) {
            System.out.println(array[i]);
        }
    }
    catch (ArrayIndexOutOfBoundsException ex) {
    }
}

Der/die Programmierer:in dieses Beispiels hat versucht, die Prüfung auf die Grenzen des Arrays zu sparen und lieber die ArrayIndexOutOfBoundsException abzufangen, da er/sie glaubt, dass das einmalige Fangen einer Ausnahme schneller geht als eine Prüfung bei jedem Schleifendurchlauf. Obwohl dieses Programm semantisch korrekt funktioniert, handelt es sich um einen Missbrauch von Ausnahmen, der zur Folge hat, dass das Programm viel langsamer läuft. Der Grund liegt darin, dass die Java-VM normalerweise die Schleifengrenzen nur einmal überprüft und dann sehr effizienten Maschinencode generieren kann. Dies ist hier nicht möglich, da es keine Grenzen im Programm gibt. Deshalb muss sie bei jedem Durchlauf erneut auf die Grenzen testen und entsprechend eine Ausnahme generieren, wenn sie überschritten wurden.

Vergleich zu C

if ((servSock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < O) {
     DieWithError("socket () failed") ;
}

if (bind(servSock, (struct sockaddr *)&echoServAddr,
        sizeof(echoServAddr)) < O) {
    DieWithError ("bind () failed");
}

if (listen(servSock, MAXPENDING) < O) {
    DieWithError("listen() failed") ;
}
for (;;) {
    if ((clntSock = accept(servSock, (struct sockaddr *) &echoClntAddr,
            &clntLen)) < O) {
        DieWithError("accept() failed");
    }
}

Hier ist noch einmal ein Beispiel der Fehlerbehandlung in der Programmiersprache C dargestellt, das im ersten Kapitel bereits gezeigt wurde.

try {
    ServerSocket servSocket = new ServerSocket();
    servSocket.bind(new InetSocketAddress(8080));

    for (;;) {
        Socket clntSocket = servSocket.accept();
        // ...
    }
}
catch (SocketTimeoutException e) {
    // Fehlerbehandlung
}
catch (IOException e) {
    // Fehlerbehandlung
}

Bei der Java-Variante des Programms kann man sehr gut die Unterschiede erkennen. Hier vermischt sich die Fehlerbehandlung nicht mehr mit dem normalen Programmablauf. Man kann deutlich sehen, wie man einen Netzwerkserver programmiert, ohne von der Fehlerbehandlung verwirrt zu werden.


Copyright © 2025 Thomas Smits