-
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
Non-interface class declarations. #704
Comments
Looks good! At first it seems a bit confusing use But I agree that the ability to control the affordances of a class Availability of private members could be enforced with a library scoped So we should consider whether |
This is something that could be managed at the annotation level, through analyzer warnings in the same way Flutter does it for example with immutable classes. As a Dart developer I would not like to come across a class constrained like that, since it eliminates the possibility of being able to modify a behavior after carefully considering how the internals work. OO programming attempts to achieve interface abstraction, which could be understood as “pass me an object with this interface and I will do the job”. Practice proves this abstraction does not satisfy all the use cases as sometimes it is required that an object not only has a certain interface, but the invocation of methods from the interface have side effects that interact with internals of a library or framework. In those cases it is required that the received object extends the object declaring the interface from the library itself. As well as extending sometimes it can be required that the values returned by methods from the extended class are exactly the ones returned by methods from the inherited class, something that could be expressed with a Although annotations do not gives guarantees, I prefer much more the annotation approach over the “code constraining” approach, leaving the objects and interfaces versatility intact. Annotations pass the responsibility to the user to follow their directions for correct functioning. By doing so, the liberty to adapt the classes is still there in case users explored the internals of the library and had a clear understanding of what they are doing. As an example Flutter marks some classes as @ZakTaccardi is this what you were wishing for? From your proposal I see three things:
Number 1 is essentially a class with constant static values of different types, where it is wished that all the static values types could be represented by the enclosing class. That could implemented as some kind of complex enum type where the constant values of the enum are unique for each object (they could have different classes). In number 2 if annotations are used the default switch statement could also be avoided, because by using the classes in the way it’s expressed by the annotations, the behavior should be the expected one. Unexpected behavior could only occur if the annotations warnings are overlooked. Annotations perfectly satisfy the example you give, but it has the advantage that the freedom to modify something is still there. The third is a regular switch statement with an automatic return (there is an open issue). |
I see this is an old issue, but I got here from the modules proposal. I agree with @icatalud: Dart should not focus on limiting devs, rather it should warn them when they may be making a mistake, while still allowing them to continue. The most common reason I come across "non-implementable" classes is "adding a new member would be a breaking change". While I can sympathize with that, we should take a step back and remember who we're trying to protect. Avoiding breaking changes means we acknowledge that using Instead, as is pointed out in the link above, most devs will write comments about what is intended and what's not, and then only focus on the "supported" breaking changes. This comment can be replaced with an annotation that warns users when they try to do something the author specifically doesn't support. But saying "the author didn't intend it" == "the API doesn't support it" is not always true, and therefore IMO Dart shouldn't be limiting devs in their (creative) use of APIs. |
In software as in life, there's no perfect solution to liberty. When we give package consumers some liberty, we take agency away from package maintainers, and vice versa. Dart has historically leaned very heavily towards consumers of APIs being able to do what they want, but that comes with a real cost both for package maintainers and package consumers.
Sure, but whenever you say "package maintainers should take great care when doing ___", you're tacitly saying "I am OK with lowering their productivity". But package consumers also want new features and bug fixes in the packages they consume! Whenever we tie the hands of package maintainers, it means fewer features and bug fixes and slower improvement. That also harms package consumers.
Sure. But as anyone who maintains a popular package will tell you, users do ignore those comments and then turn around and get angry with the package maintainer when they do something unsupported and then a new version of the package breaks their code. In practice, Dart's flexibility here puts a lot of hidden back-pressure on maintainers which makes it harder to improve their APIs.
I think it's really important to keep in mind that you have the source. Dart's package management system is entirely based on distributing packages as raw source code, and pub's |
Right, but if we make it so that users can't do what they're doing (as opposed to simply breaking it), those angry users won't just go away. I'm not denying the reality of users begging package maintainers for every last edge-case to be supported, but rather pointing out that they'll likely be twice as upset if Dart removes some of the functionality entirely. Being able to extend or implement any class you find can open up more possibilities for consumers, and I think it would be a shame if non- // a.dart
import "package:meta/meta.dart";
@sealed
class A { } then extend it in another package B: // b.dart
import "package:a/a.dart";
class B extends A { } Dart gives me the following lint:
Now, I can choose to ignore it by adding an // b.dart
import "package:a/a.dart";
class B extends A { } // ignore: subtype_of_sealed_class Or I can set it to be an error/warning instead of just a lint: # B/analysis_options.yaml
analyzer:
errors:
subtype_of_sealed_class: error The point is that the author of A was able to say "don't extend or implement |
There are things you can do to improved performance with enforced restrictions, that you can't without them, or if they are just guidelines. That's another reason for wanting strict restrictions. |
Yeah, this is a good point. However, I do believe that most users using classes in non-intended ways aren't doing so in spite of the package maintainer's preference, they're doing it because they simply don't know whether or not the package is intended to be used that way since it's hard for package authors to communicate that intent. If a package seals a class, I think most users of that class who try to extend will instead find another path to their solution. I think it's fairly rare that not being able to extend and/or implement a class prevents a user from solving their problem.
I agree warnings can be nice to prevent users from being totally stuck. However, in this case, we are hoping to use these access control restrictions for things like type safety, which makes it more important that they be non-optional. In particular, I'd like Dart to support ADT-style pattern matching (#349). To do that and get good exhaustiveness checking on match statements/expressions, we likely need to be able to statically tell "These are the only subclasses of class C" so that we can tell if every case is covered. Sealed class hierarchies can give us that. But if the sealing is only semi-enforced, then the type system can't trust that is knows all the subclasses. |
You guys bring up good points -- seems to be that for performance and safety reasons, the compiler needs to be sure it knows about all subtypes of a class. I guess my concern is that there are people currently subclassing APIs that the author may not be aware of. After all, being able to extend of implement any class without any special declarations is a huge freedom in Dart, compared to the difference between |
It is a concern—it comes up a lot whenever we discuss access control restrictions. I don't know how much of a concern it is in practice, and it's very hard to get good data on since we don't have any way to mechanically infer whether a class is intended to be subclassed. My suspicion is that it's not as much of an issue as people believe. Humans can be be kind of like cats where we will sit there and meow in front of a door if it's closed to us even if we actually don't want to go through it. We just like knowing that we could go through it if we wanted to. I think access control triggers some of that same impulse. |
Yeah, after searching my own projects, I'm inclined to trust you on that even though that's clearly a dog in your profile picture |
IMO I would prefer "sealed" keyword rather than "struct" for limiting class extendability. Because "struct" in other languages like C# is used for different intent than sealed classes, which might confuse or in future we might want to add features like inline type allocation to the language which might increase confusion even more. |
As a simple approach to sealing classes (#349), I propose a minimal, yet powerful, feature: Non-interface classes.
Background
Dart classes are "Kotlin open" classes and interfaces. It means that anyone can implement the interface of a class and extend the class—at least if it has a public generative constructor.
That may sometimes be more affordance than desired. There are requests for "Sealed" classes intended to prevent other users from implementing or extending a class. The reasons given for that feature is to control dependencies, so that additions are not breaking changes, and to enable performance improvements when compilers can locally deduce properties of all implementations of a type.
Proposal: Non-Interface
struct
ClassesIntroduce
struct
as a modifier on class and mixin declarations,struct class
andstruct mixin
, which makes the class name not denote an interface. It still introduces a type, and a class/mixin, but not an interface name.The class has an "interface" in the sense of a set of implemented types and a mapping from member names to member signatures (which is what the language uses to see if a member access is allowed), but the name will not be usable in
implements
clauses.A
struct class
can be extended as long as it has a public generative constructor. Such a subclass of astruct class
must also be astruct class
.If a
struct mixin
is applied in on any allowable super-type, the result is astruct
class.We allow a lone
struct
to work as a shorthand forstruct class
wheneverstruct
is not immediately followed byclass
ormixin
. This reduces the extra typing when declaring astruct class
.Examples
If we combine this with improved default constructors (#469), then you can declare a simple data class as:
It's possible to extend the class, but not implement it.
A properly sealed class would be:
Maybe we can even find a way to avoid the intermediate constructor, say
factory Point._(this.x, this.y);
would be a generative constructor which cannot be used as such. (Probably not the best syntax, but something to start from).Benefits
The benefit of a non-interface class is that it is guaranteed that all objects matching the type will also extend the class (or mix in the mixin). That ensures that private members are available, and that invariants ensured by the constructor are maintained.
Together with only having a private generative constructor, this ensures that nobody else can implement the class.
If it's possible to restrict the availability of a subclass with a public generative constructor, say to the same package using package-local libraries and not exporting the subclass from a public library, it allows a class that can only be extended locally in the same package.
To ensure that specific methods are not changed, we will also need to be able to make methods final (non-overridable).
Drawbacks
A class that doesn't have an implementable interface, cannot be mocked. It also cannot be implemented for any other reason, which is a restriction compared to the flexibility provided by all current Dart classes.
Changing a class into a
struct class
is a breaking change. This makes it impossible to apply thestruct
to existing platform classes when the feature is implemented. Packages can increment their major version number and addstruct
where they prefer to have it. For clients who are not depending on implementing the package's interface, it's free to update to the new version, and everybody else gets a fair warning where things are going.The text was updated successfully, but these errors were encountered: