JEP draft: Exception handling in switch (Preview)
Author | Angelos Bimpoudis & Brian Goetz |
Owner | Angelos Bimpoudis |
Type | Feature |
Scope | SE |
Status | Draft |
Component | specification / language |
Discussion | amber dash dev at openjdk dot org |
Effort | S |
Duration | S |
Reviewed by | Alex Buckley |
Created | 2024/01/12 10:43 |
Updated | 2024/04/19 22:27 |
Issue | 8323658 |
Summary
Enhance the switch
construct so that exceptions thrown by the selector (the e
in switch (e) ...
) can be handled in the switch block. Alongside a previous enhancement to handle null
selectors, this makes switch
even more useful for pattern matching. This is a preview language feature.
Goals
-
Improve readability and maintainability by allowing
switch
to concisely handle all possible outcomes of evaluating the selector. -
Streamline the use of APIs that throw checked exceptions, when used by the selector of a
switch
.
Non-Goals
-
It is not a goal to handle exceptions thrown by the switch block, in the switch block. That is, if the code for a
case
ordefault
clause throws an exception, it must be handled bycatch
clauses outside theswitch
. -
It is not a goal to introduce new kinds of patterns that match exceptions. That is, exceptions are not being downgraded to ordinary values that can be matched uniformly in all constructs that take patterns.
-
It is not a goal to embed support for exception handling in other statements and expressions.
-
It is not a goal to alter the model of checked vs. unchecked exceptions, either in general or within the extent of a
switch
.
Motivation
A switch
embodies a multi-way choice based on the value of a selector expression (the e
in switch (e) ...
). Traditionally, switch
was hostile to null
values: if the selector expression evaluated to null
, then switch
threw a NullPointerException
. switch
was also hostile to exceptions: if the selector expression threw an exception instead of producing a value, then switch
rethrew the exception.
This fondness for throwing exceptions made switch
difficult to use. It forced developers to check for a null
selector by writing code before the switch
. It also forced developers to handle exceptions from the selector, typically by enclosing the switch
in try
-catch
. Even though the outcomes from evaluating the selector are mutually exclusive -- either a non-null
value, or a null
value, or an exception -- they were not amenable to case analysis within the switch
.
switch
has been revitalized in recent years to enable data-oriented programming, with support for record patterns, guarded cases, and exhaustiveness. As part of this effort, switch
was changed in JDK 21 to avoid throwing NullPointerException
automatically for a null
selector. Developers can handle a null
selector explicitly via case null
in the switch block: (without case null
, switch
throws NullPointerException
as before)
String s = ...
switch (s) {
case "foo" -> System.out.println("Great");
case null -> System.out.println("Oops");
default -> System.out.println("OK");
}
Being able to handle null
with just another case
makes the switch
construct more uniform and makes code easier to read and less error-prone. Unfortunately, exceptions from the selector are still not amenable to case analysis within the switch block, so must be handled by catch
clauses outside the switch
:
record Box(String in) { }
Future<Box<T>> f = ...
try {
switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
}
} catch (CancellationException ce) { // an unchecked exception of Future::get
...ce...
} catch (ExecutionException ee) { // a checked exception of Future::get
...ee...
} catch (InterruptedException ie) { // a checked exception of Future::get
...ie...
}
Enclosing switch
in try
-catch
is not just clunky; it has real downsides that lead to worse programs. First, the catch
block catches not only exceptions thrown by the selector but also exceptions thrown by the switch block; this is likely to result in subtle bugs. Second, try
-catch
can only wrap a switch
statement, not a switch
expression; this leads to substantial inconvenience when trying to use expression-oriented APIs such as Streams.
To make switch
more usable for pattern matching, it would be helpful if developers could avoid try
-catch
and handle an exception from the selector with just another case
-- similar to handling a null
selector with case null
. Inspired by how a throws
clause denotes the exceptions of a method, we introduce case throws
to denote exceptions from a selector:
Future<Box> f = ...
switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
case throws CancellationException ce -> ...ce...
case throws ExecutionException ee -> ...ee...
case throws InterruptedException ie -> ...ie...
}
Being able to handle an exception from the selector locally, in the switch block, means that switch
expressions become a kind of universal computation engine, able to provide control flow in expression-oriented APIs. For example:
stream
.map(Future<Box> f ->
switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
case throws Exception e -> { log(e); yield score(0); }
})
.reduce(0, (subtotal, element) -> subtotal + element);
In summary, enhancing switch
to handle exceptions from the selector will streamline the use of libraries that throw exceptions, improve readability, and reduce mistakes.
Description
switch
statements and switch
expressions support a new kind of switch label: an exception case, denoted with case throws
.
SwitchLabel:
case CaseConstant {, CaseConstant}
case CasePattern [Guard]
case null [, default]
default
case throws CasePattern [Guard]
The first four kinds of switch label (including default
) are called normal cases.
Exception cases can appear in a switch block that uses the traditional :
form or the rule-based ->
form. Exception cases also can have guards. For example:
case throws CancellationException ce: ...ce...
case throws CancellationException ce -> ...ce...
case throws ExecutionException ee when ee.getCause() != null -> ...ee...
It is a compile-time error if an exception case specifies an exception type that cannot be thrown by the selector expression, or a type that does not extend Throwable
.
This is a preview language feature, disabled by default
To try the examples below in JDK NN you must enable preview features:
-
Compile the program with
javac --release NN --enable-preview Main.java
and run it withjava --enable-preview Main
; or, -
When using the source code launcher, run the program with
java --enable-preview Main.java
; or, -
When using
jshell
, start it withjshell --enable-preview
.
The form of a switch block
A switch block can be thought of as being partitioned into two sections: one section consists of the normal cases while the other section consists of the exception cases. Program execution flows through one section or the other, not both:
- If evaluation of the selector succeeds, only the normal cases are relevant. The exception cases will never execute even if a normal case executes by throwing exception.
- If evaluation of the selector fails, only the exception cases are relevant. The normal cases will never execute. A
default
label among the normal cases does not serve to handle exceptions that might be overlooked by the exception cases. - If the switch block has the traditional
:
form, there is no fall-through from normal cases to exception cases, or from exception cases to normal cases. In addition, there is no fall-through from one exception case to another exception case.
It is strongly recommended to group normal cases together and exception cases together:
case Box(String s) when isGoodString(s) -> ...
case Box(String s) -> ...
case null -> ...
default -> ...
case throws ExecutionException ee -> ...
case throws InterruptedException ce -> ...
Interleaving normal cases with exception cases is syntactically legal, but almost never a good idea:
case Box(String s) when isGoodString(s) -> ...
case throws ExecutionException ee -> ...
case Box(String s) -> ...
case null -> ...
case throws InterruptedException ce -> ...
default -> ...
Dominance
Exception cases do not participate in dominance ordering with normal cases. The order of exception cases is regulated like the order of catch
clauses in try
-catch
: more specific exceptions must be handled/caught before less specific exceptions. For example, this try
-catch
and switch
have the same behavior:
try {
throw new IOException();
}
catch (Exception e) {
}
catch (IOException ioe) { // compile-time error: IOException has already been caught
}
switch (...assume selector can throw IOException...) {
default -> ...
case throws Exception e -> ...e ...
case throws IOException ioe -> ...ioe... // compile-time error: exception case is dominated by previous case
}
Exhaustiveness
Every checked exception that the selector can throw must be:
- handled by
case throws
in the switch block, or - caught by a
catch
clause of an enclosingtry
-catch
, or - declared in the
throws
clause of the enclosing method.
For example, the following method is legal because all the checked exceptions of Future.get
are dealt with: ExecutionException
is handled by case throws
and InterruptedException
is declared in the throws
clause.
void m(Future<Box> f) throws InterruptedException {
switch (f.get()) {
case Box b -> defaultAction(b);
case throws CancellationException ce -> ...ce...
case throws ExecutionException ee -> ...ee...
}
}
Catching multiple exceptions
Java 22 introduced unnamed pattern variables (JEP 456) as well as the ability to have multiple patterns in a single case
label provided that none of them declare named pattern variables. An exception case can use multiple patterns with unnamed pattern variables in order to handle multiple exceptions from the selector in a single case
:
case throws CancellationException _, ExecutionException _ -> ...
This stands in for the multi-catch syntax of try
-catch
, e.g., catch (CancellationException | ExecutionException x) {...}
. Multi-catch syntax is not allowed in an exception case; see Alternatives.
Precise rethrow
Java 7 enhanced try
-catch
so that exceptions rethrown from the catch
block have more precise types when the exception parameter is final
or effectively final. The same precision applies when exception cases in switch
rethrow the exception from the selector. For example:
Object f() throws A, B, C {...}
void m() {
switch (f()) {
case throws Exception e -> throw e; // switch statement can throw A, B, C
...
}
}
Run time
When evaluating a switch
statement or expression, the selector expression is evaluated. If evaluation of the selector expression throws an exception, and at least one of the exception cases in the switch block can handle the exception, then control is transferred to the first exception case that can handle the exception. If no exception case matches the exception, then the switch
statement or expression completes abruptly with the same exception. This is consistent with the behavior of all existing switches.
Other exceptions that can be thrown from a switch block may originate from the evaluation of the guard or from the right-hand side of a case
label. Exception cases do not handle such exceptions, only those from the selector's evaluation.
Matching exceptions versus handling exceptions
It is legal for the selector of a switch
to return a value which is itself an exception. That is, the selector does not throw an exception, but merely evaluates to one. Normal case
labels can match the value with type patterns, e.g.,
Exception m() { return new ... }
switch (m()) {
case CancellationException ce -> ... // This is a case, not a case throws
case ExecutionException ee -> ... // This is a case, not a case throws
...
}
One scenario where such case
labels may appear is in a switch
expression that analyzes an exception handled by an "outer" case throws
, e.g.,
case throws Exception ex -> switch (ex) {
case CancellationException ce -> ...ce...
case ExecutionException ee -> ...ee...
...
}
When analyzing an exception in this way, care must be taken in the "inner" switch
expression (switch (ex) ...
). The normal cases of a switch
expression must always be exhaustive, either by having case
labels that match every possible value of the selector or by having a default
label. Below, the inner switch
expression has case
labels that match three exception types (those thrown by the outer switch
's selector), but these labels are not exhaustive with respect to its selector ex
of type Exception
; therefore, the inner switch
is illegal.
void m(Future<Box> f) {
switch (f.get()) {
case Box b -> defaultAction(b);
case throws Exception ex -> switch (ex) { // compile-time error: normal cases not exhaustive
case CancellationException ce -> ...ce...
case ExecutionException ee -> ...ee...
case InterruptedException ie -> ...ie...
}
};
}
(While the rules of exhaustiveness for normal cases consider only case
and default
labels in the switch block, the rules that verify exceptions thrown by the selector take a much broader view. They consider the exceptions handled by case
throws in the switch
block and any exceptions caught by enclosing try
-catch
statements and any exceptions declared in the method's throws
clause.)
Idiom: Streamlined static field initialization
Sometimes, developers cannot write the "obvious" code because it could throw a checked exception in a location where checked exceptions are not allowed. This used to mean a painful refactoring of code, but with exception cases, developers can wrap the original code in a switch
expression and handle the checked exceptions inline.
For example, the initializer of a static field is not allowed to throw a checked exception:
class Foo {
Foo() throws IOException {...}
}
class Bar {
public static final Foo THE_FOO = new Foo(); // error: unhandled exception IOException
}
Typically, initialization of THE_FOO
would be handled as follows, which is painful to read:
class Bar {
public static final Foo THE_FOO;
static {
try {
THE_FOO = new Foo();
} catch (IOException ex) {
throw new ExceptionInInitializerError(ex);
}
}
}
With a switch
expression and an exception case, the initialization can be done inline:
class Bar {
public static final Foo THE_FOO = switch (new Foo()) {
case var o -> o;
case throws Exception ex -> throw new ExceptionInInitializerError(ex);
};
}
Alternatives
catch
is so well known that people sometimes suggest it be reused in various ways, such as:
switch (...) {
...
} catch (Exception e) {
...
}
or:
switch (...) {
...
case catch Exception e -> ...
}
The first suggestion puts catch
outside the switch block, implying it can catch exceptions thrown by the right hand sides of cases inside the switch block. That would be a different feature. We want to catch only the exceptions thrown by switch's selector, so we need to catch them "locally", inside the switch block.
The second suggestion uses case catch
rather than case throws
. This looks like an innocuous syntax change, but it would distort understanding of the code. As discussed in OpenJDK, the essence of switch is "evaluate a thing, then do one of the following things as a result". We pick which thing to do based on case
clauses. Currently there are case
clauses for constants, patterns, and catch-all (default
). Each case
clause should refer back to the computation in switch's selector. A case <constant>
says "did it evaluate to this constant?". A case <pattern>
says "did it evaluate to something that matches this pattern?". A case throws <pattern>
says "did it throw something that matches this pattern?". case catch
would be asking "did it evaluate to something that catches this exception?", which doesn't make sense.
Since Java 7, try
-catch
has supported "multi-catch", e.g., catch (Exception1|Exception2 e)
. The type of e
is the
union of the two alternatives. While it might seem like an obvious move to propagate multi-catch into exception cases, this may foreclose on other, potentially more valuable possibilities, so we will consider this for the future.
This JEP offers language-level support for catching exceptions in switch
. The
verbosity of the working example shown in the Motivation could be
mitigated by extracting the complexity of try
-catch
to a helper method and
mapping the potential result to a suitable type, such as Optional
:
// a potential helper method
<T> Optional<T> toOptional(Supplier<T> thunk) { ... }
Future<Box<T>> f f = ...
switch (toOptional(() -> f.get())) { // unavoidable wrapping
case Optional o when o.isPresent() -> process(o.get());
default -> // handle exceptional cases
}
While the code above wraps the call of f.get()
, this encoding completely
disregards the various kinds of exceptions that can be raised. Optional
could
be replaced with some other data type that also holds the exceptions.
Consequently, wrapping the expression of the selector would be unavoidable and
each library would be responsible in providing its own, ad-hoc protocol for
handling errors. Unfortunately, such approach is merely a workaround; it reduces
the verbosity, but doesn't help with the fundamental problem.