Exhaustiveness in switch
Brian Goetz
brian.goetz at oracle.com
Thu May 10 20:12:37 UTC 2018
In the long (and indirect) thread on "Expression Switch Exception
Naming", we eventually meandered around to the question of when should
the compiler deem an expression switch to be exhaustive, and therefore
emit a catch-all throwing default. Let's step back from this for a bit
and remind ourselves why we care about this.
Superficially, having to write a throwing default for a condition we
believe to be impossible is annoying:
switch (trafficLight) {
case RED -> stop();
case GREEN -> driveFast();
case YELLOW -> driveFaster();
default -> throw new ExasperationException("No, we haven't
added any new traffic light colors since the invention of the
automobile, so I have no idea what " + trafficLight + " is");
}
The annoyance here, though, is twofold:
- I have to write code for something which I think can't happen;
- That code is annoying to write.
In the above, we "knew" another traffic light color was impossible, and
we listed them all -- and the compiler knew it. This is particularly
irritating. However, we often also see cases like this:
void processVowel(letter) {
switch (letter) {
case A: ...
case E: ...
case I: ...
case O: ...
case U: ...
default: throw new IllegalStateException("Not a vowel: " +
letter);
}
Here, the annoyance is slightly different, in that I could not
reasonably expect the compiler to know I'd covered all the vowels. In
fact, I think the explicit exception in this case is useful, in that it
documents an invariant known to the programmer but not captured in the
type system. But it is still annoying that I have to construct a format
string, construct an exception, and throw it; if there were easier ways
to do that, I might be less annoyed. Without diving into the bikeshed,
maybe this looks something like:
default: throw IllegalStateException.format("Not a vowel: %s",
vowel);
The details aren't relevant, but the point is: maybe a small-ish library
tweak would reduce the annoyance of writing such clauses. (This one
isn't so bad, but Dan excavated a bunch that were way worse.) But,
let's set this aside for a moment, and return back to the point of why
we want the compiler to provide a throwing default.
I think most of the discussion has centered on the problem of a novel
value showing up at runtime. This is surely an issue, and must be dealt
with, but the central issue is: a default is never able to distinguish
between a runtime-novel value and a value we just forgot to include at
compile time. It doesn't matter whether this default throws (as the
implicit default in an expression switch) or does nothing (as the
implicit default in statement switches today does).
We agreed that we should not require the user to provide a default when
they provide case clauses that cover the target type as of compile time
(true+false for boolean, all the members of a sealed type, etc.) This
is because the default you'd be forced to put in otherwise (for
expression switches) is actually harmful; if the type were later
modified to have more values, an explicit default would swallow them,
rather than yielding an error at recompilation time. So it is not only
annoying, but actually could cover up errors.
We then went off on the wrong tangent, though, where we wondered whether
it was OK to implicitly assume enums were sealed, since some enums are
clearly intended to acquire new values. But the mistake was focusing on
the wrong aspect of sealed-ness (the statement of intent to not add more
values), rather than the compiler's ability to reason credibly about
known possible values.
So, backing up, I think we should always treat a "complete" enum
expression switch specially -- don't require a default, and implicitly
add a throwing one, if all the cases are specified. This way, if the
assumption that you've covered all the cases is later broken via
separate compilation, on recompilation, you'll discover this early,
rather than at runtime. (You'll still get runtime protection either
way.) Regardless of whether we think the enum will be extended in the
future or not. There's no need for enums to declare themselves "sealed"
or "non-sealed" (and such a declaration would likely be incorrect
anyway, as it asks users to predict the future, which is error-prone.)
Given this, I'm willing to use ICCE as a base type for the implicit
exception (though there should be more specific subtypes.)
Now, statement switches. It seems sad that we can't get the same kind
of compile-time assistance over statement switches than we do over
expression switches. We're somewhat locked in by compatibility here;
statement switches today get an implicit "default: nothing" clause if
they have no default, and we cannot (and don't want to) break this. So
the next best thing is if the user could say "I want to get the same
sort of compile-time verification of putative exhaustiveness for this
statement switch as I would for expression switches." This would
require some additional syntax (please, let's not bikeshed this until
everything else on this topic is nailed down; this is a target of
opportunity, not a problem to be solved Right Now.)
Someone is likely to suggest that we should do the exhaustiveness thing
for all three of the four new forms (statement arrow, and expression
colon/arrow). Feel free to make this suggestion, but you're going to
get the "snitch" lecture :)
Another thing that we can do to make it easier to write throwing
defaults: lean on intrinsics. Recall that separately, we've got a story
to expose some compiler intrinsics for ldc() and invokedynamic().
There's room to add other things to this, such as the equivalent of
__LINE__ and __FILE__ macros in C, or (relevant to this) information
about the the current point in the compilation (such as the cases
enumerated in the innermost switch.) So for example:
default: throw SwitchException.format("Found %s, but expected one
of %s",
target,
Intrinsics.switchCases());
or even
default: throw SwitchException.of(target, Intrinsics.switchCases());
where `Intrinsics.switchCases()` would evaluate to a string that
includes all the cases handled by the current switch (in our vowels
case, this would be "A, E, I, O, U"). Again, not something for Right
Now, but something that machinery that's in the pipeline can contribute
to making it simpler and more uniform to express catch-all defaults, and
thereby reduced the perceived annoyance.
Summary:
- For switches over any type where the compiler can enumerate the
possibilities (includes enums, some primitives, and sealed types),
always allow the user to leave off a default if they've specified all
the known cases.
- Use subtypes of ICCE in implicit throwing defaults.
- Consider library enhancements to common exceptions (and maybe
additional intrinsics) to simplify code that throws formatted exceptions.
More information about the amber-spec-experts
mailing list