Exception Handling und Optionals
Ziele
- Ich kenne die Schlüsselwörter
try
,catch
,finally
,throw
undthrows
. - Ich weiss, was “Unchecked” und “Checked” Exceptions sind.
- Ich kann auftretende Exceptions behandeln.
- Ich kann eigene Exceptions definieren und anwenden.
- Ich kenne Multicatch und Try-With-Resources und kann die beiden Konstrukte anwenden.
- Ich kenne die beiden Interfaces
AutoCloseable
undCloseable
. - Ich kenne eine Möglichkeit, um anzugeben, dass bestimmte Werte “nullable” bzw. nicht “nullable” sind.
- Ich verstehe, wie
Optional
meinen Code sicherer gegenübernull
-Werten macht.
Theorie / Einleitung
In jeder Applikation können erwartete oder unerwartete Fehler auftreten. In Java werden solche Fehler durch das Exception Handling abgefangen. Ziel des Exception Handlings ist es, durch gezieltes Behandeln von auftretenden Exceptions, Abstürze der Anwendung zu verhindern. Jeder Softwareentwickler sollte sich bewusst sein, dass unbehandelte Exceptions eine Anwendung jederzeit beenden können.
In Java unterscheidet man zwischen zwei Arten von Fehlern:
- Error: Dies sind nicht reparierbare Laufzeitfehler oder Hardware-Probleme, die zum Absturz des Programms führen.
- Exception: Dies sind Fehler oder unvorhergesehene Ereignisse, die während der Programmausführung auftreten und den normalen Ablauf stören.
Eine Java-Applikation sollte nicht versuchen, Errors abzufangen, da diese Fehler in der Regel aufgrund abnormaler Bedingungen (wie z. B. zu wenig Speicher) auftreten und unter normalen Umständen nicht behoben werden können. Exceptions hingegen sind unerwartete Fehler, auf die das Programm reagieren muss.
Innerhalb des Java Exception Handling unterscheiden wir zwei Arten von Exceptions:
- Unchecked Exceptions: Laufzeitfehler, die vom Compiler nicht erkannt werden.
- Checked Exceptions: Fehler, die vom Compiler zur Kompilierungszeit erkannt werden.
Unchecked Exceptions sind oft Fehler, welche bei der Implementation übersehen werden.
Der häufigste Laufzeitfehler ist die NullPointerException
. Diese kann erst zur Laufzeit auftreten,
da nur zur Laufzeit Objekte erzeugt werden und damit eine Referenz überhaupt null
sein kann.
Die einzige Möglichkeit, Laufzeitfehler abzuhandeln, ist “Safe Programming”. Das heisst, dass wir während der Implementation
Prüfungen und sog. “Guards” im Code einbauen (z.B. prüfen, ob eine Referenz nicht null
ist bevor wir darauf zugreifen) um sicherzustellen,
dass solche Situationen zur Laufzeit nicht auftreten.
Hier ein Beispiel mit einer NullPointerException
:
```java
public static void main(String[] args) {
Person person = null; // könnte stattdessen auch eine Methode sein, welche null zurückgibt
person.getName(); // hier wird eine NullPointerException geworfen, da person == null
}
```
Checked Exceptions müssen entweder am Ort des Auftretens abgefangen oder an den Aufrufer der Methode weitergegeben werden.
Dadurch wird die Verantwortung zur Behandlung der Exception an den Aufrufer weitergegeben.
Dazu ein kleines Beispiel:
|
|
Ohne die Implementierung der Methode findByPhoneNumber
zu kennen, muss ein Softwareentwickler das zurückgelieferte Objekt vom Typ Person
zunächst auf null
prüfen.
Schauen wir uns daher die Implementierung dieser Methode genauer an:
|
|
Wie (vielleicht) erwartet, liefert die Methode null
zurück, falls kein Eintrag mit der gesuchten Nummer gefunden wird.
Dies führt in der Main-Methode im obigen Code auf der letzten Zeile zu einer NullPointerException
, da die Referenz person
auf null
zeigt.
Ein einfaches if
-Statement kann hier Abhilfe schaffen:
|
|
Der Laufzeitfehler kann nun nicht mehr auftreten. Es stellt sich jedoch die Frage, ob diese Lösung zufriedenstellend ist.
Prinzipiell sollten wir zumindest informiert werden, wenn keine Person mit dieser Nummer gefunden wird.
Eine Möglichkeit besteht darin, ein else
-Statement hinzuzufügen:
|
|
Eine alternative Lösung könnte darin bestehen, das Null-Object-Pattern oder ein Optional
-Objekt zu verwenden, um ein gültiges Objekt anstelle von null
zurückzugeben.
try / catch / finally
Um eine Checked Exception zu behandeln, muss der Codeblock, der die Exception erzeugen könnte, innerhalb eines try
-Blocks stehen.
Der Exception-Typ, der abgefangen werden soll, wird in den zugehörigen catch
-Block geschrieben:
|
|
Ein try
-Statement kann beliebig viele catch
-Blöcke haben:
|
|
Bei mehreren catch
-Blöcken muss die spezifischste Exception stets zuerst stehen.
Je weiter unten der catch
-Block steht, desto allgemeiner ist die Exception, die abgefangen wird.
Der Grund dafür ist, dass alle Checked Exceptions von der Klasse Exception
abgeleitet sind.
Befindet sich eine allgemeinere Exception weiter oben, wird der catch
-Block der spezifischeren Exception weiter unten nicht mehr erreichbar sein.
|
|
Ein try
-Block (ob mit oder ohne catch
-Block) kann zusätzlich einen finally
-Block haben.
Der finally
-Block wird nach der Bearbeitung der Exception ausgeführt.
Falls keine Exception aufgetreten ist, wird der Code im finally
-Block direkt nach dem try
-Block ausgeführt. Der finally
-Block wird also auf jeden Fall ausgeführt. Er muss deshalb so geschrieben werden, das auf jeden Fall funktioniert, auch wenn der Try
-Block nicht komplett ausgeführt wird oder der catch
-Block nie ausgeführt wurde.
|
|
Wie oben erwähnt, kann der catch
-Block weggelassen werden:
|
|
Vorsicht bei return
-Anweisungen innerhalb von catch
- oder finally
-Blöcken: Da der finally
-Block immer zuletzt ausgeführt wird, ist ein return
-Statement in diesem Block massgeblich für die Funktionalität.
throw / throws
Eine Exception muss nicht immer dort behandelt werden, wo sie auftritt.
Falls die Behandlung in andere Klassen verlagert werden soll, kann mit dem Schlüsselwort throws
angegeben werden, dass die aufrufende Komponente die Exception abfangen und behandeln muss.
Dazu ein kurzes Beispiel:
|
|
|
|
|
|
In diesem Beispiel wird die Behandlung in die main
-Methode verlagert.
Exceptions können über beliebig viele Stufen weitergegeben werden.
Wenn jedoch die “oberste” Stufe (hier die main
-Methode) die Exception nicht behandelt, wird die Anwendung mit einer entsprechenden Fehlermeldung beendet, da die Exception unbehandelt bleibt.
Umwandlung Laufzeitfehler in Checked Exception
Mit der Lösung aus dem vorherigen Beispiel können wir noch nicht vollständig zufrieden sein.
Anstatt den Rückgabewert der Methode findByPhoneNumber
auf null
zu prüfen, wählen wir nun einen anderen Ansatz:
Wir erweitern die Anwendung so, dass die Methode keine null
-Werte mehr als Rückgabewert liefert.
Da der Compiler jedoch einen Rückgabewert erzwingt, bleibt uns nur die Möglichkeit, eine Exception zu werfen, wenn kein Ergebnis gefunden wird.
Zu diesem Zweck definieren wir zuerst eine entsprechende Exception:
|
|
Diese Exception wird nun an der entsprechenden Stelle im Code geworfen.
Die Methode wird zusätzlich mit dem Schlüsselwort throws
versehen:
|
|
Beim Aufruf der Methode sind wir nun gezwungen, die Exception zu behandeln:
|
|
Aus dem ursprünglichen Laufzeitfehler ist nun eine behandelte Exception geworden.
Diese Implementierung vermeidet, wo immer möglich, die Rückgabe von null
-Werten.
Multi-Catch
Seit Java 7 gibt es die Möglichkeit, mehrere Exceptions in einem sogenannten Multi-Catch zu behandeln. Schauen wir uns das folgende Beispiel an:
Ohne Multi-Catch
|
|
Mit Multi-Catch
|
|
Die beiden Exceptions werden hier in einem einzigen catch
-Block zusammengefasst.
Zu beachten ist, dass die Exceptions innerhalb eines Multi-Catch nicht in einer Vererbungsbeziehung zueinander stehen dürfen.
Das bedeutet, dass ihre Basistypen unterschiedlich sein müssen.
Try-With-Resources
Ebenfalls seit Java 7 gibt es die Möglichkeit für automatisches Ressourcen-Management.
Betrachten wir dazu zuerst ein Beispiel ohne automatisches Ressourcen-Management:
|
|
Der finally
-Block ist notwendig, um die verwendete Ressource des BufferedReaders
zu schliessen.
Da beim Schliessen eine IOException
auftreten kann, benötigen wir im finally
-Blocks
einen zusätzlichen try-catch
-Block.
Betrachten wir nun das gleiche Beispiel mit automatischem Ressourcen-Management:
|
|
Wie wir sehen, entfällt der finally
-Block zum Schliessen der Ressourcen vollständig.
Die Ressourcen FileReader
und BufferedReader
werden automatisch geschlossen.
Dies geschieht im Hintergrund über die Methode close
, die vom Interface AutoCloseable
bereitgestellt wird.
In einem try-with-resources
-Statement dürfen daher nur Objekte verwendet werden, die das genannte Interface implementieren.
Das Closeable
-Interface stellt dabei die Abwärtskompatibilität zu älteren Java-Versionen sicher, da es ebenfalls die close
-Methode definiert.
Grundsätzlich sollte das Closeable
-Interface für IO-Streams verwendet werden, da es mit IOException
arbeitet.
Das Schliessen der Ressourcen erfolgt immer in umgekehrter Reihenfolge.
In unserem Beispiel wird also zuerst der BufferedReader
geschlossen und danach der FileReader
.
Die Verkettung von Ressourcen innerhalb eines try-with-resources
-Statements sollte vermieden werden. Besser ist die getrennte Deklaration wie im obigen Beispiel.
Null-Safety
Ein häufiger Laufzeitfehler in Java ist die NullPointerException
. Diese Exception tritt auf, wenn
- eine Methode auf einem
null
-Objekt aufgerufen wird, - versucht wird, auf ein Feld (Variable) eines
null
-Objekts zuzugreifen.
Oft wird schlicht übersehen, dass eine bestimmte Variable null
sein könnte:
|
|
Im obigen Beispiel führt der Versuch, die Methode length()
auf einem null-Objekt aufzurufen, zur NullPointerException
.
Hier werden zwei typische Ursachen für das Auftreten einer NullPointerException
deutlich:
- Einer Variable (hier
parameter
) wirdnull
zugewiesen/übergeben, was in manchen Fällen unerwartet ist. - Es wird vergessen zu prüfen, dass eine Variable den Wert
null
haben könnte.
Diese beiden Fälle können in Java auf verschiedene Arten abgefangen werden.
null
durch Check abfangen
Die offensichtlichste Möglichkeit, NullPointerExceptions
zu vermeiden, ist die Verwendung von null
-Checks.
Im folgenden Beispiel verhindern wir null
, indem wir die Variable zu Beginn der Methode prüfen und eine Exception
werfen, falls die Variable null
ist:
|
|
In diesem Beispiel ist sichergestellt, dass der Wert null
für das Argument parameter
nicht erlaubt ist.
Ein Nachteil dieser Lösung ist, dass Entwickler von aussen nicht direkt erkennen können, dass n
ull-Werte unzulässig sind.
Diesen Fall könnte man stattdessen besser mit einer @NotNull
-Annotation abdecken, wie später beschrieben.
Manchmal jedoch sollen null
-Werte zulässig sein. In solchen Fällen verwenden wir Bedingungen, um den richtigen Code auszuführen:
|
|
Um hier null
-Sicherheit zu garantieren, wurden einige zusätzliche Zeilen eingefügt. In solchen Fällen kann auch der Ternary-Operator nützlich sein:
|
|
Der Ternary-Ausdruck ist hierbei der folgende:
|
|
Dieser Ausdruck gibt parameter.length()
zurück, wenn parameter != null
ist. Ansonsten gibt er den String "ist nicht definiert bzw. 0."
zurück.
Ganz allgemein ist der Ternary-Ausdruck wie folgt aufgebaut:
|
|
Annotationen wie @NotNull
und @Nullable
Sicherlich ist dir schon einmal die Angabe @Nullable
bei einem Argument von einer Methode aus einer externen Library aufgefallen.
Solche Annotationen teilen mit,
- dass bei einer Variable erwartet wird, dass sie unter Umständen auch den Wert
null
haben kann (@Nullable
) - bzw. dass eine Variable nicht den Wert
null
aufweisen darf (@NotNull
bzw.@NonNull
).
In den folgenden Beispielen verwenden wir die Bibliothek org.jetbrains.annotations
.
Es gibt jedoch auch andere Bibliotheken mit ähnlichen Annotationen.
Da die Verwendung von Dependencies hier noch nicht behandelt wurde (Maven-Teil), bleibt dies ein theoretischer Hinweis.
Hier ein Beispiel, wie Annotationen zu mehr null
-Sicherheit führen können:
|
|
In diesem Beispiel wird
- die Annotation
@Nullable
verwendet, um mitzuteilen, dass bei der VariablefullName
der Wertnull
möglich ist. In IntelliJ Idea (von Jetbrains) wird dadurch die Methodelength()
gelb unterstrichen, weil für die VariablefullName
dernull
-Check fehlt. - die Annotation
@NotNull
verwendet, um mitzuteilen, dass die Variablenames
nicht den Wertnull
haben darf. Leider fügt diese Möglichkeit kein Warning beim Aufruf vonmethod(..., null)
hinzu. Dafür aber wird eineIllegalArgumentException
zur Laufzeit geworfen, falls ihrnull
beim Methodenaufruf zugewiesen wird.
Optionals
In Java gibt es auch ohne externe Bibliothek eine Möglichkeit anzugeben, dass eine Variable den Wert null
„repräsentieren“ kann.
Hierfür wurde die generische Klasse Optional<T>
eingeführt.
Die Idee dabei ist, dass Variablen, die den Wert null
haben könnten, den Typ Optional<...>
erhalten. Ein nullable
String hätte zum Beispiel den Typ Optional<String>
:
|
|
Der Vorteil von Optional
s ist, dass man als Entwickler:in gezwungen wird, einen null
-Check zu machen:
|
|
Denn
- wenn kein
null
-Check vor dem Aufrufen von.get()
(was den eigentlichen Wert zurückgibt) gemacht wird , dann reklamiert deine Entwicklungsumgebung (IntelliJ/VS Code) automatisch mit einer Warnung. - wenn
.get()
aufgerufen wird, und der Wertnull
repräsentiert, dann wird bereits an dieser Stelle eineNullPointerException
geworfen.
Optionals sind daher eine gängige Möglichkeit, Entwickler zu einer Prüfung auf null
zu verpflichten.
Diese Technik wird z.B. bei Streams häufig eingesetzt:
|
|
Zusammenfassung zu Null-Safety
Die NullPointerException
ist eine der häufigsten Exceptions in Java-Programmen. Deswegen lohnt es sich, besser mit null
-Werten umzugehen bzw. besser sichtbar zu machen, dass Werte null
sein können.
Drei der häufigsten Möglichkeiten, um mehr Null-Sicherheit in deinen Code zu bringen, sind:
null
-Checks- Annotationen wie
@NotNull
und@Nullable
- und
Optional<...>
-Typen zu verwenden.
Exceptions testen
Wie regulären Java Code kann man natürlich auch Exceptions mit JUnit testen. Eine ausführliche Erklärung dazu ist hier zu finden.
Das folgende Beispiel testet anhand assertThrows()
dass die Methode testCheckAge()
mit einem Alter unter 18 eine EntryForbiddenException
wirft.
|
|
Exceptions: Keine Kontrollstrukturen, sondern Fehlerbehandlung
Nach den Prinzipien von Clean Code sollten Ausnahmen (Exceptions) nicht als normaler Programmfluss eingesetzt werden. Exceptions dienen dazu, Ausnahmesituationen zu behandeln, die unerwartet auftreten und oft nicht durch reguläre Überprüfungen abgefangen werden können, wie z. B. Netzwerk- oder Datenbankfehler. Wenn Exceptions jedoch als Ersatz für reguläre Kontrollstrukturen (wie if-Abfragen) verwendet werden, wird der Code schwerer lesbar, schlechter wartbar und häufig ineffizienter.
Warum sollten Exceptions nicht für den Programmfluss genutzt werden?
- Lesbarkeit: Die Verwendung von Exceptions für den Programmfluss macht den Code schwer verständlich, da andere Entwickler erwarten, dass Ausnahmen nur in Fehlerfällen auftreten.
- Performance: Exceptions sind in der Regel ressourcenintensiver, da das Erstellen und Verarbeiten von Exception-Objekten zusätzliche Leistung kostet. Das ist besonders problematisch, wenn Exceptions in Schleifen verwendet werden.
- Debugging: Der Missbrauch von Exceptions erschwert das Debugging, weil oft unklar ist, ob eine Ausnahme durch einen Fehler oder absichtlich durch eine Programmlogik ausgelöst wurde.
- Log-Analyse: Wenn Exceptions für den Programmfluss verwendet werden, kann die Log-Analyse erschwert werden, da die Logs mit unnötigen Ausnahme-Einträgen überflutet werden. Dies macht es schwierig, echte Fehler zu identifizieren, da sich Logs mit Informationen über erwartete oder absichtlich ausgelöste Ausnahmen füllen.
Beispiel für falsche Verwendung:
In diesem Beispiel wird eine Exception missbräuchlich zur Kontrolle des Programmflusses verwendet:
|
|
Richtige Verwendung: Besser ist es, die Eingabe vor der Verarbeitung zu validieren und Exceptions nur für unerwartete Fehler zu nutzen:
|
|
Fazit: Durch die Verwendung von Kontrollstrukturen anstelle von Exceptions für den Programmfluss wird der Code nicht nur sauberer und verständlicher, sondern auch effizienter und robuster.
Jetzt bist du dran. Löse bitte die Aufgaben zu Exception Handling in den Labs.