-
Notifications
You must be signed in to change notification settings - Fork 205
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
Allow subclassing a sealed class outside the library with an abstract class #3135
Comments
Couldn't SealedImpl be made a mixin such that the only real subclasses in the implementation file are AImpl/BImpl? Such that we'd have: // foo.dart
sealed class Sealed {
int get offset;
}
abstract class A implements Sealed {
DartType get type;
}
abstract class B implements Sealed {}
// foo_impl.dart
mixin _SealedMixin { // Not using "on" clause as it's not allowed to do "on SealedClass"
// We duplicate the properties we care about in the mixin
int get offset;
// The mixin can use the content of the sealed class
void someUtils() => print(offset);
}
class _AImpl extends A with _SealedMixin {
@override
final int offset;
@override
final DartType type;
}
class _BImpl extends B with _SealedMixin {
@override
final int offset;
} There's a bit of duplication, so this is not perfect. But that would enable to use of the sealed keyword without any language modification or relying on parts. |
I think it would make sense to enhance the terminology a bit in this area: Assume that a library L defines a maximal set of classes In the example here, the intended sealed hierarchy is just the single class If the sealed hierarchy is indeed just one class then we should be able to create an // lib/foo.dart
sealed class Sealed {}
abstract class A implements Sealed {}
abstract class B implements Sealed {}
// lib/src/foo.dart <-- implementation private!
abstract class SealedImpl {} // Could be a mixin, as @rrousselGit mentioned.
class AImpl extends SealedImpl implements A {}
class BImpl extends SealedImpl implements B {}
In other words, there is no problem as long as the sealed hierarchy is a single class. So let's assume that the sealed hierarchy is (at least) // lib/foo.dart
sealed class Sealed {}
sealed class A implements Sealed {}
sealed class B implements Sealed {}
class A1 implements A {}
class A2 implements A {}
class B1 implements B {}
class B2 implements B {}
// lib/src/foo.dart <-- implementation private!
// Create a shadow of the sealed hierarchy. We don't get to declare subtype relationships,
// because that's an error, but we do provide implementations of various members.
abstract class SealedImpl /*would implement Sealed*/ {}
abstract class AImpl extends SealedImpl /*would implement A*/ {}
abstract class BImpl extends SealedImpl /*would implement B*/ {}
// Just below the sealed hierarchy we can have concrete classes,
// and at this point they can have the "correct" type.
class A1Impl extends AImpl implements A1 {}
class A2Impl extends AImpl implements A2 {}
class B1Impl extends BImpl implements B1 {}
class B2Impl extends BImpl implements B2 {} It's inconvenient that we cannot declare the subtype relationships from It may not be pretty, but I do think that it would work. |
Sorry, I believe it is useful (one might say imperative) to have // lib/foo.dart
abstract class Sealed {
Iterable<SyntacticEntity> get childEntities;
}
// lib/src/foo.dart <-- implementation private!
abstract class SealedImpl implements Sealed {
@override
Iterable<SyntacticEntity> get childEntities => _childEntities.syntacticEntities;
} The WRT to my proposed subclassing rules, would they be technically difficult? Hinging on finding the sealed family when only given |
@srawlins wrote:
Indeed, but those checks will be performed anyway when However, it gets much more interesting!:
I think I understand it now (but a couple of things were confusing along the way). Here is the rule:
I think the second rule ought to be more flexible (it's much stricter than the current rules because it requires, e.g., that concrete subtypes of a sealed type must be direct subclasses of the direct subtypes of the sealed class, IIUC). Anyway, the fundamental idea, as I understand it, is that we allow the same out-of-library subtypes of a sealed class as we do today, and then we also allow some extra out-of-library abstract direct subtypes of the sealed class. Then we add an extra constraint, in order to ensure that soundness isn't violated. The point is that no actual object will ever have one of those new abstract types without also having one of the types that we're currently considering to be the exhaustive set (that is, the direct subtypes of the sealed class The current rule in the feature specification is as follows:
We would replace that rule by this one:
A concrete declaration is a class or mixin class declaration which isn't abstract. We'd need to scrutinize this rule a lot, of course, and it is not obvious to me how much more work it is to check this rule in tools, compared to the current rule (which is O(numberOfSuperinterfaces)). But I do think that it would work! |
(or enum declaration.) This basically boils down to:
That's a very direct specification of the requirement necessary for ensuring exhaustiveness, so it pretty certainly would work. Every other rule we've made is trying to enforce that restriction by disallowing some patterns (and preferably in a way where it's directly visible on a class declaration which constraints it imposes on its subclasses). So when we say that you cannot subclass a (And then, if any of those immediate subclasses are themselves sealed, we may want to find the non-sealed frontier below the sealed classes. And maybe omit We could do that, it would be sound for exhaustiveness, but it causes a lower direct readability of the classes. (It would actually mean that declaring an abstract subclass in a separate library gives you an ability you don't have in the same library, that of creating an immediate (abstract) subclass which doesn't count towards exhaustiveness. Maybe we should introduce a way to get that without needing to be in a different library: A This would also cover one of my original pet ideas: allowing a mixin in another library to have the sealed type as on type,. Then it had to be |
This suggestion makes a lot of sense. Like Lasse says, I think we could do it. At the same time, the base transitivity stuff already in class modifiers ended up becoming a spiral of complexity that really got out of hand, and we're already hearing worry from users about the increasing complexity of the language. I'm excited about all of the new features and I know a lot of users are too but... oof... the language is getting big.
Is that really so valuable? You'll already get that checking implicitly inside all of the subtypes of |
FWIW, I don't want to make any API classes sealed. I find it very useful to use So, I appreciate local type safety. |
Thanks a lot for all of the input! I like that it seems sound. I don't know of other cases that use our style of public-interface and private-impl (I mean, it's classic, I just don't think I've seen it outside our code base in a Dart package). |
@scheglov You mean that you don't want As an avid user of analyzer, it sounds very useful. I already wrote a bunch of analyzer |
Adding a new subtype is then breaking, which means even more breaking versions of analyzer. But you could view that as a plus as well - you will know exactly the places in your code that need to update and which cases need to be handled. (you could also I think make a reasonable claim that it is breaking either way, but one will make code not compile at all while the other might be ok for a lot of code, and only code using new features etc would be broken). |
It depends on the kind of "breaking". Because that's the same kind of breaking as when adding methods to an implementable class. I view adding new sealed types the same way. In that sense, I'm arguing that someone writing an exhaustive switch voluntarily made a choice similar to that of implementing a class. They are aware that their code will need updating when a new type is added. And if they don't want this, they can opt out by making a nonexhaustive switch (using |
Actually, we do consider that to be a breaking change, which I why the documentation states:
|
Yes, I agree, that adding a method to So, there might be an argument for using sealed classes, and implementing But I'm curious about your specific cases that you noticed for exhaustive switches. |
By that standard, then pretty much everything is a breaking change. Semantic versioning makes no sense with that considered. |
I've found myself quite often needing to iterable over all the possible subclasses of a given node. I generally click on the definition of a given type. It contains a list of all the possible subtypes. And I switch over them all. It's exactly the sort of things why |
Agreed, @rrousselGit, breakage of the form "you need to add a case for |
Sure, I understand |
I'll admit I don't understand your question and your usage of But anyway, for me, the Ast/Element tree is the textbook definition of what sealed classes are for. |
The reason for that could be that Dart doesn't need it. In Java, you'll want to define an interface, and an implementation of that interface, because otherwise you don't have an interface at all. The only real reason to have a public interface and private implementation is to have public members in the implementation, which are not part of the interface, and consider them "pseudo hidden" that way. It's just rarely done, because you can just make private members instead, and not worry about them getting exposed at all. |
Yep. But in our case we want those "pseudo hidden" APIs to be usable outside the library, but within our package. |
If we merge interface and implementation classes, we would start using |
Hm... Looking at it some more. sealed class ExtendsClause implements AstNode { ... }
final class ExtendsClauseImpl extends AstNodeImpl implements ExtendsClause { ... }
Currently, in the analyzer resolution visitors we have to rely on |
This issue with this approach, is that all your users get broken when a new type is added and they But we don't have any good linting to ensure people use this type of constraint today, or even good patterns established and agreed upon. |
Fwiw, I would love it if pub had a diagnostic that would warn if you don't constrain yourself to minor release ranges, whenever you do a variety of things in this category, such as:
Or possibly this should actually be an analyzer lint? We do have some similar-ish lints. |
+1 on the lint. I've used tigher version constraints before. I voluntarily "implement" AstVisitor on purpose in I think a lint rule and considering adding new sealed subtypes as minor bumps would be ideal. I find it very counter productive if package authors voluntarily avoid using |
Why a minor version bump? |
Whether a change is breaking, in the semver perspective, it's not necessarily as simple as "can any code possibly stop working?" It's more like "will all intended uses keep working?", where the critical point is how well you've signaled which uses are intended. Breaking an unintended, and therefore unsupported, use is not your problem. Changing the API of a class that was never intended to be implemented or extended may break someone doing it anyway, but it's not semver-breaking if it doesn't break the supported API usage that's actually being versioned. The publisher decides what that is. (The rest is a documentation problem.) In practice it's rarely as clear cut, not if your part of a large organization that tells you who you can break out not, but it is a perspective that a single, third-party developer can choose to take with their package. That is, "de facto breaking" is only something you should care about if you've promised not to cause any possible breaking, and at that point it's actually "de jura" breaking too. |
When we make a class |
The reason is that depending on the use case, it might not be breaking for most users. Every time there is a new version of a package, it causes churn for all packages that depend on that package, which is a maintenance burden for package authors. So we want to strike a good balance between safety and breaking changes. This is why most classes today don't consider adding a new member to be a breaking change, even though it is potentially breaking (if anybody is implementing your class). Or adding an optional named parameter to a method (breaking if somebody is implementing or extending and overriding that method). Semantic versioning is just a tool, the point of which is to avoid breaking people. I don't think we should get too caught up in trying to be technically "correct" with our semantic versioning. We should use it in the way that best benefits our users. Because that is ultimately the point. From my perspective, it would at least be interesting to consider a version of semver, where these specific types of breakages are done only as minor version bumps, and the smaller subset of packages that do these particular things are encouraged to have a constraint based on the next minor version instead of major version. This does cause more churn for the package with that dependency, they have to update their dependency for each minor version, but it means the rest of the things that depend on that package in a more "typical" way, do not have as much churn. I do think it is a bit unfortunate to treat switching on a sealed type the same way as implementing a class from another package, but ultimately I think its very likely that people who own sealed types will add more of them, and accidentally not do breaking change releases when they do. |
Heck, even for global functions, that's breaking too if you consider things like type inference or insert mandatory xkcd reference In the end, I think it's about what makes the most sense for users. If semantic versioning is going to prevent packages from using Ultimately, with exhaustive pattern matching, the key point for me is that it's a conscious decision made by the package users. Folks have the choice to write either: switch (Base()) {
case Impl1(): print('');
case Impl2(): print('');
} Or: switch (Base()) {
case Impl1(): print('');
case _: print('');
} Folks who opted for the exhaustive check did so on purpose. They want the "breaking change". If packages refuse to offer exhaustive checks for the sake of versioning, what's the point of having the |
Bug: dart-lang/language#3135 Change-Id: Icbc2a4e506e9e58c830b53f7aecce92189bb2d1b Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/308402 Commit-Queue: Konstantin Shcheglov <scheglov@google.com> Reviewed-by: Brian Wilkerson <brianwilkerson@google.com> Reviewed-by: Samuel Rawlins <srawlins@google.com>
Just passing by to say thanks for making various Ast classes "sealed" ❤️ |
In the analyzer package we have a lot of sealed-in-spirit class hierarchies. The class declarations are found in two files, two libraries, one contains the public interfaces, and the other contains the private impls. Something like this:
(I hope you can trust that the hierarchy serves a purpose; i.e. SealedImpl has some implementation, etc.)
I think we would reap a lot of exhaustiveness benefit from sealing
Sealed
. But today we cannot changeSealed
to besealed
because of that peskySealedImpl
in the private impl library. And we cannot moveSealedImpl
because (a) it has implementation, (b) it is not public API, and (c) that would remove any exhaustiveness benefits; it would be confusing to have to cover A, B, andSealedImpl
in any exhaustive switch.This is frustrating: I can see that each concrete subclass of
Sealed
in fact is a subtype ofA
or ofB
. The family of classes is sealed in practice. How to prove it to static analysis?It occurs to me that I believe we can allow subclassing
sealed class Sealed
outside of its library by an abstract class. Today we have static checks that amount to the blanket rule:I think we can alter that rule to be more nuanced:
sealed
orabstract
), we do not have a compile-time error.I think this maintains nicely the semantics of the sealed family (both in compile-time enforcement and conceptually):
In the
Sealed
hierarchy above, you can see maybe an exhaustiveness wrinkle: A developer might still useSealedImpl
in a switch case and 🤷 maybe it is useful to do so, but such a case would... not contribute to exhaustiveness?SealedImpl
is not part of the sealed family ofSealed
, so maybe no wrinkle.Mixins cannot be abstract so I think they're not relevant to this proposal.
The text was updated successfully, but these errors were encountered: