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.

Reply via email to