Gavin has a spec that captures most of what we've talked about here,
which should be posted soon. But I want to revisit one thing, because I
think we may have swung a little too eagerly at a bad pitch.
There is a distinction between the _semantics of a given pattern_ and
_what a given construct might do with that pattern_. The construct
(instanceof, switch) gets first crack at having an opinion, and then may
choose to evaluate whether the pattern matches something. The place
where we have been most tempted to use this flexibility is with "null",
because null ruins everything.
We made a decision to lump pattern matching in with `instanceof` because
it seemed silly to have two almost identical but subtly different
constructs for "dynamic type test" and "pattern match" (given that you
can do dynamic type tests with patterns.) We knew that this would have
some uncomfortable consequences, and what we have tentatively decided to
do is outlaw total patterns in instanceof, so that users are not
confronted with the subtle difference between `x instanceof Object` and
`x instanceof Object o`. This may not be a totally satisfying answer,
and we left some room to adjust this, but its where we are.
The fact is that the natural interpretation of a total type pattern is
that it matches null. People don't like this, partially because it goes
against the intuition that's been built up by instanceof and switch.
(If we have to, I'm willing to lay out the argument again, but after
having been through it umpteen times, I don't see any of the
alternatives as being better.)
So, given that a total type pattern matches null, but legacy switches
reject null...
The treatment of the `null` label solves a few problems:
- It lets people who want to treat null specially in switch do so,
without having to do so outside the switch.
- It lets us combine null handling with other things (case null,
default:), and plays nicely when those other things have bindings (case
null, String s:).
- It provides a visual cue to the reader that this switch is nullable.
It is this last one that I think we may have over-rotated on. In the
treatment we've been discussing, we said:
- switch always throws on null, unless there's a null label
Now, this is clearly appealing from a "how do I know if a switch throws
NPE or not" perspective, so its understandable why this seemed a clever
hack. But reading the responses again:
Tagir:
> I support making case null the only null-friendly pattern.
Maurizio:
> there's no subtle type dependency analysis which determines the fate
of null
it seems that people wanted to read way more into this than there was;
they wanted this to be a statement about patterns, not about switch. I
think this is yet another example of the "I hate null so much I'm
willing to take anything to make it (appear to) go away" biases we all
have." Only Remi seemed to have recognized this for the blatant trick
it is -- we're hiding the null-accepting behavior of total patterns
until people encounter them with nested patterns, where it will be less
uncomfortable.
To be clear: this is *not* making `null` the only null-friendly
pattern. But these responses make me worry that, if the experts can't
tell the difference, then no user will be able to tell the difference,
and that this is just kicking the confusion down the road. It might be
better to rip the band-aid off, and admit how patterns work.
Here's an example of the kind of mistake that this treatment
encourages. If we have:
switch (x) {
case Foo(String a): A
case Foo(Integer a): B
case Foo(Object a): C
}
and we want to refactor to
switch (x) {
case Foo(var a):
switch(a) {
case String a: A
case Integer a: B
case Object a: C
}
}
we've made a mistake. The first switch does the right thing; the second
will NPE on Foo(null). And by insulating people from the real behavior
of type patterns, it will be even more surprising when this happens.
Now, let's look back at the alternative, where we keep the flexibility
of the null label, but treat patterns as meaning what they mean, and
letting switch decide to throw based on whether there is a nullable
pattern or not. So a switch with a total type pattern -- that is, `var
x` or `Object x` -- will accept null, and thread it into the total case
(which also must be the last case.)
Who is this going to burn, that is not going to be burned by the
existing switch behavior anyway? I think very, very few people. To get
burned, a lot of things have to come together. People are used to
saying `default`; those that continue to are not going to get burned.
People are generally in agreement that `var x` should be total; people
who use that are not going to get burned. Switches today NPE eagerly on
null, so having a null flow into code that doesn't expect it will result
in ... the same NPE.
And, people who want to be explicit can say:
case null, Object o:
and it will work -- and maybe even IntelliJ will hint them "hey, did you
know this null is redundant?" And then learning will happen!
So, I think the "a switch only accepts null if the letters n-u-l-l are
present", while a comforting move in the short term, buys us relatively
little, and dulls our pain receptors which in turn makes it take way
longer to learn how patterns really work. I think we should go back to:
- A switch accepts null if (a) one of the case labels is `null` or (b)
the switch has a total pattern (which must always be the last case.)
On 3/12/2021 9:12 AM, Brian Goetz wrote:
The JEP has some examples of how the `null` case label can combine
with others. But I would like to propose a more general way to
describe what's going on. This doesn't change the proposed language
(much), as much as describing/specifying it in a more general way.
We have the following kinds of switch labels:
case <constant>
case null
case <pattern>
default
The question is, which can be combined with each other into a single
case, such as:
case 3, null, 5:
This question is related to, which can fall into each other:
case 3:
case null:
case 5:
We can say that certain labels are compatible with certain others, and
ones that are compatible can be combined / are candidates for
fallthrough, by defining a compatibility predicate:
- All <constant> case labels are compatible with each other;
- The `null` label is compatible with <constant> labels;
- The `null` label is compatible with `default`;
- The `null` label is compatible with (explicit) type patterns:
(There is already a check that each label is applicable to the type of
the target.)
Then we say: you can combine N case labels as long as they are all
compatible with each other. Combination includes both comma-separated
lists in one case, as well as when one case is _reachable_ from
another (fall through.) And the two can be combined:
case 3, 4: // fall through
case null:
So the following are allowed:
case 1, 2, 3, null:
case null, 1, 2, 3:
case null, Object o:
case Object o, null:
case null, default: // special syntax rule for combining default
The following special rules apply:
- `default` can be used as a case label when combined with compatible
case labels (see last example above);
- When `null` combines with a type pattern, the binding variable of
the type pattern can bind null.
The semantics outlined in Gavin's JEP are unchanged; this is just a
new and less fussy way to describe the behavior of the null label /
specify the interaction with fallthrough.