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