Effect cases in switch

Brian Goetz brian.goetz at oracle.com
Tue Dec 12 21:23:09 UTC 2023


Based on some inspiration from OCaml, and given that the significant 
upgrades to switch so far position it to do a lot more than it could 
before, we've been exploring a further refinement of switch to 
incorporate failure handling as well.

(I realize that this may elicit strong reactions from some, but please 
give it some careful thought before giving voice to those reactions.)



# Uniform handling of failure in switch

## Summary

Enhance the `switch` construct to support `case` labels that match 
exceptions
thrown during evaluation of the selector expression, providing uniform 
handling
of normal and exceptional results.

## Background

The purpose of the `switch` construct is to choose a single course of action
based on evaluating a single expression (the "selector").  The `switch`
construct is not strictly needed in the language; everything that 
`switch` does
can be done by `if-else`.  But the language includes `switch` because it
embodies useful constraints which both streamline the code and enable more
comprehensive error checking.

The original version of `switch` was very limited: the selector 
expression was
limited to a small number of primitive types, the `case` labels were 
limited to
numeric literals, and the body of a switch was limited to operating by
side-effects (statements only, no expressions.)  Because of these 
limitations,
the use of `switch` was usually limited to low-level code such as 
parsers and
state machines.  In Java 5 and 7, `switch` received minor upgrades to 
support
primitive wrapper types, enums, and strings as selectors, but its role 
as "pick
from one of these constants" did not change significantly.

Recently, `switch` has gotten more significant upgrades, to the point 
where it
can take on a much bigger role in day-to-day program logic.  Switch can 
now be
used as an expression in addition to a statement, enabling greater 
composition
and more streamlined code.  The selector expression can now be any 
type.  The
`case` labels in a switch block can be rich patterns, not just 
constants, and
have arbitrary predicates as guards.  We get much richer type checking for
exhaustiveness when switching over selectors involving sealed types.  Taken
together, this means much more program logic can be expressed concisely and
reliably using `switch` than previously.

### Bringing nulls into `switch`

Historically, the `switch` construct was null-hostile; if the selector 
evaluated
to `null`, the `switch` immediately completed abruptly with
`NullPointerException`.  This made a certain amount of sense when the only
reference types that could be used in switch were primitive wrappers and 
enums,
for which nulls were almost always indicative of an error, but as `switch`
became more powerful, this was increasingly a mismatch for what we 
wanted to do
with `switch`.  Developers were forced to work around this, but the 
workarounds
had undesirable consequences (such as forcing the use of statement switches
instead of expression switches.)  Previously, to handle null, one would 
have to
separately evaluate the selector and compare it to `null` using `if`:

```
SomeType selector = computeSelector();
SomeOtherType result;
if (selector == null) {
     result = handleNull();
}
else {
     switch (selector) {
         case X:
             result = handleX();
             break;
         case Y:
             result = handleY();
             break;
     }
}
```

Not only is this more cumbersome and less concise, but it goes against 
the main
job of `switch`, which is streamline "pick one path based on a selector
expression" decisions.  Outcomes are not handled uniformly, they are not 
handled
in one place, and the inability to express all of this as an expression 
limits
composition with other language features.

In Java 21, it became possible to treat `null` as just another possible 
value of
the selector in a `case` clause (and even combine `null` handling with
`default`), so that the above mess could reduce to

```
SomeOtherType result = switch (computeSelector()) {
     case null -> handleNull();
     case X -> handleX();
     case Y -> handleY();
}
```

This is simpler to read, less error-prone, and interacts better with the 
rest of
the language.  Treating nulls uniformly as just another value, as opposed to
treating it as an out-of-band condition, made `switch` more useful and 
made Java
code simpler and better.  (For compatibility, a `switch` that has no 
`case null`
still throws `NullPointerException` when confronted with a null 
selector; we opt
into the new behavior with `case null`.)

### Other switch tricks

The accumulation of new abilities for `switch` means that it can be used 
in more
situations than we might initially realize.  One such use is replacing the
ternary conditional expression with boolean switch expressions; now that
`switch` can support boolean selectors, we can replace

     expr ? A : B

with the switch expression

```
switch (expr) {
     case true -> A;
     case false -> B;
}
```

This might not immediately seem preferable, since the ternary expression 
is more
concise, but the `switch` is surely more clear.  And, if we nest 
ternaries in
the arms of other ternaries (possibly deeply), this can quickly become
unreadable, whereas the corresponding nested switch remains readable even if
nested to several levels.  We don't expect people to go out and change 
all their
ternaries to switches overnight, but we do expect that people will 
increasingly
find uses where a boolean switch is preferable to a ternary.  (If the 
language
had boolean switch expressions from day 1, we might well not have had 
ternary
expressions at all.)

Another less-obvious example is using guards to do the selection, within the
bounds of the "pick one path" that `switch` is designed for.  For 
example, we
can write the classic "FizzBuzz" exercise as:

```
String result = switch (getNumber()) {
     case int i when i % 15 == 0 -> "FizzBuzz";
     case int i when i % 5 == 0 -> "Fizz";
     case int i when i % 3 == 0 -> "Buzz";
     case int i -> Integer.toString(i);
}
```

A more controversial use of the new-and-improved switch is as a 
replacement for
block expressions. Sometimes we want to use an expression (such as when 
passing
a parameter to a method), but the value can only be constructed using
statements:

```
String[] choices = new String[2];
choices[0] = f(0);
choices[1] = f(1);
m(choices);
```

While it is somewhat "off label", we can replace this with a switch 
expression:

```
m(switch (0) {
     default -> {
         String[] choices = new String[2];
         choices[0] = f(0);
         choices[1] = f(1);
         yield choices;
     }
})
```

While these were not the primary use cases we had in mind when upgrading
`switch`, it illustrates how the combination of improvements to `switch` 
have
made it a sort of "swiss army knife".

## Handling failure uniformly

Previously, null selector values were treated as out-of-band events, 
requiring
that users handle null selectors in a non-uniform way.  The improvements to
`switch` in Java 21 enable null to be handled uniformly as a selector 
value, as
just another value.

A similar source of out-of-band events in `switch` is exceptions; if 
evaluating
the selector throws an exception, the switch immediately completes with that
exception.  This is an entirely justifiable design choice, but it forces 
users
to handle exceptions using a separate mechanism, often a cumbersome one, 
just as
we did with null selectors:

```
Number parseNumber(String s) throws NumberFormatException() { ... }

try {
     switch (parseNumber(input)) {
         case Integer i -> handleInt(i);
         case Float f -> handleFloat(f);
         ...
     }
}
catch (NumberFormatException e) {
     ... handle exception ...
}
```

This is already unfortunate, as switch is designed to handle "choose one 
path
based on evaluating the selector", and "parse error" is one of the possible
consequences of evaluating the selector.  It would be nice to be able to 
handle
error cases uniformly with success cases, as we did with null. Worse, 
this code
doesn't even mean what we want: the `catch` block catches not only 
exceptions
thrown by evaluating the selector, but also by the body of the switch.  
To say
what we mean, we need the even more unfortunate

```
var answer = null;
try {
     answer = parseNumber(input);
}
catch (NumberFormatException e) {
     ... handle exception ...
}

if (answer != null) {
     switch (answer) {
         case Integer i -> handleInt(i);
         case Float f -> handleFloat(f);
         ...
     }
}
```

Just as it was an improvement to handle `null` uniformly as just another
potential value of the selector expression, we can get a similar 
improvement by
handling normal and exceptional completion uniformly as well. Normal and
exceptional completion are mutually exclusive, and the handling of 
exceptions in
`try-catch` already has a great deal in common with handling normal 
values in
`switch` statements (a catch clause is effectively matching to a type 
pattern.)
For activities with anticipated failure modes, handling successful 
completion
via one mechanism and failed completion through another makes code harder to
read and maintain.

## Proposal

We can extend `switch` to handle exceptions more uniformly in a similar 
was as
we extended it to handle nulls by introducing `throws` cases, which 
match when
evaluating the selector expression completes abruptly with a compatible
exception:

```
String allTheLines = switch (Files.readAllLines(path)) {
     case List<String> lines -> 
lines.stream().collect(Collectors.joining("\n"));
     case throws IOException e -> "";
}
```

This captures the programmer's intent much more clearly, because the 
expected
success case and the expected failure case are handled uniformly and in 
the same
place, and their results can flow into the result of the switch expression.

The grammar of `case` labels is extended to include a new form, `case 
throws`,
which is followed by a type pattern:

     case throws IOException e:

Exception cases can be used in all forms of `switch`: expression and 
statement
switches, switches that use traditional (colon) or single-consequence 
(arrow)
case labels.  Exception cases can have guards like any other pattern case.

Exception cases have the obvious dominance order with other exception 
cases (the
same one used to validate order of `catch` clauses in `try-catch`), and 
do not
participate in dominance ordering with non-exceptional cases.  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`.  For clarity, exception cases should probably come after 
all other
non-exceptional cases.

When evaluating a `switch` statement or expression, the selector 
expression is
evaluated.  If evaluation of the selector expression throws an 
exception, and
one of the exception cases in the `switch` matches the exception, then 
control
is transferred to the first exception case matching the exception. If no
exception case matches the exception, then the switch completes abruptly 
with
that same exception.

This slightly adjusts the set of exceptions thrown by a `switch`; if an
exception is thrown by the selector expression but not the body of the 
switch,
and it is matched by an unguarded exception case, then the switch is not
considered to throw that exception.

### Examples

In some cases, we will want to totalize a partial computation by supplying a
fallback value when there is an exception:

```
Function<String, Optional<Integer>> safeParse =
     s -> switch(Integer.parseInt(s)) {
             case int i -> Optional.of(i);
             case throws NumberFormatException _ -> Optional.empty();
     };
```

In other cases, we may want to ignore exceptional values entirely:

```
stream.mapMulti((f, c) -> switch (readFileToString(url)) {
                     case String s -> c.accept(s);
                     case throws MalformedURLException _ -> { };
                 });
```

In others, we may want to process the result of a method like `Future::get`
more uniformly:

```
Future<String> f = ...
switch (f.get()) {
     case String s -> process(s);
     case throws ExecutionException(var underlying) -> throw underlying;
     case throws TimeoutException e -> cancel();
}
```

### Discussion

We expect the reaction to this to be initially uncomfortable, because
historically the `try` statement was the only way to control the handling of
exceptions.  There is clearly still a role for `try` in its full 
generality, but
just as `switch` profitably handles a constrained subset of the 
situations that
could be handled with the more general `if-else` construct, there is 
similarly
profit in allowing it to handle a constrained subset of the cases 
handled by the
more general `try-catch` construct.  Specifically, the situation that 
`switch`
is made for: evaluate an expression, and then choose one path based on the
outcome of evaluating that expression, applies equally well to 
discriminating
unsuccessful evaluations.  Clients will often want to handle exceptional 
as well
as successful completion, and doing so uniformly within a single 
construct is
likely to be clearer and less error-prone than spreading it over two 
constructs.

Java APIs are full of methods that can either produce a result or throw an
exception, such as `Future::get`.  Writing APIs in this way is natural 
for the
API author, because they get to handle computation in a natural way; if they
get to the point where they do not want to proceed, they can `throw` an
exception, just as when they get to the point where the computation is done,
they can `return` a value. Unfortunately, this convenience and 
uniformity for
API authors puts an extra burden on API consumers; handling failures is more
cumbersome than handling the successful case.  Allowing clients to 
`switch` over
all the ways a computation could complete heals this rift.

None of this is to say that `try-catch` is obsolete, any more than `switch`
makes `if-else` obsolete.  When we have a large block of code that may 
fail at
multiple points, handling all the exceptions from the block together is 
often
more convenient than handling each exception at its generation point.  
But when
we scale `try-catch` down to a single expression, it can get awkward.  The
effect is felt most severely with expression lambdas, which undergo a
significant syntactic expansion if they want to handle their own 
exceptions.

-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://mail.openjdk.org/pipermail/amber-spec-experts/attachments/20231212/5187db96/attachment-0001.htm>


More information about the amber-spec-experts mailing list