
Teilen Sie:
Sina ist Java Developer Advocate bei Vonage. Er hat einen akademischen Hintergrund und ist generell neugierig auf alles, was mit Autos, Computern, Programmierung, Technologie und der menschlichen Natur zu tun hat. In seiner Freizeit geht er gerne spazieren oder spielt Videospiele.
Warum Sie die Verwendung von geprüften Ausnahmen in Java vermeiden sollten
Lesedauer: 10 Minuten
Ich habe auf der Devoxx UK 2023 einen kurzen Vortrag zu diesem Thema gehalten. Sehen Sie es sich auf YouTube.
Einführung
Die meisten gängigen Programmiersprachen haben spezielle Konstrukte für den Umgang mit Ausnahmen. Was sind Ausnahmen? Es handelt sich um abnormales Verhalten, das auf eine ungewöhnliche Eingabe oder einen ungewöhnlichen Systemzustand zurückzuführen ist. Mechanismen zur Behandlung von Ausnahmen unterbrechen den typischen Programmablauf, wie auf Wikipedia. Sie sind wahrscheinlich vertraut mit den try-catch-finally Konstrukt vertraut, wenn Sie eine objektorientierte Programmiersprache wie C# oder Java verwenden. Allerdings sind Sie vielleicht nicht mit dem Begriff der geprüften Ausnahmen. Vor kurzem habe ich gelernt, dass dieses "Feature" im Grunde nur in Java vorkommt - zumindest wenn es um Mainstream-Programmiersprachen geht. In diesem Artikel möchte ich Sie davon überzeugen, warum geprüfte Ausnahmen schlecht sind und warum Sie, wenn Sie kein Java-Entwickler sind, dankbar sein sollten, dass Ihre Sprache sie nicht hat!
Unterschied zwischen geprüften und ungeprüften Ausnahmen
Die Idee der geprüften Ausnahmen ist es, eine explizite Ausnahmebehandlung zur Kompilierungszeit zu erzwingen, um die "Vollständigkeit" des Codes zu gewährleisten. Eine Methode, die eine geprüfte Ausnahme erzeugt, kann diese Ausnahme auslösen, die Teil der Methodensignatur wird. Alle Aufrufer dieser Methode müssen dann entweder die Ausnahme weitergeben, indem sie sie ebenfalls in ihre Methodensignatur aufnehmen, oder sie mit einem catch Block behandeln. Betrachten Sie zur Veranschaulichung den folgenden Code:
public class ExceptionsDemo {
public static void main(String[] args) throws Throwable {
code();
}
static class BaseChecked extends Exception {}
static class CheckedA extends BaseChecked {}
static class CheckedB extends BaseChecked {}
static class BaseUnchecked extends RuntimeException {}
static class UncheckedA extends BaseUnchecked {}
static class UncheckedB extends BaseUnchecked {}
interface MyApi {
void checked() throws CheckedA, CheckedB;
void unchecked();
}
static class MyApiImpl implements MyApi {
@Override
public void checked() throws CheckedA {
throw new CheckedA();
}
@Override
public void unchecked() {
throw (Math.random() > 0.5) ? new UncheckedA() : new UncheckedB();
}
}
static void code() {
MyApi api = new MyApiImpl();
api.unchecked();
}
}Hier haben wir zwei Ausnahmehierarchien: BaseChecked und BaseUnchecked, von denen jede zwei Unterklassen hat. Beachten Sie, dass BaseChecked sich von Exception, während BaseUnchecked erweitert RuntimeException. Um den Unterschied zu verdeutlichen, betrachten wir die MyApi Schnittstelle, die zwei Methoden deklariert. Die Methode checked() Methode deklariert in ihrer Signatur, dass eine implementierende Methode entweder ein CheckedA oder CheckedB Ausnahme auslösen kann, während unchecked() nichts deklariert. Wenn die Schnittstelle implementiert wird, ist es möglich, jede ungeprüfte Ausnahme von der unchecked() Methode zu werfen. Das ist im Wesentlichen das, was eine ungeprüfte Ausnahme ist: eine Ausnahme, die eine Unterklasse von java.lang.RuntimeException ist (entweder direkt oder indirekt).
Im Gegensatz dazu sind geprüfte Ausnahmen Unterklassen von java.lang.Exception. Der Compiler verlangt, dass geprüfte Ausnahmen in der Methodensignatur deklariert werden müssen, damit sie ausgelöst werden können. Beachten Sie jedoch, dass, obwohl die MyApi::checked Interfacemethode deklariert CheckedA und CheckedB als mögliche Ausnahmen deklariert, die Implementierung (MyApiImpl::checked) wirft nur CheckedA und muss nicht deklarieren CheckedB. Daher müssen überschriebene Methoden die Ausnahmen ihrer Eltern nicht in ihrer eigenen Signatur deklarieren, es sei denn, sie werden von der Methode ausgelöst. Neue geprüfte Ausnahmen, die nicht von der übergeordneten Methode deklariert werden, können jedoch nicht geworfen werden, da dies gegen die polymorphen Prinzipien verstoßen würde, auf denen die Sprache beruht.
Skurrile Semantik
Die Semantik ist mit Hilfe eines Compilers leichter zu verstehen. Wenn Sie also mit Ausnahmen in Java nicht vertraut sind, sollten Sie mit dem Code in Ihrer IDE herumspielen. Beachten Sie zum Beispiel, wie der Compiler Sie zwingt, sowohl CheckedA und CheckedB zu behandeln, wenn Sie den Aufruf von api.unchecked() zu api.checked() im folgenden Beispiel ändern. Das liegt daran, dass Sie die Schnittstellenmethode aufrufen, nicht die Implementierungsmethode.
static void code() {
MyApi api = new MyApiImpl();
try {
api.checked();
}
catch (CheckedA | CheckedB ex) {
ex.printStackTrace();
}
}Sie können ihn natürlich auch werfen:
static void code() throws CheckedA, CheckedB {
MyApi api = new MyApiImpl();
api.checked();
}oder eine Ausnahme behandeln und die andere auslösen:
static void code() throws CheckedB {
MyApi api = new MyApiImpl();
try {
api.checked();
}
catch (CheckedA ax) {
// TODO handle here
}
}Sie können Ihren Code auch "zukunftssicher" machen, indem Sie BaseChecked in der Methodensignatur deklarieren:
static void code() throws BaseChecked {
MyApi api = new MyApiImpl();
api.checked();
}Wenn Sie Ihre Methodensignatur nicht verschmutzen wollen, können Sie sie in ein RuntimeException:
static void code() {
MyApi api = new MyApiImpl();
try {
api.checked();
}
catch (BaseChecked ex) {
throw new RuntimeException(ex);
}
}Beachten Sie, dass Sie sie immer noch im aufrufenden Code abfangen können, aber Sie müssen getCause() aufrufen, um die ursprüngliche Ausnahme zu erhalten, die ausgelöst wurde, etwa so:
public static void main(String[] args) throws Throwable {
try {
code();
}
catch (Exception ex) {
assert ex.getCause() instanceof BaseChecked;
}
} Raffinierter Wurf
Es gibt auch einen versteckten Trick, von dem ich bei meinen Recherchen zu diesem Thema erfahren habe und der mich überrascht hat, als ich ihn zum ersten Mal entdeckt habe. Wussten Sie, dass Java es Ihnen erlaubt, geprüfte Ausnahmen effektiv zu umgehen? Bevor ich darauf eingehe, müssen Sie zunächst wissen, dass RuntimeException tatsächlich erweitert Exceptionerweitert, obwohl erstere ungeprüft ist und letztere geprüft wird! Der Compiler überprüft die Klassenhierarchie und macht eine explizite Ausnahme (entschuldigen Sie das Wortspiel) für RuntimeException und seine Unterklassen. Hier ist die Trickfunktion
public static <E extends Throwable> void sneakyThrow(Exception ex) throws E {
throw (E) ex;
}
static void code() {
MyApi api = new MyApiImpl();
try {
api.checked();
}
catch (BaseChecked ex) {
sneakyThrows(ex);
}
}Beachten Sie, dass wir die api.checked() Methode aufrufen und die geprüfte Ausnahme effektiv auslösen können, ohne sie in der Methodensignatur von code()! Und nein, das ist kein Syntaxzucker für die Umhüllung einer Ausnahme. Wenn Sie sie im Aufrufer abfangen würden, würden Sie feststellen, dass es die gleiche Ausnahme ist, die geworfen wurde, und nicht eine RuntimeException die sie einhüllt.
public static void main(String[] args) throws Throwable {
try {
code();
}
catch (Exception ex) {
System.out.println(ex.getClass().getName());
}
} Untertypen von Beschattungsausnahmen
Wenn der vorangegangene Abschnitt Sie vom Hocker gehauen hat, dann habe ich es teilweise geschafft, meinen Standpunkt darzulegen: Geprüfte Ausnahmen sind kompliziert! Besonders in einer Sprache, die sowohl geprüfte als auch ungeprüfte Ausnahmen unterstützt, können die Semantik und das Zusammenspiel zwischen ihnen ziemlich komplex und manchmal verwirrend sein. Dies wird noch durch die Tatsache verschlimmert, dass geprüfte Ausnahmen in Java der "Standard" sind. Dies führt zu einem Problem bei der "Beschattung" von ungeprüften Ausnahmen. Betrachten Sie zum Beispiel den folgenden Code:
static void code() throws Exception {
MyApi api = new MyApiImpl();
if (Math.random() > 0.67) {
api.checked();
}
else {
api.unchecked();
}
}Beachten Sie, dass die Signatur der Methode nun die allgemeine java.lang.Exception in ihrer throws Klausel deklariert. Wenn wir die Methode aufrufen, müssen wir sie jetzt behandeln. Aber was passiert, wenn statt des Werfens BaseChecked, a BaseUnchecked Ausnahme ausgelöst wird? Was ist, wenn wir nicht abfangen wollen RuntimeException? Nun, dann müssen wir sie erneut auslösen. Der idiomatische Weg ist, die Ausnahme RuntimeException zuerst abzufangen, etwa so:
public static void main(String[] args) throws Throwable {
try {
code();
}
catch (RuntimeException ex) {
throw ex;
}
catch (Exception ex) {
// Handle checked
}
}Ähnlich wäre es, wenn Sie mit CheckedA, CheckedB und BaseUnchecked explizit behandeln wollen, aber nicht RuntimeExceptionzu behandeln, können Sie dies folgendermaßen tun:
public static void main(String[] args) throws Throwable {
try {
code();
}
catch (BaseUnchecked ex) {
// Handle BaseUnchecked
}
catch (RuntimeException ex) {
throw ex;
}
catch (Exception ex) {
if (ex instanceof CheckedA) {
// Handle CheckedA
}
else if (ex instanceof CheckedB) {
// Handle CheckedB
}
}
}Auf diese Weise fangen Sie Ausnahmen vom spezifischsten bis zum allgemeinsten ab, fast wie eine switch Anweisung. Verstehen Sie das Problem? Je allgemeiner eine Ausnahme ist, desto mehr Informationen verbirgt sie. Sie sind gezwungen, sich mit dem allgemeinsten Ausnahmetyp zu befassen, und es liegt an Ihnen, zu entschlüsseln, welche spezifischen Subtypen von einer Methode ausgelöst werden können. Dies kann nicht durch Methodensignaturen mitgeteilt werden - der Compiler kann Ihnen hier nicht helfen. Sie müssen sich also auf die Dokumentation oder sogar auf die Kenntnis des Quellcodes verlassen, um herauszufinden, welche Ausnahmen eine Methode auslösen kann. Damit wird der Hauptzweck von geprüften Ausnahmen wohl zunichte gemacht. Zugegeben, die unsachgemäße Verwendung von Ausnahmen in Methodensignaturen ist eher ein Designproblem als ein Sprachproblem, aber es braucht nur einen faulen Apfel, um Ihre Methodensignaturen zu verderben.
In der Praxis ist dies besonders häufig bei java.io.IOException, wo es viele Unterklassen gibt, die spezifische Probleme beschreiben, aber wenn Sie eine Bibliotheksmethode verwenden, die IOExceptionauslöst, dann haben Sie effektiv auf die Möglichkeit verzichtet, spezifischere Untertypen auszulösen, es sei denn, Sie sind bereit, alle anderen möglichen IO-Ausnahmen explizit abzufangen und zu behandeln, was ich nicht empfehlen würde!
API-Entwicklung und undichte Abstraktion
Definitionsgemäß müssen geprüfte Ausnahmen in einer Methodensignatur deklariert werden, um ausgelöst zu werden. Das bedeutet, dass Sie, wenn sich Ihre Implementierung ändert - zum Beispiel, wenn Sie eine Bibliotheksmethode aufrufen, die eine IOException oder eine andere geprüfte Ausnahme auslöst - sind Sie gezwungen, diese entweder innerhalb Ihrer Implementierung zu behandeln oder sie in eine RuntimeException. Die erste Option bläht Ihren Code auf und macht die Methode schwieriger zu verstehen. Letzteres ist ein Workaround, aber nicht unbedingt eine "brechende Änderung", da Ihre Methode nicht alle möglichen ungeprüften Ausnahmen deklarieren muss. Der beste Weg, eine solche Änderung zu kommunizieren, ist die Verwendung der @throws Javadoc in der Dokumentation der Methodensignatur zu verwenden, etwa so:
/**
* Method that does X.
*
* @throws UncheckedA If A goes wrong.
* @throws UncheckedB If B goes wrong.
*/
static void code() {
new MyApiImpl().unchecked();
}Auf diese Weise müssen die Benutzer Ihrer Methode nicht auf Entdeckungsreise gehen, um herauszufinden, wann eine mögliche Ausnahme ausgelöst werden könnte. Es bedeutet jedoch eine zusätzliche Belastung für die Betreuer, die Ausnahmen, die ausgelöst werden können, explizit zu dokumentieren, und für die Benutzer, diese Dokumentation zu lesen. Sie geben explizit an, welche Ausnahmen Ihnen in dieser Methode bekannt sind und wann sie ausgelöst werden können. Dies ist wohl kommunikativer, als einfach eine geprüfte Ausnahme auszulösen und sich darauf zu verlassen, dass der Compiler die Benutzer dazu zwingt, diese zu behandeln. Außerdem können Sie auf diese Weise selektiv entscheiden, welche Informationen Sie Ihren Benutzern preisgeben. Sie müssen keine Low-Level-Details von Ausnahmen angeben, die ausgelöst werden KÖNNEN, aber höchst unwahrscheinlich oder sogar unmöglich sind. Dies bringt mich zu meinem nächsten Punkt.
Einige Ausnahmen können NIE vorkommen
In manchen Fällen müssen geprüfte Ausnahmen auch dann abgefangen werden, wenn sie nachweislich nie ausgelöst werden können. Hier ist ein trivialer Fall:
static void code() throws URISyntaxException {
URI url = new URI("https://example.com");
}Die meisten Zeichenketten sind gültige URIs, da java.net.URI partielle URIs unterstützt und feststellen kann, welche Segmente angegeben sind. Die JDK-Maintainer haben dies erkannt und eine alternative Methode zur Erstellung eines URIs entwickelt. Die [Dokumentation] für diese Methode (https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/net/URI.html#create(java.lang.String)) sollte Ihnen einen Hinweis darauf geben, warum sie existiert.
static void code() {
URI url = URI.create("example");
}Ein übermäßiger Einsatz von geprüften Ausnahmen kann die Arbeit mit einer Bibliothek unangenehm machen und sich sogar negativ auf Ihre Metriken auswirken. Ein besonders ärgerliches Beispiel aus persönlicher Erfahrung ist Jacksons ObjectMapper. In dem Vonage Java SDKmüssen die meisten unserer Domain-Objekte in JSON serialisiert werden, aber um dies mit Jackson zu tun, müssen wir mit JsonProcessingException. Die einzige Möglichkeit, wie diese Ausnahme ausgelöst werden kann, ist, wenn die Klasse Annotationen falsch verwendet. Es gibt sogar eine beliebte StackOverflow-Frage zu diesem Thema, denn wenn man eine 100%ige Codeabdeckung anstrebt, ist es erforderlich, den catch Block abzudecken, auch wenn die Ausnahme in der Praxis nie ausgelöst wird.
Standardmäßige Ausbreitung
In den meisten Fällen möchten Sie eine Ausnahme wahrscheinlich ohnehin weitergeben ("aufblasen"). Das Schöne an ungeprüften Ausnahmen ist, dass ihre Behandlung optional ist. Standardmäßig werden sie geworfen, bis sie abgefangen oder vom ausführenden Thread behandelt werden. UncaughtExceptionHandler (standardmäßig wird der Stacktrace des Threads ausgegeben). Bei geprüften Ausnahmen muss man sie explizit propagieren, was, wie besprochen, die Methodensignaturen verschmutzt und sie zum Problem des Aufrufers macht, auch wenn die Ausnahme in der Praxis vielleicht nie auftritt.
In Ungnade gefallen
Es gibt einen guten Grund, warum Java eine der einzigen weit verbreiteten Programmiersprachen ist, die über geprüfte Ausnahmen verfügt (siehe diese StackOverflow-Frage). Andere JVM-Sprachen wie Kotlin, Scala, Groovy, Clojure usw. haben keine geprüften Ausnahmen - zumindest zwingen sie Sie nicht dazu, sie zu behandeln. Die Dokumentation der Sprache Kotlin fasst die Argumentation recht gut zusammen und zitiert das Interview von 2003 mit dem leitenden Sprachdesigner von C# über die Gründe für das Überspringen geprüfter Ausnahmen.
Selbst innerhalb der Java-Sprache selbst muss man sich nur die neueren APIs in der Standardbibliothek ansehen, um zu erkennen, dass geprüfte Ausnahmen sparsam verwendet werden sollten. Die in Java 8 eingeführten APIs wie java.time, java.util.stream und java.util.function vermeiden alle geprüfte Ausnahmen. Die inzwischen gut etablierte Streams-API ist aktiv gegen geprüfte Ausnahmen eingestellt. Dies hat Entwicklern, die die Streams-API verwenden möchten, viel Ärger bereitet, da sie umständliche Workarounds benötigen, um geprüfte Ausnahmen von den in Java integrierten funktionalen Schnittstellen aus weiterzuleiten. Mehr dazu später.
Andere Paradigmen der Ausnahmebehandlung
Meine Neugier auf kontrollierte Ausnahmen begann mit einer Diskussion mit meinem Kollegen, Guillaume. Er hat einen ausgezeichneten Vortrag zu diesem Thema gehalten, der die Argumente für geprüfte Ausnahmen untermauert. Er argumentiert, dass Ausnahmen nicht immer das richtige Werkzeug für den Umgang mit Fehlern sind und plädiert stattdessen für die Verwendung von Monaden. Ein Beispiel dafür ist die Art und Weise, wie die Java Streams API mit Ausnahmen umgeht. Nehmen Sie zum Beispiel den folgenden Code:
static void code() {
OptionalInt resultWrapped = IntStream.range(1, 20)
.filter(i -> i % 9 == 0 && i % 2 == 0)
.findAny();
int guaranteedResult = resultWrapped.orElseThrow(IllegalStateException::new);
int resultWithAlternative = resultWrapped.orElse(18);
}
Beachten Sie, dass die Verwendung eines filter auf einem Stream bedeutet, dass Sie beim Aufruf einer Terminaloperation wie finayAny()aufruft, erhält man einen Wrapper, der ein Ergebnis enthalten kann oder auch nicht. Um das Ergebnis zu erhalten, müssen Sie orElseThrow()aufrufen, der auch eine Supplier um die Ausnahme anzupassen, wenn der Wert nicht vorhanden ist. Dies ist fast wie eine getarnte geprüfte Ausnahme, da Sie gezwungen sind, das mögliche Fehlen eines Wertes ausdrücklich zu bestätigen, auch wenn er in der Praxis garantiert vorhanden ist. Natürlich ist es eleganter und expliziter, da Sie auch die Möglichkeit haben, einen alternativen Wert mit orElse.
Der Vortrag von Venkat Subramaniam auf der Devoxx UK 2023 zum Thema "Ausnahmebehandlung in funktionaler und reaktiver Programmierung" wurde mir klar, dass die beiden Ansichten nicht im Widerspruch zueinander stehen. Zwei direkte Zitate aus seinem Vortrag für den Kontext sind:
"Ausnahmebehandlung ist eine rein imperative Art der Programmierung." "Funktionale Programmierung und Ausnahmebehandlung schließen sich gegenseitig aus."
Ich kann den Vortrag sehr empfehlen, der auch das Thema der Verwendung von geprüften Ausnahmen in funktionalem Code, wie z. B. Java Streams, anspricht, wie bereits erwähnt. Die Erkenntnis ist, dass bei funktionalen und reaktiven Programmierstilen die Behandlung von Ausnahmen in der gesamten Pipeline der Datentransformation durchgeführt wird. Anstatt mit catch und finally Blöcke zu verwenden, nutzen reaktive Frameworks explizite Fehlerbehandlungsfunktionen, die auf die Pipeline angewendet werden, um Ausnahmen zu behandeln. Und damit schließt sich der Kreis zur ursprünglichen Intention von geprüften Ausnahmen: Vollständigkeit und explizite Bestätigung von Fehlern und Ausfällen innerhalb des Codes zu gewährleisten. Vielleicht geht es bei der Debatte über die Behandlung von Ausnahmen wirklich darum, wann und wo im Code wir auftretende Fehler behandeln sollten, und weniger um die dafür verwendeten Mechanismen.
Abmeldung
Das war's für den Moment! Wenn Sie irgendwelche Kommentare oder Vorschläge haben, können Sie uns gerne auf X, früher bekannt als Twitter oder besuchen Sie unseren Gemeinschaft Slack. Ich hoffe, dass dieser Artikel nützlich war und freue mich über alle Gedanken und Meinungen. Wenn er Ihnen gefallen hat, lesen Sie bitte auch meine anderen Java-Artikel.
Teilen Sie:
Sina ist Java Developer Advocate bei Vonage. Er hat einen akademischen Hintergrund und ist generell neugierig auf alles, was mit Autos, Computern, Programmierung, Technologie und der menschlichen Natur zu tun hat. In seiner Freizeit geht er gerne spazieren oder spielt Videospiele.