try, catch und finally gehören zu den Dingen, die man früh lernt und danach selten hinterfragt. Der finally-Block gilt als der Ort für Aufräumarbeiten, und eine return-Anweisung beendet eine Methode und liefert dem Aufrufer einen Wert. Beides kennt man gut. Interessant wird es, wenn beides in derselben Methode zusammentrifft.
Das folgende Beispiel ist bewusst klein. Es hat keine Exceptions, keine Ressourcen und keine Schleifen, nur zwei return-Anweisungen.
Welcher Wert kommt beim Aufrufer an?
public class FinallyReturn {
static int berechne() {
try {
return 1;
} finally {
return 2;
}
}
public static void main(String[] args) {
System.out.println(berechne());
}
}
Antwortmöglichkeiten:
A. Es wird 1 ausgegeben.
B. Es wird 2 ausgegeben.
C. Der Code lässt sich nicht kompilieren.
D. Der Aufruf endet zur Laufzeit mit einer Exception.
Bevor du weiterliest: Tipp einmal kurz, welche Antwort du spontan wählen würdest. Danach schauen wir uns an, was Java Schritt für Schritt macht.
Step-by-Step zur Lösung
Wir beginnen beim sichtbaren Code. main ruft berechne() auf, und die Methode betritt den try-Block. Dort steht return 1;.
Eine return-Anweisung mit Ausdruck erledigt in Java zwei Dinge nacheinander. Zuerst wird der Ausdruck ausgewertet, hier also das Literal 1. Danach wird die Anweisung abrupt beendet, und zwar mit dem Grund „return mit dem Wert 1“. Dieser Grund beschreibt, warum der try-Block verlassen werden soll. Beim Aufrufer ist zu diesem Zeitpunkt noch nichts angekommen.
Jetzt kommt der finally-Block ins Spiel. Für try-finally gilt folgende Regel: Wird der try-Block abrupt beendet, zum Beispiel durch return oder throw, wird zuerst der finally-Block ausgeführt, bevor die gesamte try-Anweisung beendet wird. Das gilt auch dann, wenn der try-Block bereits ein return erreicht hat. (Sonderfälle wie ein System.exit(...) im try lassen wir hier außen vor, sie spielen für dieses Beispiel keine Rolle.)
Bis hierhin ist das vermutlich vertraut. Entscheidend ist, wie die try-Anweisung danach endet, und das hängt davon ab, wie der finally-Block selbst endet. Die Sprachregeln unterscheiden zwei Fälle:
Wird der finally-Block normal beendet, bleibt der ursprüngliche Grund bestehen. Die try-Anweisung wird dann aus genau dem Grund beendet, aus dem der try-Block verlassen wurde, bei uns also mit „return mit dem Wert 1“.
Wird der finally-Block dagegen selbst abrupt beendet, zum Beispiel durch ein eigenes return oder ein throw, wird die try-Anweisung aus diesem neuen Grund beendet. Der ursprüngliche Grund wird verworfen.
Schauen wir uns den finally-Block unseres Beispiels an:
} finally {
return 2;
}
Auch hier wird zuerst der Ausdruck ausgewertet (2), und danach endet der Block abrupt mit dem Grund „return mit dem Wert 2“. Er endet also nicht normal. Der vorgemerkte Grund aus dem try-Block, „return mit dem Wert 1“, trifft damit auf einen finally-Block, der den Kontrollfluss der Methode selbst beendet.
Damit lässt sich die ursprüngliche Frage jetzt beantworten.
2
Die richtige Antwort ist damit B. Das return 1 im try-Block wird zwar erreicht, und sein Ausdruck wird auch ausgewertet. Der vorgemerkte Rückgabewert wird aber verworfen, sobald der finally-Block mit return 2 abrupt endet. Der Aufrufer sieht die 1 zu keinem Zeitpunkt.
Dasselbe Prinzip bei Exceptions
Die Regel gilt nicht nur für return, sondern für jeden abrupten Abschluss des try-Blocks. Das zeigt sich besonders deutlich bei einer Exception:
static int ladeKonfiguration() {
try {
throw new IllegalStateException("Konfiguration fehlt");
} finally {
return -1;
}
}
public static void main(String[] args) {
System.out.println(ladeKonfiguration());
}
-1
Der try-Block endet hier abrupt mit einem throw. Danach wird der finally-Block ausgeführt, der seinerseits mit return -1 abrupt endet. Dieser neue Grund ersetzt den alten, und die IllegalStateException ist damit verschwunden. Es gibt keinen Stacktrace, keine Fehlermeldung und keinen Hinweis darauf, dass im try-Block etwas schiefgelaufen ist. Der Aufrufer bekommt einfach einen ganz normalen Rückgabewert. Das betrifft jede Art von Throwable, also auch Errors.
Warum ist das so tückisch?
Der Code ist syntaktisch völlig in Ordnung, und der Compiler akzeptiert ihn. Mit der Lint-Option -Xlint:finally kann javac zwar auf einen finally-Block hinweisen, der nicht normal abschließen kann, standardmäßig verlässt man sich darauf aber nicht.
Hinzu kommt, wie man Code liest. Beim Überfliegen nimmt man ein return im try-Block als Ende des Weges wahr, und der finally-Block gilt gedanklich als Aufräumcode, der am Ergebnis nichts mehr ändert. In echtem Code liegen try und finally oft etliche Zeilen auseinander, und ein return im finally fällt dann leicht durch.
Am unangenehmsten ist die Variante mit der Exception. Ein Fehler, der eigentlich nach außen gemeldet werden müsste, wird stillschweigend geschluckt. Weder im Log noch im Stacktrace deutet etwas darauf hin. Wer später nach der Ursache sucht, findet keine Spur, weil die Exception nie bei einem catch oder beim Aufrufer angekommen ist.
Ein finally-Block soll sicherstellen, dass aufgeräumt wird, und nicht entscheiden, was die Methode am Ende liefert oder ob ein Fehler überhaupt sichtbar wird. Ein return im finally verlagert diese Entscheidung an einen Ort, an dem man sie nicht erwartet.
Wie wäre die korrekte Variante?
Das return gehört in den try-Block oder hinter die gesamte Konstruktion, und der finally-Block erledigt nur das Aufräumen. Fehler behandelt man bewusst in einem catch oder lässt sie nach außen durchlaufen:
static int berechne() {
try {
return 1;
} finally {
aufraeumen();
}
}
Hier bleibt es beim Rückgabewert 1, weil der finally-Block normal endet. Soll ein Fehler abgefangen werden, geschieht das sichtbar:
static int ladeKonfiguration() {
try {
return leseWert();
} catch (IllegalStateException e) {
LOGGER.warn("Konfiguration konnte nicht gelesen werden", e);
return -1;
} finally {
aufraeumen();
}
}
Der Standardwert -1 ist dann eine bewusste Entscheidung im catch, und der Fehler landet im Log. Für Ressourcen wie Streams oder Reader ist try-with-resources ohnehin die bessere Wahl, weil das Schließen dort gar nicht mehr von Hand im finally stehen muss.
Merksatz:
Ein
returnimfinallyersetzt den Rückgabewert destry-Blocks und verdeckt sogar eine Exception. Deshalb sollte einfinally-Block immer normal enden.
Welche Antwort hattest du spontan gewählt? Und hast du ein return im finally schon einmal in echtem Code gesehen?
