Skip to content
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

Closed
leafpetersen opened this issue Oct 28, 2022 · 25 comments
Closed
Labels
class-modifiers Issues related to "base", "final", "interface", and "mixin" modifiers on classes and mixins. patterns Issues related to pattern matching.

Comments

@leafpetersen
Copy link
Member

leafpetersen commented Oct 28, 2022

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 and closed), and two positive capabilities (open, and interface) 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 as sealed are also considered implicitly sealed, but they may override this. Sealed classes are not considered for exhaustiveness checking.

sealed class A {} // May not be extended or implemented outside of the current library
class B extends A {} // Likewise
class C implements A {} // no implicit restrictions

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 as closed are not considered implicitly sealed (they inherit no negative capabilities). Closed classes are considered for exhaustiveness checking.

closed class A {} // May not be extended or implemented outside of the current library
class B extends A {} // May be extended or implemented outside of the current library
sealed class C extends A {} // May not be extended or implemented outside of the current library 

A class which is marked as open may be subclassed, and may not be implemented unless it is explicitly marked with interface. All subclasses of a class marked as open are implicitly open.

open class A {} // May be extended outside of the library, but not implemented outside of the current library
class B extends A {} // Likewise
sealed class C {} // 
open class D extends C {} // May be extended outside of the library, but not implemented
open interface class E extends C {} // May be extended or implemented outside of the library

A class which is marked as interface may be implemented, and may not be extended unless it is explicitly marked with open. All subclasses of a class marked with interface are implicitly marked with interface. A class may not be marked with interface unless either its superclass is in the same library or its superclass also has the interface 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 marked interface to remove the subclass capability that it inherits from its superclass.

interface class A {} // May be implemented outside of the library, but not extended outside of the current library
class B extends A {} // Likewise
sealed class C {} // 
interface class D extends C {} // May be implemented outside of the library, but not extended
open interface class E extends C {} // May be extended or implemented outside of the library

Comparison to #2592

The different syntax choices can be somewhat summarized by the following choices. The two are slightly incomparable around closed vs interface, since in #2592 closed is not inherited, and I have chosen here to make interface inherited. Either choice could be made to go the other way.

Syntax from #2592 This alternative Class capabilities Subclass capabilities
class class Implement, Extend, Construct
sealed class closed class Exhaustive Implement, Extend
base class open class Extend Construct Extend Construct
closed class N/A Implement, Construct Implement, Extend, Construct
N/A interface class Implement, Construct Implement Construct
closed base class N/A Construct Construct, Extend
N/A sealed class Construct Construct

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 uses sealed in the place that I have used closed 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 and interface were driven by familiarity from Kotlin and elsewhere.

If we prefer to use sealed for exhaustiveness, we might consider using final where I have used sealed here - that is, for "no interface, no extension".

@munificent
Copy link
Member

In the second paragraph, you say:

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 as sealed are not considered implicitly sealed (they inherit no capabilities).

Do you mean closed in the second sentence?

@leafpetersen
Copy link
Member Author

Do you mean closed in the second sentence?

Yes, updated.

@leafpetersen
Copy link
Member Author

leafpetersen commented Oct 29, 2022

Updated to make it clear that open and interface are transitively applied unless overridden. Also, cc @munificent @eernstg @lrhn @stereotype441 @jakemac53 @natebosch @kallentu @mit-mit .

@lrhn
Copy link
Member

lrhn commented Oct 29, 2022

So closed is special in that it's one-level only, but allows exhaustiveness, sealed is transitive unless re-opened/re-interfaced.

The class Object is perhaps declared as open interface class and Null is sealed.
A new class inherits open interface from Object unless overridden.

You cannot combine open or interface with closed, because that would break exhaustiveness.

You cannot combine open or interface with sealed, because that's the same as open or interface by itself.

You can combine sealed and closed, for the exhaustiveness. It's otherwise the same as sealed.

A class inherits restrictions from its superclass/super-interfaces. The closed keyword temporarily overrides those, but only for a single level.

Subclasses can remove restrictions again in some cases. A subclass of a sealed class (which is necessarily in the same library) can be sealed (default), open, interface or open interface.
We do not want a second library removing restrictions put in by the original library, though.

For reintroducing interface or open, I'm not completely convinced that it works as described. Maybe it does, but there are too many negations in my train of thought when I try to understand it.

A class with no new modifiers can be implemented iff all its immediate super-interfaces (including the interface of the supertype) can be implemented.
A class with the modifier sealed or open (by itself) cannot be implemented.
A class with the modifier interface must have superinterfaces which all have "accessible implementability", defined as:

  • can be implemented, or
  • is declared in the current library, and all its immediate super-interfaces have accessible implementability.

(That is, along every super-interface path towards Object, any leading unimplementable interfaces must all be in the current library.)

This is a little weird, because we don't usually order superinterfaces. I'd be happy to just say that you can never reintroduce interface after removing it. Still, if it's reintroduced inside the same library, we know immediately that we can't use it for optimization anyway.

A class with the modifier open must have a super-class which has "accessible extensibility":

  • Can be extended
  • Is in the current library and has a superclass which has accessible extensibility.

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.

@munificent
Copy link
Member

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 closed for now): whether it can be extended and/or implemented. The syntax for specifying them uses a few combinations of three keywords:

  • Yes implement, yes extend: open interface class.
  • Yes implement, no extend: interface class.
  • No implement, yes extend: open class.
  • No implement, no extend: sealed class.

When writing a class, if you want the same capabilities as your superclass, you just write class. Otherwise, you must specify both. There is no syntax for toggling just a single capability. If my superclass is interface and I want to allow extending and implementing, I have to write open interface, not just open.

When looking at a class, to understand its capabilities, I walk the class and its superclasses until I hit one that isn't just class and then I know what I can do with it.

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:

  • Turn extends from off to on (maybe like open)
  • Turn extends from on to off (maybe like closed)
  • Turn implements from off to on (maybe like interface)
  • Turn implements from on to off (maybe like base)

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 class) and then syntax for the four desired combinations (sealed, open, interface, open interface).

Then the closed modifier turns on exhaustiveness checking and applies the necessary restrictions just to the class (make it abstract) and its direct subtypes (only allow them in the current library) needed to be sound. It does not affect the inherited defaults for extends/implements.

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 BaseClass decide to allow it to be implemented:

open interface class BaseClass {}

Now my MyClass gains that capability too, which I may not intend. Should users defining subclasses preemptively repeat the defaults of their superclass to be insulated from those changes? Should I have written?

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.

@leafpetersen
Copy link
Member Author

@lrhn @munificent I think you're both overthinking this a bit. The proposal is extremely simple:

  • If you specify nothing, you inherit the capabilities of your superclass (most people don't care, they get what they get)
  • If you specify anything, you must specify all of the capabilities that you want.
  • If you want nothing, you use sealed (or final if we prefer that syntax).

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 base and closed very odd and non-orthogonal. Comparing again from the table above:

  • interface class vs closed class
  • open class vs base class
  • sealed class (or final class if you prefer) vs closed base class

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 interface class but you want to allow extension (you must be in the same library for this to be possible). Then you have to write open interface class. Which isn't great. But note that you cannot express this at all in the base/closed proposal.

In other words, I believe this proposal makes the common cases look better, and the uncommon cases possible.

A class with no new modifiers can be implemented iff all its immediate super-interfaces (including the interface of the supertype) can be implemented.

@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 closed, but let's leave that on the side).

A class with the modifier interface must have superinterfaces which all have "accessible implementability",

@lrhn I'm not following where you got this section.

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:

@munificent This is literally your own proposal! :). You propose (in #2592) that base is transitive. Replace open in your example with base:

base class BaseClass {}

Now, as in your example, the owner of BaseClass decides to add in the ability to implement:

class BaseClass {}

Now, as you observe, in your own proposal, MyBaseClass suddenly inherits a capability that it didn't want!

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

  • Not make these things transitive
  • Or require users to explicitly list all of the capabilities that they want.

But I think that this is orthogonal to this proposal.

It's true that in this proposal I have also made interface transitive, whereas in your proposal you don't. I have an open comment on your proposal since I don't understand why you've made that choice. But again, if we believe that is the right choice, we can also make that choice here.

@munificent
Copy link
Member

I find the syntax base and closed very odd and non-orthogonal.

I agree that base isn't great, but I think closed is fairly straightforward. Why do you say they're non-orthogonal? There's two keywords and each lets you control a capability independently of the other.

Comparing again from the table above:

  • interface class vs closed class
  • open class vs base class
  • sealed class (or final class if you prefer) vs closed base class

For all of these common cases, I think this syntax reads much, much better.

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 abstract is a negative capability, but we seem to understand it OK. Probably just because we've put the time in to get used to it.

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 interface class but you want to allow extension (you must be in the same library for this to be possible). Then you have to write open interface class.

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 E's capabilities? You don't know until you walk the superclass hierarchy all the way up to A which, hopefully, has specified it.

That is, your superclass is an interface class but you want to allow extension (you must be in the same library for this to be possible). Then you have to write open interface class. Which isn't great. But note that you cannot express this at all in the base/closed proposal.

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 base/closed proposal is re-adding implements when the superclass has taken that away. But that's a deliberate restriction. The value of being able to take away implements is that it means every instance of your class will be an actual instance of your class: it will have your class's private members and will have run one of its generative constructors. If we allow subclasses to re-add the implements capability, you lose the ability to express that invariant.

In the proposal that invariant is maintained by saying base is inherited. If we don't want it to be transitive—and in retrospect I don't think I do—a better approach is probably to say that base is not inherited but is instead required. If your superclass is base, you must write base on the subclass too. The same way we say that you must write abstract when you subclass an abstract class unless you have implemented all of its methods.

That would make class declarations completely context free—looking at the class tells you its capabilities—which seems like a valuable property.

@leafpetersen
Copy link
Member Author

I agree that base isn't great, but I think closed is fairly straightforward. Why do you say they're non-orthogonal? There's two keywords and each lets you control a capability independently of the other.

Perhaps non-orthogonal is not the right word. Linguistically, base and closed are different. One is an adjective - "this class is closed", and the other is... what a compound noun? "This class is used as a base"? The word closed feels like a negative capability (which it is), but there's nothing about it which leads me to the conclusion that it has anything to do with interfaces. The word base does suggest something to do with extension, but it doesn't feel like a negative capability at all - if anything, if sounds like a positive capability (which it isn't).

Now, it's true that you could make a pretty similar objection to open and interface. open is an adjective, and interface is not normally an adjective (though we do tend to use it informally as one - we talk about an interface class sometimes). But they do both at least sound like positive capabilities to me, and they do very clearly point out what their function is. There's no doubt what interface class means, and open class, while perhaps a little less clear, at least has strong connections to Kotlin, and I think also in general would be fairly easily intuited by most people (since in most languages, classes don't define interfaces, and so of course an open class is talking about extension).

What are E's capabilities? You don't know until you walk the superclass hierarchy all the way up to A which, hopefully, has specified it.

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.

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?

True, this works for closed, because you chose not to make closed transitive (and you still haven't explained to me why... :) ). It doesn't work for base though. In this proposal, I am "fixing" your choice to not make closed transitive, because I believe it is a mistake (for reasons I explained in comments on your proposal). But again, this choice is orthogonal to the choice of keyword/capabilities.

The thing you can't express in the base/closed proposal is re-adding implements when the superclass has taken that away.

Right, and you can express it in mine (within the same library only).

But that's a deliberate restriction. The value of being able to take away implements is that it means every instance of your class will be an actual instance of your class: it will have your class's private members and will have run one of its generative constructors. If we allow subclasses to re-add the implements capability, you lose the ability to express that invariant.

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 A which must be extended, with a subclass _B (in the same library) which allows implements... fine.

In the proposal that invariant is maintained by saying base is inherited. If we don't want it to be transitive—and in retrospect I don't think I do—a better approach is probably to say that base is not inherited but is instead required. If your superclass is base, you must write base on the subclass too. The same way we say that you must write abstract when you subclass an abstract class unless you have implemented all of its methods.

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:

  • class means all capabilities
  • sealed/final means no capabilities
  • open means extensible, interface means implementable
  • Every class must list all of the capabilities that it has, and they must be a subset of the capabilities of the superclass (unless in the same library, in which case YOLO).

@leafpetersen
Copy link
Member Author

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 mixin back into the mix, since I think it falls out fairly nicely. To avoid conflating issues (and given the discussion in #2594) I'll move back to using sealed for enumerated families, with some further discussion below.

Motivations for proposing an alternative to closed and base (#2592).

The motivations for this alternative are twofold.

First, I don't find the closed and base nomenclature compelling, as I discuss above. In short, nothing about closed suggest to me that it is related to interfaces; and base, while suggestive, suggests a positive capability (which it is not). I also don't find base very compelling, even though it might be serviceable. Overall, the result doesn't seem intuitive to me. If I saw closed class with no other context, I would guess that you were forbidden from extending it. If I saw base class with no other context... I'm not sure what I'd guess. Maybe that there was something special going on? Maybe you have to say that in order not to have a superclass? If I saw "closed base class" with no other context.... I'd be confused. How can it be a base class if it's closed? So sure, once you learn the syntax, it's fine... but I don't think it's very readable.

The second motivation is that the use of negative capabilities gives you no way to add things back in. In what I understand to be the current proposal, you are required to repeat the superclass modifiers (at least for base) in the subclass. This means that once you've marked something as base, you can never add back in the ability to implement (which is only possible inside of the library, but still potentially useful).

The third motivation is that one of the most common combinations, I expect, will be to forbid both extension and implementation, and for this, the syntax is the most verbose: closed base class. It's also the most confusing: it's not usable as a base class!

A fourth motivation is that I don't see an easy way to extend the closed/base proposal to handle the mixin capability. What syntax do we use for saying "this thing can't be used as a mixin"? Nothing obvious comes to mind.

Proposal

The proposal here is to instead define a system in which: there is a syntax for "all capabilities"; there is a syntax for "no capabilities"; and there is a syntax for "just these enumerated capabilities". I've chosen to use positive capabilities for two reasons: first that it reads better to me to have an explicit list of what the capabilities are; and second that I haven't seen any proposals that I like for good terminology for the negative capabilities.

I'm ignoring abstract here, which is currently a negative capability that I think we do not propose to change.

The proposal is as follows.

The syntax class means "all capabilities" (implement, extend, mixin, construct).

This is compatible with current Dart. An unmodified class has all of the the capabilities. Subclasses within the same library, or in another library, may freely remove capabilities.

The syntax final class means "no capabilities except construct".

This is expected to be the most common case: I have a class which is never expected to be extended, implemented, or mixed in outside of the current library.

The modifiers mixin, interface, and open can be added to class to add individual capabilities (mixin, implement, extend respectively).

  • interface class -> Only usable as an interface + construction
  • open class -> Only usable for extension + construction
  • mixin class -> Only useable as a mixin + construction
  • open interface class -> Usable as an interface or a superclass, but not a mixin
  • open mixin class -> Usable as a superclass or a mixin, but not as an interface

This allows all possible combinations to be specified, but some of the more common examples (just an interface, or just a base class) are very brief and clear)

When extending or mixing in a class from another library, the capabilities of the new class must be a subset of those of the superclass.

For example, if you extend a class SuperClass which has all of the capabilities, you can just say class SubClass extends SuperClass. But if the superclass has no interface, then you must say open mixin class SubClass extends SuperClass if you wish to preserve all of the superclass capabilities, or just open class SubClass extends SuperClass if only care about extension. It is always valid to say final class Subclass extends SuperClass (assuming that SuperClass affords extension at all).

Within a library, a subclass of another class from the same library must obey the same restrictions, except that the subclass may add back in capabilities by explicitly listing them. For example, given final class SuperClass, class SubClass if invalid even in the same library; however, open interface class SubClass is valid.

You are free to do whatever you want inside of a library, but to avoid confusion, we require that you be explicit when you add capabilities back in that had been removed from a superclass.

The modifier sealed may be added to a class, in which case the class is treated as implicitly abstract, and may not have any direct subtypes outside of the current library. Subtypes of a sealed classes have no additional restrictions placed on them.

Sealed provides the minimum required restrictions for exhaustiveness checking.

Examples

/// Foo has no implementations outside of this library
abstract final class Foo { ... }
/// But it has an implementation inside of the library.  No need to mark this as final, since it only implements
class _FooImpl implements Foo { ...}
/// A pure interface
abstract interface class Foo {}
/// A class which may be constructed and extended, but nothing else
open class Foo {}
/// A closed family of types that allows extension below the family
sealed class Variant {}
// Variant1 allows all subclassing/implementation/mixin
class Variant1 extends Variant {}
// Variant2 chooses to be final
final class Variant2 extends Variant {}
/// A closed family of types
final sealed class Variant {}
// Variant1 must be final, unless it explicitly adds in capabilities
final class Variant1 extends Variant {}
// Variant2 chooses to add back in the ability to extend
open class Variant2 extends Variant {}

Commentary

I'm not 100% sold on the sealed syntax, but I haven't found a better alternative, except possibly variant.

It's a little odd that interface class MyInterface allows construction. This is a little cleaner in the base/closed system, since there everything is a negative capability. So closed class MyInterface means the same thing as interface class MyInterface, but it's somewhat less surprising that you can still construct it, since closed really means "no extension". In general, I think this is a consequence of mixing positive (interface, open and mixin) modifiers with negative modifiers (abstract). I'd don't see a good alternative to this though.

The modifier open is the odd duck in this proposal: interface class and mixin class directly describe a type of thing that the class is ("a class which is an interface" or "a class which is a mixin"), whereas open class says something about the uses to which a class can be put ("a class which is open to extension"). Using base class instead of open class would be more consistent with this nomenclature. This is definitely an option - however, my personal sense is that it still reads less clearly, and it is definitely the case that open class has a significant advantage in that it is familiar to anyone coming from Kotlin.

@lrhn
Copy link
Member

lrhn commented Nov 1, 2022

@leafpetersen You had me at the first part, but then the positive interface/mixin/open threw me off.

What about:

  • class alone means what it does today, except that you can't mix it in.
  • mixin class allows you to also mix it in (but cannot have constructors or superclasses).
  • final class means the class cannot be extended or implemented (outside of the library). All in-library subtypes are also final (must be written, or implicitly).
  • closed class means the class cannot be implemented (outside of the library), but can be extended. Can be combined with mixin. All in-library subclasses are also closed. If implemented inside the library, it might limit how much the compiler can benefit, but it does ensure that any subtype extends one of the implementations from the library.
  • switch class means that the class cannot be extended or implemented outside of the library, and the subtypes have exhaustiveness. Not inherited. (Or sealed class. All names are strawmen. It's just hard to remember the distinction between "closed" and "sealed".)

And abstract works like today.

You cannot loosen the restriction again after introducing it, not even inside the same library.
There is no "can implement but not extend", because the use-case is unclear, and it doesn't provide any useful guarantees for the compiler. Declaring it isn't useful. You can always get the effect by just not having a public generative constructor anyway.

Advantage: All the modifiers are restrictive (other than mixin, which is only there to allow the current classes which are also used as mixins.)
All the modifiers have a predictable effect. It's predictable because it cannot be removed again, and because the compiler can actually benefit from the restriction. The "can implement outside the library, but not extend" was dropped because it provides no predictive advantage, the compiler has to assume the maximally pessimal case anyway.

@eernstg
Copy link
Member

eernstg commented Nov 1, 2022

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 (extendable and implementable might be even more readable, but it doesn't take long to learn that it's called open respectively interface).

One way to specify all capabilities explicitly and still remain somewhat concise is to use class to specify all capabilities, abstract to remove "construct", and any combination of open, interface, and mixin to enable "extendable", "implementable" respectively "can derive a mixin", say, "mixinable". This is the core of @leafpetersen's most recent proposal, as I see it. I like it!

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 A that has certain capabilities and a subtype B of A, declared in the same library L as A, and another subtype C of A declared in a different library L2.

  1. If C satisfies that there is a path in the superinterface graph from C with no implements edge that reaches a class in L which is a subtype of A and which has an implementation of all private members in its interface, then C is guaranteed to inherit an implementation of all private members of A (even though such members cannot be declared in L2). This is what I mean by a "privacy correct" class, and it's a significant motivation for having "interface capability is absent". Another guarantee provided in this situation is that construction of any object of type A must have included the execution of a generative constructor of a subtype of A in L.

  2. If C satisfies that there is no superinterface graph path that includes an extends edge to a subtype S of A in L (note that S could be a mixin or a class) then it is guaranteed that C does not inherit any A member implementations from L (extends edges can occur elsewhere in the graph, it's only the first edge that reaches a declaration in L which must be implements). We could say that this makes A a pure interface as seen from outside L, which might be useful from a software engineering perspective.

I think we should specify that when A does not have the capability "implementable", then it is required that every subtype of A also does not have that capability. This is necessary in order to provide guarantee (1) above (and then we will need to drill down into the details about what else is required, if anything).

However, we might want to specify a weaker constraint when the capability "extendable" is absent for a class A: We simply require that every public or leaking subtype S of A in L also does not have the capability "extendable". This ensures that any further subtype B of S, declared in a different library L2, cannot have an extends edge to S, it must be an implements edge, and that is sufficient to ensure guarantee (2). Nothing stops C from having the capability "extendable", and no harm should occur in L if some other class extends C and inherits whatever it wants from there.

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'.

@munificent
Copy link
Member

munificent commented Nov 2, 2022

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 final and interface work very well. How is this for a half-way proposal between this one and mine:

Proposal

  • A class marked base can be extended but not implemented outside of the current library. A mixin marked base can be mixed in but not implemented outside of the current library.

  • A class marked interface can be implemented but not extended outside of the current library.

  • A class marked final can be neither extended nor implemented outside of the current library. This avoids a confusing combination of base interface class to take away both capabilities and means that users can think of base and interface in terms of what they enable instead of what they prohibit.

  • If you extend or mix in a type marked base from another library, your type must be marked base or final. This closes the base class modifier loophole #2451 loophole and ensures that if a library author intends that all instances of their class concretely extend it, that another library author can't break that invariant.

  • A class marked base, interface, or final can't be used as a mixin. This is help us get closer to removing the ability to mix in classes in general.

Remarks

Note 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:

  • We only need a small set of modifiers to set the capabilities in one direction. We don't need closed interface or some other syntax to mean "add capabilities back".

  • You can understand the capabilities of a class just by looking at its own declaration.

I think the simplicity and local reasoning are worth the potential footgun.

@lrhn
Copy link
Member

lrhn commented Nov 2, 2022

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 final ensures that nobody else, anywhere else than in this library, can ever create a subtype.
You have complete knowledge of all the possible instances of the type from looking just in this library.
If a subclass in this library being public and non-final allows someone outside of the library to create a subtype, then the property above goes away. Then final has no practical effect, it's just a a restriction on the user, but the compiler can't use it. And a reader might be confused.

If the in-library subclass is base, then you still know that all subtypes extend a class in this library, but then you could also have just made the superclass base and given it a private constructor.
If the in-library subclass is interface, then all you ensure is that any other subtype is a subtype of that subclass, not the other ones. But then you could have final'ed the other subclasses. It does prevent directly implementing the superinterface. Not sure I can see the use-case, though.

If a class is base, then you ensure that all subtypes inherit implementation from the class, including private member implementation. That's useful. (I'd even allow you to declare final public members in a base class, which cannot be overridden in subclasses.) Compilers can depend on the implementation being what it can see, or overrides of that. (In JVM terms, you can do virtual dispatch on the members, not interface dispatch.)

If a public subclass in the same library is not base or final, you lose that ability.
All you gain is preventing someone from implementing the super-interface directly, but they can implement it indirectly by implementing the subclass. Same usage again as above, and still can't find a good use-case.

If a class is interface, so you can only implement, not extend, then ... it's just a class with no public generative constructor.
We have those already. We don't need a word for it, unless we want to add more to the feature than just the restriction.
Say, don't allow concrete members on the class (unless it is extended inside the same library, but we can already do that), and don't create a default constructor (that one might be useful.)

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 class can do everything.
An interface class is the same, but it's implicitly abstract and doesn't get a default constructor. Maybe cannot have generative constructor at all. (Can be abbreviated to just interface?)
A sealed class/base class cannot be implemented outside of the current library. All in-library subclasses must also be sealed (or final).
A final class cannot be extended or implemented outside of the current library. All in-library subclasses must also be final.

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 switch class/closed class can only be extended or implemented (as otherwise allowed) inside the current library, but subclasses are unaffected. They inherit any of the persistent restrictions from superclasses.

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.

@leafpetersen
Copy link
Member Author

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:

  • We only need a small set of modifiers to set the capabilities in one direction. We don't need closed interface or some other syntax to mean "add capabilities back".

@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 interface base class . The point is that these are positive not negative capabilities.

@munificent
Copy link
Member

I want to clear this up: in my variant of this proposal you do not need any other modifiers for this. You just use interface base class . The point is that these are positive not negative capabilities.

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.

@leafpetersen
Copy link
Member Author

However, your proposal does mean that there are two ways to spell "make a class with all capabilities":

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 // ignore always_repeat_superclass_modifiers instead of interface base class.

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.

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.

@munificent
Copy link
Member

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

Ah, right, thanks for clarifying.

@eernstg
Copy link
Member

eernstg commented Nov 4, 2022

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 C in a library L allows for extends C (and may or may not allow with C), but doesn't allow for implements C. We could then define the static analysis such that the following is guaranteed:

  • Every subtype of C is a subclass of C.

This is very simple, just make it a compile-time error to have implements C, everywhere. If we insist that implements C must be allowed in L, then we can enforce a slightly weaker property:

  • Every subtype of C is a subclass of some class B in L where B <: C.

In both cases, we have several useful properties:

  1. If a new concrete, public member m is added to the declaration of C then it is guaranteed that no subtype outside L has a compile-time error because it has no implementation of m. (There could be an 'incorrect override' error in a subtype D that has an existing declaration of m with a different member signature). This makes it a lot safer to add new members to a widely used type, if it is possible to write a meaningful implementation. Perhaps that implementation is suboptimal for some subtypes, but they can just be optimized (over time, as needed) by overriding m.

  2. If a new private member _m is added to the declaration of C then it is guaranteed that no subtype can exist where _m is implemented as a "noSuchMethod thrower" (this happens if there is a subtype outside L that has no _m). In other words, public classes in L can safely have private members.

  3. For every subtype S of C, the construction of S will execute a generative constructor of C (with the simple property above) or a generative constructor of one of the subtypes of C in L (with the weaker property). In any case, any invariants that we may wish to establish at construction time can be supported by code in this set of constructors (which is a finite, statically known set of declarations in L).

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 A to a B and back, such that we have a two-edge loop:

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 A where b is overridden, but it must still be a final getter, so we know that we will get exactly the same object every time we evaluate the getter b in a given instance of A.

Similarly, B could have a subtype (via extends, with, implements, anything goes), but the a getter of a given instance of type B must still be final, so we get the exact same value every time we evaluate a.

So the assertion will remain true if it is true when the instance of A is created.

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.

@munificent
Copy link
Member

If we insist that implements C must be allowed in L, then we can enforce a slightly weaker property:

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.)

In both cases, we have several useful properties: 1. ... 2. ... 3.

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.

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.

Me too.

@lrhn
Copy link
Member

lrhn commented Nov 5, 2022

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.

  1. Enabling exhaustiveness checking in switch statements is a use-case. Because enabling that makes changes into breaking changes, it must be opt-in.

  2. Ensuring a specific implementation of a private (or public) member is a use-case. The tool for that is to disable the implicit interface. Possibly also preventing overriding (final members in extends-only classes).

  3. Preventing access to (parts of) a specific private implementation may be a use-case, which can be enforced by preventing extends, but still having an implicit interface. I'm not sure it's a valuable use-case, and would be happy without it.

  4. Locking down an interface, so that adding members to it in the future cannot be a breaking change, is also a good use-case. Completely preventing subclassing is a way to do that.

In each case, we can allow subclasses in the same library to ignore the restriction.
If they do so, does it make sense wrt. the use-case to remove the restriction introduced by the superclass?

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.

@natebosch
Copy link
Member

natebosch commented Nov 11, 2022

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 class in the same library being more dangerous than it looks because it may reopen a restriction. After some thought I don't think I'm too concerned about this - if it becomes a problem in practice we could add a lint and a @reopen annotation.

@munificent
Copy link
Member

Those are tools we provide in order to enabled users to reach other goals. Those goals are our primary use-cases.

...

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.

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 extends

I 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 AstNode will have gone through a generative constructor of either Expression or Statement.

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 AstNode a private generative constructor and having public generative constructors on Expression and Statement.

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 implements

This 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 base or final, any subclasses must also be marked base or final even in the same library.

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.

@lrhn
Copy link
Member

lrhn commented Nov 11, 2022

The AstNode description looks like something where you'd want to have exhaustiveness checking too, in which case it's already possible.
It's true that if you want that behavior, and not exhaustiveness checking, we need something like this.

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 implements from either level.

(Also, using base as a positive modifier gets less weird when you can't also reintroduce interface. The base interface modifier combination is just weird. If base means "cannot implement", and you can't re-introduce implementing, then that won't happen.)

@munificent
Copy link
Member

The AstNode description looks like something where you'd want to have exhaustiveness checking too, in which case it's already possible. It's true that if you want that behavior, and not exhaustiveness checking, we need something like this.

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 abstract and require users to use sealed for that because sealed types are implicitly abstract. I think any restriction that is implicitly bundled with some other feature should also be directly accessible.

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 implements from either level.

(Also, using base as a positive modifier gets less weird when you can't also reintroduce interface. The base interface modifier combination is just weird. If base means "cannot implement", and you can't re-introduce implementing, then that won't happen.)

Agreed. Leaf and I talked about this a bunch and my current pitch is:

  • class: Yes extend, yes implement.
  • base: Yes extend, no implement.
  • interface: No extend, yes implement.
  • final: No extend, no implement.

Using final lets us use positive terms for the single-capability cases that describe what you can do while avoiding the weirdness you get when combining them.

I worry some about user confusion between final and sealed (or whatever we pick for exhaustiveness), but it's probably something we'll just have teach people. The nice thing about being permissive by default (i.e. class having all capabilities) is that most application developers won't have to worry about this much. They'll need to know it if they try to reuse a class from some other library in a way that it prohibits, but they won't have to spend effort deciding what to annotate their code with.

@leafpetersen leafpetersen added the class-modifiers Issues related to "base", "final", "interface", and "mixin" modifiers on classes and mixins. label Dec 14, 2022
@leafpetersen
Copy link
Member Author

Closing this, we have a design elsewhere.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
class-modifiers Issues related to "base", "final", "interface", and "mixin" modifiers on classes and mixins. patterns Issues related to pattern matching.
Projects
None yet
Development

No branches or pull requests

5 participants