Description
We should ensure that the set of subtypes which exhaust a sealed type is predictable and computable.
(TL;DR: We should ignore non-implementable mixins with a sealed class as an on
type from the types we care about for exhaustiveness of that sealed class. We should compute a set of public types that can exhaust a sealed type for documentation purposes. Possible algorithm provided.)
We have an algorithm for checking whether a sealed type has been exhausted by a switch
.
Generally, a sealed type has a number of subtypes, so that if you check an object whose static type is the sealed type against every one of those subtypes, at least one of them is guaranteed to match. Checking all of them "exhausts" the supertype.
(Instead of checking against the subtype directly, you might also be able to exhaust the subtype using multiple checks tjat again ensures that if the value has that subtype, it will be matched by at least one switch case. Checking a type directly is the easiest way to exhaust that type, some types allow other ways too, like sealed types and enums.)
There can potentially be many ways to exhaust a sealed type, but to be usable, a sealed type should communicate to users a set of subtypes they need to check in order to achieve an exhaustive switch.
We want that set of subtypes to be predictable, both to the author and to users, at least in non-pathological cases.
(It's probably OK that things get weird if you do things that makes the sealed class effectively unusable anyway, but for any reasonable use-case, the author and users should see the same declarations and make the same conclusion about which types to check for.)
It would be nice if that set (or a set) is computable, because it would be useful for the DartDoc for a sealed type to list the exhausting subtypes specially.
Most of the time, that will just be the immediate subtypes of a sealed class, but we need to be sure we handle non-trivial cases too.
Do we need a concept of public exhaustive subtypes, separate from local exhaustive subtypes?
Example:
sealed class S {}
sealed class _Base extends S {}
class A implements S {}
class B extends _Base implements S {}
class C extends _Base implements S {}
Here the type _Base
and A
exhausts S
, but nobody outside of this library cares. They need to know that A
, B
and C
exhaust S
, and they never never need to hear about _Base
.
A minimal set of public subtypes which exhaust a sealed public supertype seems to be the most useful and actionable information for users. It might differ from the local types that can exhaust the sealed type (but should still be sufficient inside the declaring library.)
Which means that we may want to be able to compute the set of public subtypes for documentation purposes.
Non-trivial cases
It's all about mixins.
mixin _M {}
sealed class S {}
class A extends S with _M {}
class B extends S with _M {}
class C extends S {}
Here we'd expect S
to be exhausted by A
, B
and C
.
However, A
and B
are not "immediate subclasses" of S
, they are subclasses of C with _M
. (See #2830 for why C with _M
should be sealed
, in which case A
and B
exhaust each of their C with _M
s.)
@munificent Will _M
and C
exhaust S
locally, or will the algorithm only consider actual subtypes?
That case is "easy" because _M
is private, and maybe because M
is not a subtype of S
.
Example:
mixin M implements S {} // public
sealed class S {}
class A extends S with M {}
class B extends S with M {}
class C extends S {}
In this case, the exhausting subclasses need to be C
and M
, because M
is public and therefore someone, somewhere can create a new instantiable subclass of M
, which is a subclass of S
not declared in this library. An instance of such a class will be an S
and M
, but checking A
, B
and C
alone is not guaranteed to exhaust all values of type S
.
So M
needs to be checked in order to exhaust all possible values of type S
. And then A
and B
are no longer necessary to check, they're included in the necessary M
check.
That case was also clear because M
was a public, subclassable subtype of S
.
Example:
mixin _M implements S {} // private
sealed class S {}
class A extends S with _M {}
class B extends S with _M {}
class C extends S {}
In this case, the exhausting subclasses probably still need to be C
and _M
, because even though _M
is private, that doesn't prove that the type of _M
is not accessible elsewhere. There might be a typedef M = _M;
.
We can try to check whether the private type escapes in a way that makes it subclassable, but then we need to ensure that we are clever enough to detect all possible leaks. Possibly possible, but complicated. Let's assume that leak-detection is not viable, so being a private declaration is not helping us in any way.
Then nobody outside of this library can exhaust S
, which is a concerning consequence for using what looks like a very reasonable private implementation choice.
The issue is that we have an abstract private sub-classable subtype of S
which is not a subtype of any of the public subtypes, one we plan to ensure is only ever used in a concrete class which is a subtype of one of the public subtypes of S. That ensures that no instance of _M
will not also be an instance of one of A
, B
or C
.
We just can't easily prove that intent, and we can't specify it.
One approach is to use a mixin on
type instead of just implements
:
Example
mixin _M on S {} // `on` clause relation is special!
sealed class S {}
class A extends S with _M {}
class B extends S with _M {}
class C extends S {}
Again, we have the problem that _M
can be implemented, so if it leaks, A
, B
and C
are not exhaustive for S
, and we must assume that it can leak.
Then try:
base mixin _M on S {} // `on` clause relation is special!
sealed class S {}
class A extends S with _M {}
class B extends S with _M {}
class C extends S {}
Here we might have a case. There can be no concrete subclass of _M
which is not also a concrete subclass of another subclass of S
. Which means that any subtype of _M
is either a subtype of another subtype of S
, it it's a mixin application on S
itself, which means it's inside the same library as S
, in which that declaration's type can be used as part of the public exhausting set.
Do we need to treat non-implementable mixins on the sealed type specially in some way?
Here _M
is a subtype of S
, but we don't want to include it in the set of exhausting subtypes of _S
. We probably can get away with that because there can be no objects implementing _M
which do not also implement at least one other subclass of _S
, so _M
is not needed in a minimal set of exhausting subtypes.
So A
, B
and C
are enough to exhaust S
because they exhaust _M
, because there cannot be an _M
which is not one of A
, B
or C
(or a new subclass of S
which would then be included in the exhausting set).
We need to figure out precisely when that logic applies. A mixin being non-implementable (base
/final
/sealed
) and on S
may be sufficient.
(That also explains why it's not a problem that a mixin in a nother library has on S
for a sealed S
. That mixin cannot be implemented, and then the mixin in another library doesn't matter for exhaustability, because such a mixin doesn't matter in any library.)
That also explains why this example is sound:
library lib1;
import "lib2.dart";
sealed class S {}
class A extends S with M {}
class B extends S with M {}
class C extends S {}
and
library lib2;
import "lib1.dart";
base mixin M on S {}
class D extends C with M {}
The classes A
and B
implement M
, but M
is an ignorable mixin wrt. exhaustiveness, even in another library,
so S
is exhausted by A
, B
and C
, the closest (public) subtypes to S
declared in lib1
.
(Whether lib2
can use switch (s) {case C c: ...; case M m => ...}
to exhaust S
depends on what the algorithm does, but we won't document M
as one of the types to use, because it's not declared in the same library as S
.)
Proposal
Define the set of exhausting subtypes of a sealed type S in library L as the set, EXHAUST(S), of proper subtypes of S declared in L which are not non-implementable mixin
declarations with S
as on
type, and which
do not have a supertype which is itself in EXHAUST(S)
(Start with the set of all proper subtypes of S declared in L. Remove all unimplementable mixins with S as an on
type. Then remove all types which still have a supertype in the remaining set.)
If the exhaustiveness algorithm needs to find a a set of subtypes that exhausts a sealed type, this should be that set.
(If the algorithm works directly from declarations, it's still a set that the library author can use to exhaust the type.)
Then we define the public exhausting subtypes of a sealed S in library L, EXHAUSTpub(S) as the proper subtypes of S declared in L, P, s.t.:
- P ∈ EXHAUST(S) and
- P is public, or
- P is not sealed, or
- P is sealed and EXHAUSTpub(P) is empty.
- or there exists R in EXHAUST(S), R is private and sealed and P ∈ EXHAUSTpub(R);
If this set contains a private name, then the sealed type has a subtype which is private and is either not sealed, or has no public subtypes. In that case, that type cannot be exhausted from outside the library.
This is the set of types we should show in DartDoc. If it contains a private declaration, we should probably just say that the type cannot be exhausted without a wildcard pattern/default case, and list subtypes normally.
WDYT?
@leafpetersen @munificent @kallentu @eernstg @stereotype441 @natebosch @jakemac53