-
Notifications
You must be signed in to change notification settings - Fork 213
Listing the sub-types that exhaust a sealed type #2832
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
I'd prefer an approach which uses the graph properties strictly and ignores the distinction between private and non-private declarations. We would then rely on propagation in order to confirm that any particular set of types will exhaust a given sealed type. For example: // Introduce exhaustiveness.
sealed class A {}
// Unrelated stuff, allowed but makes no difference.
mixin UM {}
base mixin _UM {}
// A is exhausted by `{B1, B2, _B3}`.
final class B1 extends A with UM, _UM {}
class B2 implements A {}
sealed mixin _B3 on A {} // Propagate exhaustiveness.
// _B3 is exhausted by `{C1, C2}`.
base class C1 implements B1, _B3 {}
interface class C2 extends B2 with _B3 {}
The point is that we're only using the notion of 'direct declared superinterface' to detect that the direct subinterfaces of So we don't have a complex EXHAUST function, we just check the superinterface graph and walk down as far as possible from each sealed class to a predecessor whose path back to the sealed class has exactly one edge that counts: That's the exhaustive set. graph BT;
C1[base C1] -.-> B1[final B1]
C1 -.-> _B3
B1 --x AUM_UM[A & UM & _UM]
AUM_UM --> _UM([base _UM])
AUM_UM --x AUM[A & UM]
AUM --> A[sealed A]
AUM -.-> UM([UM])
C2[interface C2] --x B2_B3
B2_B3[B2 & _B3] --> B2
B2_B3 -.-> _B3([sealed _B3])
B2 -.-> A
_B3 -.-> A
It is then up to various tools (e.g., DartDoc) to use this basic notion of exhaustive sets to compute a set which is exhaustive based on propagation, e.g., |
Using "direct declared superinterface" risks depending on syntactic accidents. sealed class S {}
class A extends S {}
class B extends A implements S {} Here both So maybe restrict the levels to to class having That would discount It also brings us back to sealed class S {}
base mixin _M on S {}
class A extends S with _M {}
class B extends S {} Here |
sealed class S {}
class A extends S {}
class B extends A implements S {} Ah, good catch! The algorithm I mentioned will produce a set of types which is (easily provably) exhausting the sealed type by means of direct declared subinterfaces, but it is possible that some types in that set are related (like We need to perform this reduction after each sealedness propagation operation: In my example here, I think this reduction step is needed, because we probably won't have any graph-shape based rule which will tell us from first principles to remove Considering the other example:
sealed class S {}
base mixin _M on S {}
base class A extends S with _M {}
class B extends S {} The 'direct declared' criterion would say that We could consider "auto sealing" every non-leaking private type. However, I'd actually prefer the more explicit approach where we don't compute the sealedness property, we always require an explicit |
It's the latter. A non-implementable mixin with If the mixin can be implemented ( |
A similar argument would be applicable for any abstract class, but we probably wouldn't force developers to use [Edit:] Ah, it isn't necessarily another subtype of Also, what's going to stop |
Mixins are unique in that they cannot create objects themselves, and cannot be simply subtyped into something which can create objects, at least not if they can't be implemented. Nothing stops the same library from doing (If we had an abstract class which could never be subclassed into something that can create values, we could also ignore it from exhaustion checks. It's just not a particularly useful thing to have, so we don't have language support for declaring or detecting that. But it is why we can ignore the valid subtype |
I just don't see a good reason why we should ignore a pseudo-non-implementable mixin when we're computing exhaustive sets of types for a given sealed type. sealed class S {}
// "Normal" cases.
class B1 extends S {}
class B2 implements S {}
// We actually want clients to use this as a type.
base mixin NiceInterface on S {}
// `NiceInterface` has a bunch of implementations. Clients shouldn't care which one they got.
base class Impl1 implements NiceInterface {}
base class Impl2 extends B1 with NiceInterface {} // In some cases we also want the methods.
...
base class ImplN implements NiceInterface {} I don't see why it should be impossible to recognize that How does it help anybody if we're trying really really hard to eliminate that mixin from the exhaustive set? |
The situation I want to avoid is having: sealed class S {}
// "Normal" cases.
class B1 extends S {}
class B2 implements S {}
base mixin NiceInterface on S {}
class C extends B2 with NiceInterface {} and somehow end up thinking that you need to exhaust I don't know (and don't understand) how the exhaustiveness checking algorithm actually works, but I think it needs to decide, somehow, which subspaces of a sealed type needs to be exhausted in order to exhaust the type itself. |
It's true that the reduction step that I mentioned (delete every type from the exhaustive set which is a subtype of some other type in the set) is needed in order to reduce However, the algorithm I proposed won't create that set, it will create However, if you have a specific reason why you want to eliminate (But if
I don't think it needs to do that in a general sense, I think it's fine if it can determine whether or not any given set of types is exhaustive. The algorithm must definitely be able to recognize that This can be done (assuming the algorithm I mentioned) by (1) noting that the static type of the matched object is a sealed type I don't see a need for this algorithm to try to avoid any particular nodes in the graph (mixin or not, implementable or not, private or not). This would simply introduce complexity that doesn't help anyone. A quite different task is documentation: Telling developers what they need to include when they want to switch over a given sealed type. In this case it does make sense to avoid private classes and perhaps some others, but that's a pragmatic matter, and it is possible that we will rely on human judgment in order to determine what the best exhaustive set is, based on the superinterface graph but also on the meaning and purpose of each of those classes. In other words, it might be a task which is best done manually in some non-trivial situations to specify in the DartDoc of |
I think you may be overthinking this, which is my fault because the proposal is totally silent on this question (oops) and the exhaustiveness prototype sidesteps it by only modeling a simplified abstract subset of the type system. The exhaustiveness checker does not need a set of exhaustive subtypes that are disjoint. It is totally fine with them overlapping in various ways and (I believe!) the algorithm will sort it all out correctly, automagically. So I don't think we need any notion of a "minimal" set of exhaustive types. Looking at Erik's example: // Introduce exhaustiveness.
sealed class A {}
// Unrelated stuff, allowed but makes no difference.
mixin UM {}
base mixin _UM {}
// A is exhausted by `{B1, B2, _B3}`.
final class B1 extends A with UM, _UM {}
class B2 implements A {}
sealed mixin _B3 on A {} // Propagate exhaustiveness.
// _B3 is exhausted by `{C1, C2}`.
base class C1 implements B1, _B3 {}
interface class C2 extends B2 with _B3 {} As Lasse suggests, I think we want to ignore mixin applications when considering the direct subtypes of a sealed type. So the hierarchy of subtypes as far as sealed is concerned would be (here parentheses mean "is marked
The sealed subtypes of We don't need to flatten that graph down so that the subtypes of Another example: sealed class S {}
class A extends S {}
class B extends A implements S {} Here, the sealed hierarchy is:
So you have to cover both It works sort of like this. Say you have a switch like: S s = ...
switch (s) {
case A _: ...
} Because the matched value type is a sealed type, it essentially expands it out into two separate switches for each subtype: S s = ...
switch (s as A) { // Narrow the matched value type.
case A _: ...
}
switch (s as B) { // Narrow the matched value type.
case A _: ...
} It knows, because So, in general, overlapping hierarchies shouldn't cause any problems. In fact, the prototype has tests for funny things like: // (A)
// / \
// (B) (C)
// / \ / \
// D E F
sealed class A {}
sealed class B implements A {}
sealed class C implements A {}
class D implements B {}
class E implements B, C {} // <-- !
class F implements C {} And it understands that these switches are all exhaustive: A a = ...
// Easy.
switch (a) {
case B _:
case C _:
}
// Medium.
switch (a) {
case D _:
case E _:
case F _:
}
// Hard!
switch (a) {
case B _:
case F _:
}
We will have it soon: abstract final class C {}
The two algorithms handle it a little differently. But the basic way to think about it is when a case matches only a subtype of the matched value type, it's not really helpful. If you have: class A {} // Not sealed.
class B extends A {}
class C extends A {}
switch (A()) {
case B _: ...
case C _: ...
} Those cases match some stuff, probably, but as far as exhaustiveness checking is concerned, they're useless. Cases that may match don't help prove exhaustiveness. Only cases that must match are useful, because they prove that no value can escape it. (That's also why cases with guards don't help with exhaustiveness.) What sealing does is let you bifurcate the matched value's type into a set of subtypes—that may or may not be disjoint!—and then try them independently. That's useful because (and only because) when the matched value's type gets narrower, it means you're more likely to have a case whose type is a supertype of the matched value. Those are the case types that are useful because a case whose type is a supertype of the matched value always matches. In the previous example, if we mark switch (A() as B) {
case B _: ...
case C _: ...
}
switch (A() as C) {
case B _: ...
case C _: ...
} And now we know that the first case must match in the first switch and the second case much match in the second switch. Therefore, the original switch is exhaustive too. It took me a long time to wrap my head around this, because functional languages don't have subtyping so exhaustiveness checking is always disjoint. Therefore cases either always match or never match. Subtyping means you need to think about cases that may or must match. And it's only the latter that are useful. I didn't follow everything in the comments about private |
Right. We get the same effect with the rule that I mentioned. In short, in a given superinterface graph containing a sealed class/mixin In particular, a mixin application is never included in the set of direct subinterfaces of any denotable class, because a mixin application is never a leaf node in the graph, and every predecessor of a mixin application We can't outright ignore mixin applications in the graph, because they introduce subtyping relations. But it is probably possible to cluster some nodes. For example,
That's great! This is exactly the kind of approach I'd prefer: No exceptions, no unnecessary complexity. And we have no problems detecting that |
For the record: We want to ignore anonymous mixin applications. That's most of them, but we cannot ignore: mixin M {}
sealed class S {}
class A = S with M; Here (Named mixin applications. There are dozens of them!) |
Right, I'd treat class S {}
mixin M {}
class A1 = S with M;
class A2 = S with M;
void main() {
// A1 a1 = A2(); // Compile-time error.
print(identical(A1, A2)); // 'false'.
} So |
If we're going to nitpick (and by golly, I will!), the class (And it is possible to tell the difference in a few corner cases.) |
Tangential:
I can honestly never remember what they are. Can someone remind me why we even have the |
I can't say why we added the syntax The underlying idea of a mixin is that it describes the implementation difference between a superclass and a subclass. I'd use It's convenient to have the basic mixin-application operation in the language. That means that we can tell people that class _$S_M1 = S with M1;
class _$S_M1_M2 = $S_M1 with M2;
class C extends _$S_M1_M2 with { ... } Having a fundamental operation that you can't express directly is always annoying. The case where you can really tell the difference is: mixin _M { ... something ..., no fields. }
class C = Object with _M; Here There was no other way to create a declaration that could be used as a superclass, a mixin, and have a In Dart 3.0, you'll just write |
I've updated the spec for exhaustiveness (#2948) and it now defines how sealed types are expanded. Given that, I think this can be closed since there's no other work to do. Feel free to re-open, though, if you think the spec should be changed. |
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:
Here the type
_Base
andA
exhaustsS
, but nobody outside of this library cares. They need to know thatA
,B
andC
exhaustS
, 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.
Here we'd expect
S
to be exhausted byA
,B
andC
.However,
A
andB
are not "immediate subclasses" ofS
, they are subclasses ofC with _M
. (See #2830 for whyC with _M
should besealed
, in which caseA
andB
exhaust each of theirC with _M
s.)@munificent Will
_M
andC
exhaustS
locally, or will the algorithm only consider actual subtypes?That case is "easy" because
_M
is private, and maybe becauseM
is not a subtype ofS
.Example:
In this case, the exhausting subclasses need to be
C
andM
, becauseM
is public and therefore someone, somewhere can create a new instantiable subclass ofM
, which is a subclass ofS
not declared in this library. An instance of such a class will be anS
andM
, but checkingA
,B
andC
alone is not guaranteed to exhaust all values of typeS
.So
M
needs to be checked in order to exhaust all possible values of typeS
. And thenA
andB
are no longer necessary to check, they're included in the necessaryM
check.That case was also clear because
M
was a public, subclassable subtype ofS
.Example:
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 atypedef 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 ofA
,B
orC
.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 justimplements
:Example
Again, we have the problem that
_M
can be implemented, so if it leaks,A
,B
andC
are not exhaustive forS
, and we must assume that it can leak.Then try:
Here we might have a case. There can be no concrete subclass of
_M
which is not also a concrete subclass of another subclass ofS
. Which means that any subtype of_M
is either a subtype of another subtype ofS
, it it's a mixin application onS
itself, which means it's inside the same library asS
, 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 ofS
, 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
andC
are enough to exhaustS
because they exhaust_M
, because there cannot be an_M
which is not one ofA
,B
orC
(or a new subclass ofS
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
) andon 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 sealedS
. 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:
and
The classes
A
andB
implementM
, butM
is an ignorable mixin wrt. exhaustiveness, even in another library,so
S
is exhausted byA
,B
andC
, the closest (public) subtypes toS
declared inlib1
.(Whether
lib2
can useswitch (s) {case C c: ...; case M m => ...}
to exhaustS
depends on what the algorithm does, but we won't documentM
as one of the types to use, because it's not declared in the same library asS
.)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 withS
ason
type, and whichdo 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.:
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
The text was updated successfully, but these errors were encountered: