Exceptions

1. Grundlagen

1.1. Programm-Ausführung, Stack, Heap

Ein Java-Programm startet die Ausführung (leicht vereinfacht) mit der Ausführung der Methode
public static void main(String[] args) der beim Start anzugebenden Klasse (auf der Kommandozeile z.B. mit
java my.cool.pkg.MyMainClass oder in der Eclipse-Run-Configuration).

Davor werden noch statische Variable initialisiert (z.B. private static LocalTime currTime = LocalTime.now();) und statische Initializer ausgeführt (z.B. static { System.out.println("Static Initializer running …​");}).

In der Main-Methode werden optional weitere statische Methoden aufgerufen, Objekte erzeugt und daran Instanz-Methoden aufgerufen. Dieser Prozess setzt sich fort und führt zu einer mitunter sehr tiefen Verschachtelung gerade laufender Methoden.

Die Aufrufparameter, lokale Variable (primitive und Referenzen auf Objekte) und weitere Informationen dieser gerade aktiven Methoden werden auf dem sogenannten Stack abgelegt. Im Gegensatz dazu liegen alle erzeugten Objekte auf dem sogenannten Heap (Details hierzu z.B. unter Stack-Speicher und Heap-Speicher in Java).

Bei Abschluss einer Methode wird diese vom Stack entfernt und die Ausführung läuft bei ihrer aufrufenden Methode weiter (der Rückgabewert ist dann für diese verfügbar). Nun könnte eine weitere Methode aufgerufen werden, die wiederum auf dem Stack landet.

So wächst und schwindet die Höhe des Stacks dynamisch bis zum Programmende, wo die Ausführung wieder in der Main-Methode landet, sofern nicht davor ein System.exit(0) (oder eine andere Zahl, die eine grobe, vom Entwickler festzulegende Problemkategorisierung darstellen kann) aufgerufen worden ist.

1.2. Ergänzung mit Exception-Konzept

Da Java (wie die meisten Programmiersprachen) nur einen einzigen Rückgabewert erlaubt (der allerdings eine Referenz auf ein beliebig komplexes Objekt sein kann), ist auf dieser Basis eine angemessene Reaktionsmöglichkeit auf Fehler sehr beschränkt – oder zumindest nur mit großem Aufwand erreichbar.

Um das Auftreten von Fehlern sinnvoll handhaben zu können, gibt es daher ein eigenes, sehr elegantes Konzept – einen standardisierten, mächtigen "Fluchtweg" aus einer Fehlersituation – die Exceptions.

2. Praktische Umsetzung

2.1. Auslösen (Werfen) von Exceptions

Diese Exceptions werden quasi als mächtiger Ersatz für Fehlermeldungen im Code "geworfen". Dabei wird ein Exception-Objekt erzeugt und dieses erhält als Konstruktor-Parameter üblicherweise auch (neben optional weiteren Infos) einen Fehlertext (Message).

public class ExceptionUsageDemo {
    public int calcSomething (int value) {
        if (value == 1) {
            throw new IllegalArgumentException("unzulässiger Wert 1 übergeben");
        }
        return 5/(value - 1);  // würde bei value=1 unendlich
    }
}

Eine Exception beendet die prozedurale Abfolge der Statements und verlässt die betroffene Methode (nach der Möglichkeit zu Aufräumarbeiten) unmittelbar und gibt ein neu erzeugtes Exception-Objekt mit Fehler-Infos an das aufrufende Statement (die Problemstelle in der aufrufenden Methode) zurück.

2.2. Checked und Unchecked Exceptions

Es gibt im wesentlichen 2 praxisrelevante Exception-Basisklassen: eine hat den Namen Exception (genannt "Checked Exception"), die andere ist eine Subklasse der anderen mit dem Namen RuntimeException (genannt "Unchecked Exception").

Die Klassenhierarchie der Exceptions ist: Throwable > Exception > RuntimeException

Eigene Exceptions sind wie oben erwähnt entweder von Exception (checked) oder RuntimeException (unchecked) abzuleiten.

Diese beiden unterscheiden sich – wie schon aus den "Spitznamen" erahnbar – in einer wichtigen Hinsicht fundamental voneinander: Sobald in einer Methode eine Checked Exception (Exception oder Subklassen davon – außer der RuntimeException-Subklasse!) geworfen werden kann, MUSS dies in der Methodensignatur vermerkt sein. Der Compiler erzwingt dann eine sinnvolle Weiterbehandlungsmöglichkeit im aufrufenden Code (alternativ ein Try-Catch der eigenen Exceptions ist nur in seltenen Ausnahmefällen sinnvoll).

import java.io.File;
import java.io.FileReader;
import java.io.FileNotFoundException;

public class UsageCheckedExceptionDemo {
    public void readFile() throws FileNotFoundException {   // siehe 'throws ...'
        File file = new File("NichtExistenteDatei.txt");
        FileReader fr = new FileReader(file);
    }
}

Unter der Annahme, dass die Datei NichtExistenteDatei.txt tatsächlich nicht gefunden wird, wird die angekündigte FileNotFoundException ausgelöst und ohne weitere Maßnahmen wird das Programm beendet und folgender Stacktrace auf STDERR ausgegeben:

java.io.FileNotFoundException: NichtExistenteDatei.txt (Datei oder Verzeichnis nicht gefunden)
	at java.base/java.io.FileInputStream.open0(Native Method)
	at java.base/java.io.FileInputStream.open(FileInputStream.java:212)
	at java.base/java.io.FileInputStream.<init>(FileInputStream.java:154)
	at java.base/java.io.FileReader.<init>(FileReader.java:75)
	at UsageCheckedExceptionDemo.readFile(UsageCheckedExceptionDemo.java:8)

Falls der Programmierer eine Unchecked Exception (RuntimeException oder Subklassen davon) verwendet, mus diese NICHT in der Signatur vermerkt werden. Damit kann eine solche Exception zur Laufzeit ohne sinnvolle Behandlung auftreten, falls sie nicht dennoch explizit behandelt wird.

Werfen einer IllegalArgumentException (im SDK bereitgestellt, unchecked)
public class UncheckedExceptionUsageDemo {
    public int calcSomething (int value) {
        if (value == 1) {
            throw new IllegalArgumentException("unzulässiger Wert 1 übergeben");
        }
        return 5/(value - 1);  // würde bei value=1 unendlich
    }
}

Es wird mit new IllegalArgumentException(…​) ein Exception-Objekt erzeugt und dann mit throw das "Werfen" ausgelöst.

Hier wird bei Aufruf der Methode mit calcSomething(1); wie oben ohne weitere Maßnahmen das Programm beendet und folgender Stacktrace auf STDERR ausgegeben:

java.lang.IllegalArgumentException: unzulässiger Wert 1 übergeben
	at UncheckedExceptionUsageDemo.calcSomething(UncheckedExceptionUsageDemo.java:4)

2.3. Umgang mit auftretenden Exceptions

Das aufrufende Code-Abschnitt hat nun 2 Möglichkeiten, mit diesem Ereignis und dem damit verbundenen Exception-Objekt umzugehen:

  • es fühlt sich befähigt, die Fehlerursache sinnvoll zu behandeln – dann wird die Exception "gefangen" und im Falle des Auftretens entsprechender Code ausgeführt

  • es hat nicht ausreichend geeigneten Kontext um sinnvoll zu reagieren – dann wird die Exception an die in der Aufruf-Hierarchie nächsthöhere Methode weitergereicht (hat die gleiche Wirkung wie eine selbst geworfene Exception).

Beispielsweise kann eine fehlerhafte Benutzer-Eingabe nur an einer Stelle behandelt werden, wo die Möglichkeit zur Fehlermeldung und Anbieten einer Eingabekorrektur durch den Benutzer gegeben ist.

Als "Faustformel" kann dienen, dass der Code, in dem der den Fehler seine Ursache hat (falsche Eingabe, etc.), auch der praktikabelste Platz zur Fehlerbehandlung ist.

Beispiel:

import java.io.File;
import java.io.FileReader;
import java.io.FileNotFoundException;

public class ExceptionHandlingDemo {
    public void readFile() {  // nun kein 'throws ...', da Exception selbst behandelt
        File file = new File("MichGibtsNicht.txt");
        //File file = new File("ExceptionHandlingDemo.class");  // die gibt es bei Ausführen in BlueJ
        try {
            FileReader fr = new FileReader(file);
            System.out.println("Datei '" + file.getName() + "' gelesen");
        } catch (FileNotFoundException ex) {
            System.out.println("Uuups - " + ex.getMessage());
        } finally {   // optional - wird in JEDEM Fall anschließend (zum "Aufräumen" etc.) ausgeführt
            System.out.println("alles unter Kontrolle");
        }
    }
}

Wenn Datei MichGibtsNicht.txt verwendet wird, entsteht folgende Ausgabe komplett in SDTOUT:

Uuups - MichGibtsNicht.txt (Datei oder Verzeichnis nicht gefunden)
alles unter Kontrolle

Wenn Datei ExceptionHandlingDemo.class aktiviert wird (bei Ausführung z.B. in BlueJ existiert diese im aktuellen Verzeichnis), entsteht folgende Ausgabe komplett in STDOUT.

Datei 'ExceptionHandlingDemo.class' gelesen
alles unter Kontrolle

Offensichtlich wird alles unter Kontrolle in beiden Fällen ausgegeben. Der finally-Block kann entfallen, wenn nicht benötigt (wird meistens weggelassen):

2.4. Erzeugen eigener Exceptions

2.4.1. Wozu eigene Exceptions

Der Sinn besteht darin, dass man damit gezielt nur auf diejenigen Exceptions reagieren kann, für die man "zuständig" ist.

2.4.2. Praktische Durchführung

Erfolgt einfach durch Erben: …​ extends Exception. Alle besseren IDEs bieten die Möglichkeit, Konstruktoren von der Superklasse "abzuleiten", d.h. eigene mit selben Parameterlisten zu erzeugen. So ist mit wenigen Klicks eine gültige und meist ausreichende eigene Exception zu erstellen. Sehr oft löscht man den Großteil der automatisch erstellten Konstruktoren und behält nur die nachstehend gezeigten.

package my.demo.exceptions;
public class MyBadParamException extends Exception {

    public MyBadParamException(String message) {  // meist nur diese 2 Konstruktoren nötig
        super(message);
        // Enthält zwingend den Aufruf des passenden Superklassen-Konstruktors, sonst nichts
    }
    public MyBadParamException(String message, Throwable cause) {
        super(message, cause);  // 'cause': Exc-Obj. d. "tieferen Ursache"
    }
}

2.4.3. Komplexere eigene Exceptions

In manchen Situationen ist es sinnvoll, mehr Kontext-Information mit dem Exception-Objekt "mitzuschicken", sodass der Reagierende alle nötigen Fakten für eine sinnvolle Reaktion verfügbar hat.

Man kann einfach beliebige Instanzvariablen und auch Methoden (v.a. Getter) definieren und die Konstruktoren erweitern, sodass die Werte bei der Erzeugung mitgegeben werden.

Auch Setter und weitere Methoden sind denkbar, um erst das Exception-Objekt mit
MySmartException ex = new MySmartException(…​);
zu erzeugen (dabei passiert noch nichts!), dann alle gewünschten Methoden auszuführen und es erst danach dem
throw ex; die Exception auszulösen.

2.5. Stacktrace — Bedeutung und Nutzung

Bei unbehandelten Exceptions wird vom Laufzeitsystem vor der Beendigung des Programms eine Behandlung durchgeführt, und zwar in Form eines Stacktrace. Dieser gibt neben der Exception Message wichtige Einsichten in die Entstehung.

Wir sehen uns nun nochmals einen oben erwähnten Stacktrace (mit Package my.demo für die Klasse) an:

java.io.FileNotFoundException: NichtExistenteDatei.txt (Datei oder Verzeichnis nicht gefunden)  (1)
    at java.base/java.io.FileInputStream.open0(Native Method)                                   (2)
    at java.base/java.io.FileInputStream.open(FileInputStream.java:212)                         (3)
    at java.base/java.io.FileInputStream.<init>(FileInputStream.java:154)                       (4)
    at java.base/java.io.FileReader.<init>(FileReader.java:75)                                  (5)
    at my.demo.UsageCheckedExceptionDemo.readFile(UsageCheckedExceptionDemo.java:8)             (6)
1 Angabe, welche Exception, dazu die Message (hier Dateiname + Problemart)
2 Aufruf der Betriebssystemfunktion zum Datei-Öffnen (Native Method, mit der C-Funktionen des Betriebssystems angesprochen werden können)
3 die "tiefste" echte Java-Methode - hier sieht man den Namen der Source-Datei und sogar die Zeilennummer des JDK-Sourcecode. In Eclipse sind die Dateien als Link bereitgestellt und die JDK-Source-Datei kann bei korrekter Eclipse-Konfiguration direkt an der richtigen Stelle geöffnet werden!
4 mit .<init> ist jeweils ein Konstruktor gemeint (FileInputStream ist die Basis)
5 FileReader bietet elegantere Möglichkeiten zum text-orientierten Dateizugriff
6 hier die erste (und hier auch finale) "eigene" Klasse und Methode, wo in Eclipse der Link unbedingt angeklickt werden sollte, um direkt zur selbstverursachten Problemstelle zu gelangen.
Bei vernünftiger Package-Verwendung findet man die eigenen Klassen sehr rasch, da immer der voll qualifizierte Klassenname angezeigt wird (hier my.demo.UsageCheckedExceptionDemo, dahinter die Methode '.readFile', dahinter in Klammer die Source-Datei samt Zeilennummer)!

Als Faustformel ergibt sich also, im Stacktrace die erste eigene Klasse (von oben nach unten) zu suchen. Dort ist das Problem fast immer lokalisiert.

2.6. Beispiel — Textmenü mit Fehlerbehandlung

Um die Idee der Zuständigkeit für das Exception-Handling besser verständlich zu machen folgt hier ein Beispiel mit auf java.util.Scanner basierendem Kommandozeilen-Menü:

folgt

https://www.baeldung.com/java-get-current-stack-trace [Get the Current Stack Trace in Java | Baeldung] …​ 2023-03-05

Don’t Use Checked Exceptions …​ 2024-02-22