-
Notifications
You must be signed in to change notification settings - Fork 200
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
What keyword should we use for exhaustive class hierarchies? #2594
Comments
I'll put my two cents in for the keyword naming. When I first heard of the feature, I was introduced to it as However, the initial strawman calls it And with This is a shot in the dark and not that this keyword is better than what we already have, but why did we not use TLDR;; Why not |
One problem with sealed is that Dart already has I fear we're going to confuse our users (and ourselves) if "sealed" starts meaning something else. |
I added a counter-proposal here which uses |
I did a search of ~50k files in Pub packages, and I found 207 matches across 166 files, so it doesn't seem to be super widely used. If familiarity is the goal, we will probably get more mileage using
It's not transitive in Kotlin, Scala, or Java either. It would be more misleading if we used Honestly, I think most of what's going on here is that folks are feeling surprised that the using-sealed-subtypes-for-exhaustiveness feature is not transitive. I was surprised when I first realized the other languages work that way too. But that is how they work, and once I thought about it, I realized it makes sense. Once I got used to the idea, using |
I think this is quite misleading, at least WRT Kotlin, because all classes in Kotlin are not available for subclassing or implementation unless otherwise specified. So while it is true that the exhaustiveness checking part is not transitive (which I don't think anyone is proposing it should be), the sealed for subclassing part is of course transitive, simply because that's always the default. |
Yes, but that's orthogonal to exhaustiveness checking, so I'm not sure how it's relevant.
I thought that was exactly what @lrhn meant when he said "I think people will expect
I think transitivity is independent of the default. Kotlin defaults to non-extensible but that doesn't mean that the extensibility of a class is a transitive property of it. As I understand it, subclasses don't inherit the extensibility of their superclass. So if you extend a class marked I think maybe we're talking past each other? I intended this issue to be about the exhaustiveness-checking-thing, not the preventing-subclassing-thing. |
I don't know if we ever really thought about that. It is kind of long (and hard to spell), but you have a good point that it's pretty unambiguous. |
In your proposal, I don't think anyone is arguing (unless I'm very confused) that the enables exhaustiveness checking part should be transitive. The discussion is around whether the "no subtypes outside of the library" part should be transitive. And my point is that saying "no it shouldn't because Kotlin" is not a reasonable counter-argument. In Kotlin, "no subtypes outside of the library" is what you get, unless you explicitly choose otherwise, so there's no need to make it transitive - the behavior that @lrhn would prefer to be transitive is the behavior you already get (so it's trivially transitive).
I can't speak for him, but again, my interpretation is that he is speaking to the "no subtypes" bit, not the "enable exhaustiveness bit".
I don't think transitivity is the key point. The key point is that:
To be clear, I'm not sure I'm opposed to your approach. But I think the arguments that @lrhn is making here are perfectly well-formed, and I also think they reflect a real confusion we're going to run into. It is surprising to me that subclasses of |
Right. The way I'd describe it is The high level principle is to support what the user asks for soundly while being otherwise as permissive as possible (which I think is generally in line with Dart's ethos). I assume that same reasoning is how Kotlin, Java, and Scala also ended up allowing exhaustive subtypes to themselves be extended or implemented. There's no need to prohibit it for soundness, and if we interpret the user writing Of course, if we interpret |
I'm really, really baffled here. Kotlin does not do what you describe. It simply, flatly, does not. If you try to extend a subclass of a sealed class, you will get an error. You can, of course opt into the behavior you describe by post-hoc opening the otherwise non-extensible subclass, but it is flat out wrong to say that Kotlin is choosing to be as permissive as possible here. [Update] I looked into Scala and Java. Scala does do what you describe. Java does not - it requires you to explicitly choose a modifier for subclasses of sealed classes. |
Stepping back a bit, I'm broadly ok with this semantics. I don't think it's as clear to me that extensibility should be opt out rather than opt in as it is to you, but I'm basically fine with it. But I do have a lot of concerns about the choice of keyword here. The semantics you propose matches the Scala semantics, but they do not match Kotlin and Java, nor do they really line up with the use of sealed in C#. I really have a lot of concerns about the potential for user confusion here. |
In looking over this, I feel like perhaps where we're talking past each other is on the question of "what is possible to do" vs "what are the defaults"? So let me try to lay this out as precisely as possible. I will use the term "enumerated family of types" for a set of types which is known to be the complete set of subtypes of a single supertype. The first question is, should it be possible to have a an enumerated family of types, for which members of the family themselves have subtypes (outside of the current library/package). The answer to this, if we go by other related languages, is a resounding yes as far as I can tell. Kotlin, Java, and Scala all provide some affordance for doing this. I can't speak for @lrhn , but I personally have no concerns whatsoever with providing this affordance. The second question is, what should be the default behavior when you try to add a subtype of a member of a family of enumerated types. On this question (which I have been assuming was the fundamental question that we were asking, but perhaps this is where the confusion arises?) there is no consensus among the languages under consideration. Specifically, here is my understanding, in table form (using "EF" as a short form for "Enumerated Families", and "FM" as a short form for "Family Member", meaning one of the enumerated subtypes of the base type of the enumerated family).
My take away from this is that WRT defaults, there's no clear signal from other languages unless we weight some languages more heavily than others (which, given the relative usage of Java + Kotlin, perhaps we should?). All of the languages use the keyword This is a troublesome chart for me.
The question I am struggling with is, how likely is it that the casual reader of code will draw the right conclusions. In the Kotlin case, I'm fairly confident that they will guess correctly. If they see In the Java case, likewise, the user will definitely guess correctly (at the expense of verbosity). The subclass will just tell them "I am not sealed" or "I am final". The Scala (and proposed Dart) case seems to me the most likely to lead the user astray. They see There is a somewhat separate issue from the choice of syntax, which is whether or not Dart/Scala is correct in choosing to make "extensible" the right default for the FM or not. You seem to feel fairly strongly that it is based on the general permissiveness of Dart, which I hear - it's a valid "style" point. I am a bit surprised by this though, given the empirical data you have from corpus analysis is that the vast, vast majority of classes are not extended or implemented. I think it's worth thinking hard about whether "extensible with opt out" (the Scala approach) is really the correct default over "non-extensible with opt-in" (the Kotlin approach). |
I really appreciate your comment. I think I understand what you're saying better now.
Yes, but that has nothing to do with It may be that in terms of user experience they designed
Maybe, but another way we could write that table is:
As far I can tell, except for Java, other languages don't change a class's extensibility from what it would be if you hadn't put
Yes, but when we asked (a relatively small, biased sample of) users, they generally preferred Dart's permissive defaults in spite of that. And we have ~a decade of empirical evidence that it can't be that bad of a default because flipping it has never risen high enough in priority relative to other language features to make us doing anything about it. This is in contrast to other flips we have done like a sound type system, implicit downcasts, and nullability where we did flip the default (with all of the painful migration that entails) because it was clear from users that the default was wrong. Permissive extensibility and implementability seems to be a pretty good default for Dart, at least when exhaustiveness checking is not involved. It may be that once a class is sealed then we should flip the default at that point for everything under that type on the hierarchy. I'll discuss that more over on #2595. |
Yes, this is a reasonable point, but presenting the table that way disguises my basic concern with the choice of keyword here, which is that for Kotlin, the general default for extensibility lines up with the common intuition of what sealed means. For Scala, Java and Dart, the general default for extensibility does not line up with the common intuition of what sealed means. Hence, I suspect, Java's choice to make the user be explicit. And hence my thought that perhaps we should consider a different keyword, so that the cognitive dissonance between the implication of the keyword and the actual semantics. Again, I'm not strongly opposed to using the |
Yes, that's fair. I can see how So, uh... How about |
Yes, this is true, and one of the reasons that I hesitate here. If you're coming from C#, you're going to be confused no matter what. And if you're coming from Kotlin/Java/Scala, then we're trading off confusion of learning a new keyword against possible confusion around the semantics of the subclasses. Maybe it's better to leverage the familiarity of the keyword? Another point is that it's probably not the end of the world if users do get confused. Most users probably don't care. And if they do actually care, well, then they need to learn to mark each subclass as
I think I prefer |
What do you think about variant class Option<S>;
class Some<S> implements Option<S> {
S value;
Some(this.value);
}
class None implements Option<Never> {
const None._();
factory None() => const None._();
} |
"Variant" isn't doing anything for me. It feels like it emphasizes the wrong thing. Every type can have varying subtypes in Dart. The special thing about these ones is that they vary less than other ones. |
I don't see any advantage to add the "closed" or "final" class modifier to close a FM. The reasons why someone wants to implement a class may not be known beforehand. |
Closing this issue since we've pretty well settled on |
As part of pattern matching, we want to be able to mark a class or mixin as having a known exhaustive set of direct subtypes. That way, if you match all of those subtypes, you know the supertype is covered. We've gone back and forth over the keyword for this. My initial pitch was
switch
. In the more recent PR, I proposedsealed
.For reference, here's what some other languages do:
In C#,
sealed
on a class means the class can't be extended. (It's likefinal
in Java.) C# has no notion of exhaustive subtypes.In Java, a class or interface can be marked
sealed
and then there is a separatepermits
clause that lists the allowed subtypes, which are then used for exhaustiveness. The subtypes of asealed
type must themselves be marked eithersealed
ornon-sealed
to indicate whether they allow further extension.Kotlin uses
sealed
to mark a class as having a known set of subtypes for exhaustiveness.Scala also uses
sealed
for the same purpose.TypeScript doesn't have any notion of exhaustiveness checking right now.
Swift uses separate enum types for exhaustiveness and doesn't build it on top of subtyping.
So, basically, except for C#, every language that uses
sealed
uses it to mean what we would have it mean for Dart: That the type has a known set of subtypes used for exhaustiveness checking.As @mit-mit has pointed out, even C#'s use is not too far off, because a type marked
sealed
in Dart is also prevented from being extended (or implemented) outside of the library where it's defined.So, while I think
switch
is a neat idea, I believesealed
will be easiest for users to learn and understand. But I'm filing this issue so we can discuss it more.The text was updated successfully, but these errors were encountered: