Uniform handling of failure in switch
Brian Goetz on December 15, 2023Based 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.
Overview
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 thatswitch
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 nocase 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 upgradingswitch
, 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 thatswitch
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.
Content of this post is originally available on OpenJDK mailing list. We encourage you to follow or join the conversation there.