Armed with this observation, here's a restack of the terminology and
rules, but with no semantic differences from the current story, just new
names.
The basic idea is:
total on T with no remainder -> total on T
total on T with remainder R -> covers T, excepting/modulo R
Concepts:
- A pattern P can be _total_ on a type T. Total patterns match every
element of the value set of T, including null.
- A set of patterns P* can _cover_ a type T, excepting/modulo R*.
This means that for every element in the value set of T that is not
matched by some pattern in R*, it is matched by some pattern in P*.
- If P is total on T, { P } covers T (with no exceptions.)
Base cases:
- (type patterns) The type pattern `T t` is total on all U <: T
- (var patterns) The var pattern `var x` is total on all T
- (default) The label `default` corresponds to a pattern that covers
all types, modulo null.
- (sealing) For an abstract sealed type S permitting T0..Tn, the set
of patterns { T0, T1, ... Tn } covers S, modulo null and novel subtypes
of S.
Induction cases:
- (lifting) For a deconstruction pattern D(T), { D(Q) : Q in Q* }
covers D (modulo D(R*) and null) iff Q* covers T modulo R.
Construct restrictions:
- The pattern on the RHS of instanceof may not be total on the type of
the LHS.
- In a (non-legacy) switch on x : T, the set of patterns in the case
labels must cover T, excepting some remainder. It throws on the remainder.
- In a pattern assignment `P = e`, where `e : T`, P must be cover T.
It throws on the remainder.
This is just a restack of the terms, but I think it eliminates the most
problematic part, which is 'total with remainder'. Now, total means
total. Total with remainder becomes "covers, excepting".
On 4/28/2021 2:13 PM, Brian Goetz wrote:
I think you're right that the terminology is currently biased for
mathematicians, which is a necessary initial phase to prove that the
language is right, but needs to get to the part where the terminology
makes sense to Joe Java.
The notion of "total with remainder" is indeed confusing, and we need
to find a better way to say it, but we come by it honestly. Because
the "remainder" corresponds to cases where no reasonable Java
developer would want to be forced to write them out, such as "novel
enum value".
I think we can make things slightly (but not enough) better by using
"total" for single patterns only, and using "exhaustive" for sets of
patterns, since that's what drives switch exhaustiveness.
But that's only a start.
On 4/28/2021 2:09 PM, Maurizio Cimadamore wrote:
I think I got the two main fallacies which led me down the wrong path:
1. there is a distinction between patterns that are total on T, and
patterns that are total on T, but with a "remainder"
2. there is a distinction between a pattern being total on T, and a
set of patterns being total (or exhaustive) on T
This sent me completely haywire, because I was trying to reason in
terms of what a plain pattern instanceof would consider "total", and
then translate the results to nested patterns in switch - and that
didn't work, because two different sets of rules apply there.
Maurizio
On 28/04/2021 18:27, Brian Goetz wrote:
I think part of the problem is that we're using the word "total" in
different ways.
A pattern P may be total on type T with remainder R. For example,
the pattern `Soup s` is total on `Soup` with no remainder.
A _set_ of patterns may be total on T with remainder R as well.
(The only way a set of patterns is total is either (a) one of the
patterns in the set is already total on T, OR (b) sealing comes into
play.) Maybe this should be called "exhaustive" to separate from
pattern totality.
Switch exhaustiveness derives from set-totality.
Instanceof prohibits patterns that are total without remainder, for
two reasons: (1) its silly to ask a question which constant-folds to
`true`, and (b) the disagreement between traditional `instanceof
Object` and `instanceof <total pattern>` at null would likely be a
source of bugs. (This was the cost of reusing instanceof rather
than creating a new "matches" operator.)
Foo x = ...
if (x instanceof Bar)
The instanceof will not be considered total, and therefore be
accepted by the compiler (sorry to repeat the same question - I
want to make sure I understand how totality works with sealed
hierarchies).
If the RHS of an `instanceof` is a type (not a type pattern), then
this has traditional `instanceof` behavior. If Bar <: Foo, then
this is in effect a null check.
If the RHS is a _pattern_, then the pattern must not be total
without remainder. If Bar <: Foo, `Bar b` is total on Foo, so the
compiler says "dumb question, ask a different one."
If the RHS is a non-total pattern, or a total pattern with
remainder, then there's a real question being asked. So in your
Lunch-permits-Soup example, you could say
if (lunch instanceof Soup s)
and this matches _on non-null lunch_. Just like the switch. The
only difference is switch will throw on unmatched nulls, whereas
instanceof says "no, not an instance", but that's got nothing to do
with patterns, it's about conditional constructs.
If that's the case, I find that a bit odd - because enums kind of
have the same issue (but we have opted to trust that a switch on an
enum is total if all the constants known at compile time are
covered) - and, to my eyes, if you squint, a sealed hierarchy is
like an enum for types (e.g. sum type).
OK, let's talk about enums. Suppose I have:
enum Lunch { Soup }
and I do
switch (lunch) {
case Soup -> ...
}
What happens on null? It throws, and it always has. The behavior
for the sealed analogue is the same; the `Soup s` pattern matches
the non-null lunches, and if null is left unhandled elsewhere in the
switch, the switch throws. No asymmetry.
Anyway, backing up - this below:
```
switch (lunch) {
case Box(Soup s):
System.err.println("Box of soup");
break;
case Bag(Soup s):
System.err.println("Bag of soup");
break;
/* implicit */
case Box(null), Bag(null): throw new NPE();
}
```
is good code, which says what it means. I think the challenge will
be to present error messages (e.g. if the user forgot to add case
Box(null)) in a way that makes it clear to the user as to what's
missing; and maybe that will be enough.
The challenge here is that we don't want to force the user to handle
the "silly" cases, such as:
Boolean bool = ...
switch (bool) {
case true -> ...
case false -> ...
case null -> ... // who would be happy about having to
write this case?
}
and the similarly lifted:
Box<Boolean> box = ...
switch (box) {
case Box(true) -> ...
case Box(false) -> ...
case Box(null) -> ... // who would be happy about having to
write this case?
case Box(novel) -> ... // or this case?
case null -> // or this case?
}
So we define the "remainder" as the values that "fall into the
cracks between the patterns." Users can write patterns for these,
and they'll match, but if not, the compiler inserts code to catch
these and throw something.
The benefit is twofold: not only does the user not have to write the
stupid cases (imagine if Box had ten slots, would we want to write
the 2^10 partial null cases?), but because we throw on the
remainder, DA can treat the switch as covering all boxes, and be
assured there are no leaks.