-
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
[Access Modifiers] Access modifiers using closed, sealed, open and interface #2595
Comments
In the second paragraph, you say:
Do you mean |
Yes, updated. |
Updated to make it clear that |
So The class You cannot combine You cannot combine You can combine A class inherits restrictions from its superclass/super-interfaces. The Subclasses can remove restrictions again in some cases. A subclass of a For reintroducing A class with no new modifiers can be implemented iff all its immediate super-interfaces (including the interface of the supertype) can be implemented.
(That is, along every super-interface path towards This is a little weird, because we don't usually order superinterfaces. I'd be happy to just say that you can never reintroduce A class with the modifier
This is unaffected by interfaces, it's only about inheriting implementation. With this, a sealed class from one library cannot be opened/interfaced by another library, not unless the first library also exposes a subclass which already allows it. |
It took me a while to wrap my head around this. Let me try re-explaining it and you can tell me if I got it right. A class has two capabilities (ignored
When writing a class, if you want the same capabilities as your superclass, you just write When looking at a class, to understand its capabilities, I walk the class and its superclasses until I hit one that isn't just Because there are no global default capabilities we need syntax to be able to toggle those capabilities in both directions. In principle, that would require four keywords:
But the proposal doesn't let you toggle capabilities individually. Instead you either inherit both or specify both, so we only need syntax to mean "inherit" (just Then the Do I have this right? If so... it sounds pretty complex. One problem with having the superclass determine the default is that changing that default can then propagate down in ways that might not be wanted. Let's say there's some class out there like: open class BaseClass {} And in my code I want a subclass of that that can also be extended but not implemented. The shortest thing to write is: class MyClass extends BaseClass {} Later, the maintainers of open interface class BaseClass {} Now my open class MyClass extends BaseClass {} Assuming I understand it right, I gotta admit I'm not feeling this. I'm not attached to any particular set of keywords, but I do think it would be a generally simpler feature if we just pick a set of global defaults (probably yes extends and yes implements) and then have non-inherited keywords to turn those off. That means you can understand the capabilities of a class just by looking at its own declaration, and there are fewer keywords and keyword combinations to specify. |
@lrhn @munificent I think you're both overthinking this a bit. The proposal is extremely simple:
We don't have to do the "If you specify nothing" part - I added it because @munificent seemed to feel it was important (and has it as part of his proposal). Ignoring the transitivity (which I comment on below, if this is controversial fine but it's not specific to this proposal), the reason that I like this proposal, is that I think it reads much better. I find the syntax
For all of these common cases, I think this syntax reads much, much better. The only place that I think this syntax reads worse is when you want to add back in capabilities. That is, your superclass is an In other words, I believe this proposal makes the common cases look better, and the uncommon cases possible.
@lrhn I don't think any of the proposals (including this one) pass on access modifiers from super-interfaces - only from super-classes. So a class with no new modifiers can be implemented iff its superclass can be implemented (or its superclass can only not be implemented because it is
@lrhn I'm not following where you got this section.
@munificent This is literally your own proposal! :). You propose (in #2592) that base class BaseClass {} Now, as in your example, the owner of class BaseClass {} Now, as you observe, in your own proposal, So yes, as with your proposal, I think in this proposal users who really care would want to explicitly list the capabilities that they support. If we feel that this is a problem, then we can choose to either
But I think that this is orthogonal to this proposal. It's true that in this proposal I have also made |
I agree that
Yeah, it's a good point that using positive capability words does make it a little more obvious what you can do with the class. Sort of weird how
I think the real place where the syntax reads (much) worse is: import 'a.dart';
class B extends A {}
class D extends C {}
class E extends D {} What are
Isn't it just: closed class Super {}
class extends Sub extends Super {} // Implements + extends, just like any other class with no modifiers. Am I missing something? The thing you can't express in the In the proposal that invariant is maintained by saying That would make class declarations completely context free—looking at the class tells you its capabilities—which seems like a valuable property. |
Perhaps non-orthogonal is not the right word. Linguistically, Now, it's true that you could make a pretty similar objection to
This is a choice, which I took from your proposal. It is equally true of #2592 . If we don't like that, fine, we can not do it with this proposal, and/or with #2592. But I believe it is orthogonal to the choice of modifiers.
True, this works for
Right, and you can express it in mine (within the same library only).
You can express that invariant in my proposal. The only place you can re-add back in implements or extends is within the same library. And I think that's at least potentially useful. It is definitely useful for extends. You can define a superclass which only supports use as an interface, but then have subclasses in the same library which allow extension. The other way feels a bit esoteric, but again, the principle is that within your library you can do whatever you want. So if you want to have a class
That's fine, but again, this is orthogonal. I made the choices I made here to match (mostly, except that I also made closed/interface transitive) your choice of semantics WRT to inheritance. If we believe that traits should not be silently inherited, then this proposal becomes:
|
A lot of the discussion above seems to me to be sidetracked on things which are not particular to this proposal, but rather are general choices. It sounds like @munificent at least is leaning away from implicit transitivity, so let me restate this proposal with that in mind. I'll also incorporate Motivations for proposing an alternative to
|
@leafpetersen You had me at the first part, but then the positive What about:
And You cannot loosen the restriction again after introducing it, not even inside the same library. Advantage: All the modifiers are restrictive (other than |
The size of a class declaration is often quite substantial. When reading a class declaration, the context isn't always IntelliJ or VS Code. This makes me prefer an approach where each class declaration specifies every capability explicitly (that is, the presence/absence of a capability can be determined by looking at the class declaration, and in particular we do not have to look up all superclass declarations as well). I believe the best readability is achieved with positive properties ( One way to specify all capabilities explicitly and still remain somewhat concise is to use The most controversial rule could be that each class must have a subset of the capabilities of its superclass. I would very much like to find a valuable guarantee which is provided by the absence of each capability (the presence of a capability is clearly useful because it's an affordance). I hope we can find some simple rules about capabilities in a subtype graph such that we get this outcome. Consider a class
I think we should specify that when However, we might want to specify a weaker constraint when the capability "extendable" is absent for a class This description is intended to add some further motivation for having a somewhat more differentiated approach to capabilities across the edges of a superinterface graph than 'the capabilities of a subclass must be a subset'. |
I'll ignore the mixin stuff for now. I think our two proposals are converging so I just wanted to write a comment here based on what we talked about today. I like 80% of this. In particular, I think Proposal
RemarksNote that with the first four rules, within a library, authors are free to extend, implement, or mix in types regardless of their modifiers. These subtypes are obligated to specify whatever restrictions they want to preserve. In other words, if a library has: // lib.dart:
final class A {}
class A2 extends A {}
base class B {}
class B2 extends B {}
interface class C {}
class C2 extends C {} Then in another library, you are allowed to write: // app.dart:
import 'lib.dart';
class MyA extends A2 {}
class MyB extends B2 {}
class MyC extends C2 {}
class MyA2 implements A2 {}
class MyB2 implements B2 {}
class MyC2 implements C2 {} But you can't write: // app.dart:
import 'lib.dart';
class MyA extends A {}
class MyA2 implements A {}
class MyB implements B {}
class MyC extends C {} Requiring library authors to remember to respecify the same restrictions on their subtypes if they want them is possibly somewhat error-prone. In return for that:
I think the simplicity and local reasoning are worth the potential footgun. |
Trying to look at this from a different perspective than just giving or taking away capabilities, what is the reason for someone doing that? And is it then reasonable to re-grant the capability in a subclass? (My guess is "no".) Can we say when you should use the modifier, and why? Declaring a class If the in-library subclass is If a class is If a public subclass in the same library is not If a class is Because of that reasoning, I wan't the restrictions to be impossible to remove in a subclass. Reading the modifier means that it's true for all subclasses. A This is a strict progression of loss of capability. A lost capability cannot be added back by a subclass. If you see the modifier, you can trust that it actually means what it says. A There is no restriction inheritance through interfaces from other libraries, because if you can implement something, there is no restriction on it. If you implement something restricted in the same library, you must keep that restriction. There is no "implement but not extend" capability, because I think it's just confusing, not really practically useful. |
@munificent I'm fine with your variant of this proposal, but since this seems to be a persistent source of confusion I want to clear this up: in my variant of this proposal you do not need any other modifiers for this. You just use |
Yes, that's a good point. We don't need separate negative modifiers. However, your proposal does mean that there are two ways to spell "make a class with all capabilities": class Foo {}
interface base class Bar {} And the correct way to write it depends on the superclass of the class declaration. My proposal (and the alternative I suggested here) are both simpler in that they're context free: The class declaration itself always tells you its exact properties. |
Yes, this is true. As I say, I don't object much to your counter-proposal. I expect that there will quickly be a lint to warn when you have a class which adds back in capabilities, and so the syntax going forward will be
Nit: my proposal is also context free. The class declaration itself always tells you its exact properties in my proposal. It's true that in my proposal, in both the same library and in other libraries, the modifiers on the superclass will force you to write certain modifiers on subclasses, whereas in your proposal that is only the case outside of the same library. But the class declaration itself is 100% context free in both systems. |
Ah, right, thanks for clarifying. |
I don't have strong opinions about the exact keywords (except that I think positive ones are more readable than negative ones), but I do think we should ensure that the absence of an affordance provides a useful guarantee. Here is a follow-up on this comment, giving more information about the case where a class
This is very simple, just make it a compile-time error to have
In both cases, we have several useful properties:
It is not easy to maintain any properties in Dart (e.g., every public member can be overridden), but if we assume 'final getters' then we have language enforced immutability, and this allows us to enforce that the value of each final getter satisfies some constraints. For example: open class P {
final int prime; // Must be a prime number.
P(this.prime) {
assert(isPrimeNumber(prime));
}
}
class P2 extends P { // This is the same library, could also use `implements P`.
// Try to violate the property in various ways.
static int _counter = 0;
int prime; // Overriding by mutable variable: Compile-time error, not a final getter.
int get prime => super.prime + _counter++; // Error, not a final getter.
int get prime => 4; // Accepted at compile time, but assertion in P throws.
int get prime => 7; // OK. Overriding is not a problem in itself.
} We can also maintain structural properties in the run-time object graph, again assuming final getters. In this case we wish to enforce that there is a reference from each open class A {
final B b;
A(this.b) {
b.a = this;
assert(identical(this, b.a));
}
}
class B {
late final A a;
} Again, there could exist a subclass of Similarly, So the assertion will remain true if it is true when the instance of Of course, maintenance of most application level invariants can't be supported strictly by a type system that doesn't have a built-in theorem prover (and probably that kind of thing would need a huge amount of hand-holding anyway), but I believe that the guarantee that we will execute one of a small, known set of generative constructors can help a lot in an effort to check and maintain any given constraints. |
I think we should. The way I look at it as this: I want to give library authors the ability define types with certain invariants that they can rely on knowing that other users can't break. If, for example, they want to ensure that every instance of their class went through one of the class's generative constructors, they can enforce that in such a way that no other user can break it. But if they want to break the invariant themselves, that's totally fine. It's their code. (It is of course important for usability reasons for them to know when they are breaking that invariant, which is what a lot of the discussion about keywords and transitivity is about.)
Yes, that's a great list! Those are exactly the kind of invariants where I think it's useful for a class author to be able to prohibit implementing the class's interface.
Me too. |
The way I read Erik's comment, it's that we shouldn't be focusing on "preventing subclassing" or "preventing implementing" as the goals. Those are tools we provide in order to enabled users to reach other goals. Those goals are our primary use-cases.
In each case, we can allow subclasses in the same library to ignore the restriction. For 1, exhaustiveness: Yes. That's why we generally only specify exhaustiveness to be a one-level restriction. For 2, ensuring implementation: No. If a subclass can be freely implemented, then there is no guarantee that the superclass implementation is inherited by all objects of that type. For 3, preventing access to implementation: Maybe. Not really, but it's possible for a class extending the superclass to effectively hide the API that it wants to protect. For 4, locking down an interface: No. That's why I generally suggest that we should not allow (and risk) subclasses removing restrictions introduced by superclasses. The restrictions are inherited to all subclasses, even those inside the same library which otherwise ignore those restrictions. |
I think I prefer Bob's proposal in #2595 (comment) I like that there are not both positive and negative keywords. I was originally a little worried about a |
Agreed, but if we're going to give users the ability to restrict classes but not give them the ability to remove those restrictions in their own subclasses because we don't have use cases for the latter, we better be sure we've thought of all the use cases. :) Re-adding extendsI do think it's useful to remove extends from a superclass but re-add it for subclasses. Using made-up keywords here: final class AstNode {}
base class Expression extends AstNode {}
base class Statement extends AstNode {} Here, this API says, "You can add new kinds of expressions or statements, but you can't add entirely new syntactic categories by extending AstNode directly." It means that every instance of I think this is a useful thing an API designer might want to be able to express. Note that they can already express this indirectly by giving I believe it's a good principle to say that any restriction that you can implicitly author on a class by its structure should also be a restriction you can explicitly state. That way it's clearer that you intend the class to be restricted in that way. We could even potentially align these by saying that if your class only has private generative constructors you must annotate it as non-extensible. That way, if your class declaration says it can be extended from outside of the library, the language makes sure users actually can. Re-adding implementsThis one's harder. I can't think of any meaningful reasons why you might want to have a superclass that you can't implement while having a subclass that can be implemented. It's just a really weird structure. I'd be OK with saying that if you opt out of implements for a class, then even in the same library you are prohibited from implementing it and all subclasses must also prohibit implementing. So if a class is marked I don't think it's strictly necessary to have the same kinds of restriction transitivity for each capability, since the capabilities are meaningfully different and the invariants they afford are different. |
The Not sure how well it extends to a superclass which allows implementation, but not extending, and subclasses which allow both again—the same thing, just without removing (Also, using |
Yes, and that's important to me. I don't want users to be forced into exhaustiveness checking when all they actually want is "you can't extend this by my library can". It would be like if we didn't have
Agreed. Leaf and I talked about this a bunch and my current pitch is:
Using I worry some about user confusion between |
Closing this, we have a design elsewhere. |
In #2592 @munificent proposes a set of modifiers for classes to handle exhaustiveness, as well as control over whether a class can be used as an interface or as a superclass. This issue proposes a slightly different approach to achieve the same essential outcomes (with some differences in what is expressible).
The idea is to add two negative capabilities (
sealed
andclosed
), and two positive capabilities (open
, andinterface
) which can be combined to achieve the results we want.In all cases, restrictions only apply outside of the defining library (as with #2592).
I ignore
abstract
here, since I believe it is orthogonal.Proposal
A class which is marked as
sealed
cannot be extended or implemented outside of the defining library. All subclasses of a class which is marked assealed
are also considered implicitly sealed, but they may override this. Sealed classes are not considered for exhaustiveness checking.A class which is marked as
closed
cannot be extended or implemented outside of the defining library, and is considered implicitly abstract. Subclasses of a class which is marked asclosed
are not considered implicitly sealed (they inherit no negative capabilities). Closed classes are considered for exhaustiveness checking.A class which is marked as
open
may be subclassed, and may not be implemented unless it is explicitly marked withinterface
. All subclasses of a class marked asopen
are implicitlyopen
.A class which is marked as
interface
may be implemented, and may not be extended unless it is explicitly marked withopen
. All subclasses of a class marked withinterface
are implicitly marked withinterface
. A class may not be marked withinterface
unless either its superclass is in the same library or its superclass also has theinterface
capability (implicitly or explicitly). We wish to prevent classes outside of a library from adding back in the interface capability. However, a class may be markedinterface
to remove the subclass capability that it inherits from its superclass.Comparison to #2592
The different syntax choices can be somewhat summarized by the following choices. The two are slightly incomparable around
closed
vsinterface
, since in #2592closed
is not inherited, and I have chosen here to makeinterface
inherited. Either choice could be made to go the other way.class
class
sealed class
closed class
base class
open class
closed class
interface class
closed base class
sealed class
This doesn't entirely capture the difference between the proposals, since this proposal allows capabilities to be added back in on subclasses, which is not supported in #2592 .
Choice of keywords
I've proposed using
sealed
as described above because it is consistent with our existing use of@sealed
. This is in contrast to #2592 which usessealed
in the place that I have usedclosed
here.The choice of
closed
was motivated by the intuition that we are defining a closed type family - a type which a known set of subtypes.The choice of
open
andinterface
were driven by familiarity from Kotlin and elsewhere.If we prefer to use
sealed
for exhaustiveness, we might consider usingfinal
where I have usedsealed
here - that is, for "no interface, no extension".The text was updated successfully, but these errors were encountered: