JEP draft: Exception handling in switch (Preview)

AuthorAngelos Bimpoudis & Brian Goetz
OwnerAngelos Bimpoudis
TypeFeature
ScopeSE
StatusDraft
Componentspecification / language
Discussionamber dash dev at openjdk dot org
EffortS
DurationS
Reviewed byAlex Buckley
Created2024/01/12 10:43
Updated2024/04/19 22:27
Issue8323658

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

Non-Goals

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:

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:

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:

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:

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.