Skip to content

Still a backdoor in "base" modifier #2755

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

Closed
munificent opened this issue Jan 4, 2023 · 19 comments
Closed

Still a backdoor in "base" modifier #2755

munificent opened this issue Jan 4, 2023 · 19 comments
Labels
class-modifiers Issues related to "base", "final", "interface", and "mixin" modifiers on classes and mixins.

Comments

@munificent
Copy link
Member

The intent with marking a class base is that there's no way to create an instance that is a subtype of that class that doesn't directly inherit it. The proposal tries to ensure that by requiring that concrete subclasses of the base type can't themselves expose an interface. Instead, they have to be marked base or final themselves. But that rule can be ignored within the current library. That lets you get an interface to a class you don't control like so:

// lib_a.dart
base class A {}

// lib_b.dart
import 'lib_a.dart';

// OK: B is marked "base":
base class B extends A {}

// OK: C is implementing a "base" class, but within the same library so it's allowed.
class C implements B {}

C is a subtype of A but does not extend A or inherit its implementation.

I was hoping to have the static semantic rules specified just in terms of the immediate supertypes of the declaration, but I can't think of a local restriction that closes this hole while still allowing you to implement classes marked base inside your own library.

Maybe the right fix is to change the rules about requiring base or final on subtypes to:

It is a compile-time error to:

...

  • Declare a class, mixin, or mixin class with a supertype (direct or indirect) from another library that is marked base unless the subtype is marked base or final. This ensures that a subtype can't escape the base restriction of its supertype by offering its own interface that could then be implemented without inheriting the concrete implementation from the supertype.

Does that sound right? @lrhn @kallentu @stereotype441

@munificent munificent added the class-modifiers Issues related to "base", "final", "interface", and "mixin" modifiers on classes and mixins. label Jan 4, 2023
@munificent
Copy link
Member Author

Alternatively, we could just say that once you've removed the "implements" capability, it can't be re-added, even within the same library. So we'd remove the "outside of the library where it is declared" part from the implements rule and make it:

It is a compile-time error to:

...

  • Implement a class or mixin marked base or final.

@eernstg
Copy link
Member

eernstg commented Jan 4, 2023

Alternatively, we could just say that once you've removed the "implements" capability, it can't be re-added, even within the same library.

I'd prefer this approach: If we allow the property to be violated in the same library and then somehow enforce a suitable constraint in other libraries, a developer would still need to be aware of the non-subclass-subtype in the same library in order to understand which guarantees they actually have, and the source code doesn't give the developer any hints about this fact.

@lrhn
Copy link
Member

lrhn commented Jan 4, 2023

I agree. As I've understood the current specification's intent:

  • The current library can ignore and override any modifier. The analyzer warns you if that exposes public classes which break a superclass restriction, unless you mark it with @reopen. But you can.
  • Other libraries cannot directly break a class/mixin restriction, and cannot remove it.

The "and cannot remove it" is the crucial part here, and where we cannot just look at the local superclass for guidance.

If a class or mixin is marked base, it's intended that all subclasses must inherit implementation (aka, cannot be implemented).
You can extend/mixin it in your own library, but your class must also be base, final or sealed, and so must all transitive subclasses. You can never introduce a subclass which allows its interface to be implemented.

If a class or mixin is marked interface, it's intended that its implementation cannot be inherited, but it can still be subtypes. If you create a subtype, you've immediately lost the implementation that should be protected, so there is no further need to worry.

(If it's final or sealed, you can't create subtypes at all.)

On the other hand, just because one of your transitive superclasses is based, it doesn't mean you can't be implementable, as long as there is a subclass of that base class, in the same library as the base class`, which is also implementable.

So:

// lib_a.dart

base class Foo {
  int get _id => ...;

  void same(Foo other) => _id == (other is Bar ? other.id : other._id);
}
@reopen 
class Bar extends Foo {
  int get id => _id;
}

// lib_b.dart
import "lib_a.dart";

class Baz extends Bar {} // open and implementable.
class Qux implements Bar, Baz {
  int get id => 42;
} // All is well.

So, we can't just enforce the restrictions of every super-interface, we need to find the boundary in the super-class/interface tree between this library and other libraries. If what you do is valid against the first class from another library in the super-tree paths, then it's fine.

I tried doing something in #2730. Possibly over-engineered. I don't think the interface restrictions need to be that complicated.

Thinking of it again, we might be able to model this with a single flag, let's call it a synthetic inherited-base modifier that we add to your declarations for you.

  • For each superclass (extends clause) type and mixin (with clause) type T of a class declaration C:
    • If T is declared in another library, and T has a base modifier (it must have either base or no modifier to be valid for extends or with), the class C gets an inherited-base modifier.
    • If T is declared in the same library as C and has an inherited-base modifier, the class C gets an inherited-base modifier.
  • For each superclass-restriction (on clause) type T of a mixin declaration C:
    • If T is declared in another library, and T has a base, final or sealed modifier, then C gets an inherited-base modifier. (The inherited-base modifier should perhaps just be called no-interface instead.)
    • If T is declared in same library as C and T has an inherited-base modifier, then C gets an inherited-base modifier.
  • If a class or mixin declaration C has an inherited-base modifier:
    • It must have a base, final or sealed modifier.
    • It's a compile-time error to implement the interface of C even inside the same library.

That is, if you create a subtype of a type from another library which doesn't supply a public interface, or of a local subtype of such a type, you also cannot expose an interface, even inside the same library.

(Can the on-type of a mixin declaration be a final or sealed type at all? Any actual mixin application of the mixin must have be on an extensible subtype of that type, but we don't know which, so we'll have to assume that it can be at least as restricted as the on-type itself. So we have to enforce "no interface" if the on type doesn't have an interface.)

On the other hand, I think disallowing re-adding implements capability inside the same library is unnecessary.
With the @reopen-required warnings, it won't happen by accident. But you can if you want to.

I don't think it'd be a big problem in practice to have the restriction. The use-cases for it aren't going to be plentiful, and there'll probably be workarounds. But it's inconsistent (why can you @reopen final to base, or even to interface, but you can't reopen base? Or can't you reopen final to interface, and if so, how would you create a set of known sub-interfaces of a specific type without exhaustiveness (like a single-layer of sealed, but without the implied exhaustiveness)? Or should we just say that you are not allowed to do that?

I don't expect to be able to predict all use-cases. That's why I don't want to prevent specific combinations, even if they go counter to other specific use-cases that we can imagine.
I worry that there might be some cases where you have private classes breaking a restriction, and users won't understand why that's a problem, when others can't see them anyway. But I also don't want to treat private declarations differently that public declarations.

I'm satisfied with giving users tools that provide local restrictions on a single declaration (as it applies to any other library looking at it), and letting the total behavior of a library emerge from the combination of modifiers on all the classes. The modifiers is a tool to build guarantees, but they don't provide global guarantees by themselves. Subclasses in the same library can disprove any guarantee that you might think is implied by the superclass modifier.

@eernstg
Copy link
Member

eernstg commented Jan 4, 2023

Here is an attempt to list some properties that we could have. Let A be a declaration of a class, a mixin, or a mixin class in a library L. Let S be the set of subtypes of A which are declared in L.

  • interface .. A: No subtype of A outside L can inherit a member implementation from A.
  • base .. A: Every subtype of A is a subclass of a class in S, or a subclass of a mixin application that mixes in a member of S.
  • final .. A: Every property for interface and every property for base is also valid for final.

Based on these properties, interface will ensure the usual implementation freedom associated with the word "interface": we can change the implementation of A without breaking any subtypes "out there", and we can have an implementation of members in A without introducing an implementation dependency.

Similarly, base implies that private properties of A can be used safely in this library, and every instance of type A must run a generative constructor of some class in S during its construction (so S shouldn't contain any mixins / mixin classes if you want to have some specific code in those generative constructors).

final provides the guarantees of both of the others. Moreover, if we have final on the entire subtype graph in S then that is the entire subtype graph. In particular, it is sufficient to study this library in order to understand the behavior of an object obtained as the value of an expression of static type A.

We can maintain these properties as follows:

  1. If @reopen occurs on a subtype of A in this library then no guarantees are provided.
  2. If @reopen does not occur on any subtype of A in this library then the properties above are guaranteed.

To ensure the soundness of rule (2), we report the following errors:

  • interface .. A: Every subclass/mixin-application of A in L must have the modifier interface or final.
  • base .. A: Every subtype of A in L must have the modifier base or final.
  • final .. A: Every subtype of A in L must have the modifier final.

In other words, "If you don't say @reopen then you can't re-open, and if you do say @reopen then all bets are off".

@lrhn
Copy link
Member

lrhn commented Jan 4, 2023

If we allow reopening (aka. having fewer restrictions than a supertype in the same library, which we currently do and I think we should keep doing), we do need to decide what that means when the same superinterface can be reached in more than one way.

Take the example:

// lib_a.dart
base class Foo {}

@reopen 
class Bar extends Foo {}

// lib_b.dart
import "lib_a.dart";

class Baz extends Foo implements Bar {} 
class Qux implements Bar, Baz {}

Here Baz extends a base class (which is allowed) and implements an unrestricted interface (which is allowed).

Is it OK for Baz to not be marked as (at least) base?

The inherited-base algorithm I gave above would say that Baz must be base, because it has a path in the superinterface declaration hierarchy which meets a base class from another library as the first type from that library - the extends Foo path. (I have another, probably slightly better, phrasing of effectively the same rule that I might post tomorrow.)

On the other hand, the existence of Bar means that library A doesn't have any problem with classes existing which do not inherit members from Foo, as long as they at least implement Bar. Which Qux then does.

So maybe the rule we really need is that:

  • A class or mixin declaration S must be marked as base, final or sealed, and cannot be implemented even it the same library, if
    • S has a superinterface C, whose declaration is in another library L and has a modifier base or final, and
    • S does not also have a superinterface D also declared in L, which has C as a superinterface, and which does not have a base, final or sealed modifier.

That is, if a base/final declaration from another library is in your superinterface hierarchy, there must be at least one path to that superinterface through the declarations which goes through a re-opened subclass of the same library, otherwise you must preserve its base-ness.

@munificent
Copy link
Member Author

munificent commented Jan 5, 2023

The way the proposal works, a library author may choose to craft an invariant about their library based on the modifiers they place on the declarations in it. If a library author sets up any of these invariants, code outside of the library should not be able to break it. That's what I really care about.

I'm comfortable with the library author breaking their own invariants. It's their library. They chose to author the invariant in the first place, and they can discard it if they want. For me, the key questions are:

  1. Can we let library authors relax restrictions on types without leading to loopholes that break invariants authored in other libraries?

  2. If so, are the rules for that simple enough that users can understand them?

For interface, I think answer is "yes". As @eernst notes, interface is about severing an inheritance chain. If another library can't extend a class marked interface, then it must sever the chain. And once it's been severed, there's nothing subtypes can do to cause problems. The chain is already broken.

Of course, the library author may choose to extend their own interface class and have that subclass not be marked interface. They can discard their own invariant, and that's fine (with me).

For base, I think the answer to 1 is "yes" but 2 might be "no". I spent a bunch of time beating my head on this today and the only approach I've been able to come up with is the one Lasse suggests where you walk the superinterface graph looking for base types that are not "guarded" by non-base types.

I believe what Lasse proposed is correct in that it closes the loopholes. But I can't imagine being able to explain it to a user. I like @stereotype441's general point that complex semantics are OK if they are in service of an intent that users understand. But I don't think even the intent here is as clean and simple as it is in things like type promotion and inference.

If a library author writes:

base class B {}

class C extends B {}

Do they actually intend that another library should be able to do:

base class D extends B {}
class E implements C {}
class F implements B, C {}

But not:

class F implements B {}

I feel like we're going too far out on a limb with the design here. It might be the right design, but it's complex and I don't have concrete user data I can point to justify it.

In the absence of empirical evidence pushing us towards a complex design, my inclination is to pick a simpler, more restrictive rule that is easier to understand and explain while still closing the loopholes. We can always loosen it later if users want. But we can't go the other way if we ship a complex, confusing, but permissive rule.

I don't usually like to start with something restrictive, but in this case, it's a restriction the user is opting in to. If they find the rules around base too restrictive... they can just not put base on their class in the first place and then they're back to being able to do anything.

How does this sound:


It is a compile-time error to:

  • (Unchanged) Extend a class marked interface or final outside of the
    library where it is declared.

  • (Unchanged) Implement a class or mixin marked base or final outside of
    the library where it is declared.

  • (Unchanged) Mix in a mixin marked interface or final outside of the
    library where it is declared.

  • (Changed) Extend a class marked base outside of the library where it
    is declared
    unless the extending class is marked base or final. This
    ensures that a subtype can't escape the base restriction of its supertype
    by offering its own interface that could then be implemented without
    inheriting the concrete implementation from the supertype.

  • (Changed) Mix in a mixin or mixin class marked base outside of the
    library where it is declared
    unless the class mixing it in is marked
    base or final. As with the previous rule, ensures you can't get a
    backdoor interface on a mixin that doesn't want to expose one.

  • (New) Implement a class or mixin that has a superclass (direct or
    indirect) from another library marked base or final. This can only
    occur when the class and its immediate superclass are in the same library,
    but the immediate superclass has a superclass from another library.


The changed rules are so that a library can't inherit from its own class while
also removing that superclass's base restriction which might be necessary
if the superclass extends a base class from another library. It closes this
loophole:

// lib_a.dart
base class A {}

// lib_b.dart
import 'lib_a.dart';

// OK:
base class B extends A {}

// Error according modified rule but not original one:
class C extends B {}

// If the above was allowed, then you could do:
class D implements C {}
// And now you have a class that is a subtype of A but not a subclass.

The new rule closes the loophole described in this issue:

// lib_a.dart
base class A {}

// lib_b.dart
import 'lib_a.dart';

// OK:
base class B extends A {}

// Disallowed now since B has a `base` superclass from another library:
class C implements B {}

The rules are somewhat more restrictive. You can still do:

base class A {}

base class B extends A {}

class C implements A {}

So you can inherit from a base class in the same library if you keep the same restriction. And you can reopen a base class in a subtype as long as you don't inherit from it. But you can't do:

base class A {}

class B extends A {}

I haven't totally wrapped my head around these proposed rules yet. I feel like I need to write a program to test every possible hierarchy or something. But I think they might work?

@lrhn
Copy link
Member

lrhn commented Jan 5, 2023

The rule change is intended to ensure that all subclasses of a base or final class disallows being implemented as an interface anywhere except inside the same library. There is no way to reopen the interface to other libraries.
You can implement the interface yourself, but the result must again be base or final.

And if you implement any base or final interface from another library, in any way, your own class cannot be used as an interface, even by yourself.

I think it's strict. Maybe too strict, but as you say, we might be able to loosen it in the future. As long as the loosening has to happen inside the same library as the original base/final class, it'll still not be something that you can use to break other libraries' invariants.

The rules do not mention sealed, which is where we need transitivity, even inside the same library, because the restrictions on the subclasses of a sealed class are the restrictions of its non-sealed super-interfaces. A sealed class propagates the "must be non-implementable" property from its super-types, and those must not be forgotten by its subtypes.

So, it's a compile-time error for a class or mixin declaration S in library L to:

  • (Unchanged) Extend a class C (have an extends C clause), where C is declared
    with an interface, final or sealed marker in a library other than L.
  • (Unchanged) Implement a class or mixin interface I (have an implements ..., I, ... clause),
    where I is declared with a base, final or sealed modifier in a library other than L.
  • (Unchanged) Mix in a mixin M (have a with ..., M, ... clause), where M is declared with
    an interface, final or sealed modifier in a library other than L.
  • (Changed) Have a superinterface (direct or indirect) I, where the declaration of I
    has a base or final modifier, and S does not have a base, final or sealed modifier. This
    ensures that a subtype can't escape the base restriction of its supertype
    by offering its own interface that could then be implemented without
    inheriting the concrete implementation from the supertype.
  • (New) Implement a class or mixin interface I (have an implements ..., I, ... clause),
    where I has a superinterface (direct or indirect) D, and D is declared with a base or final
    modifier in a library other than L. Because of the previous rule, I must be base, final or sealed,
    and because of the second rule above, S and I must be in the same library, so this restriction is on
    implementing types from the same library, when they have inherited a base/final interface
    from another library.

The change, to using "has a superinterface" instead of a direct implements clause, allows us to look through sealed classes. It also means that, going by how it's defined, you have to look at the entire super-interface tree to know whether something must be base and cannot be implemented.

In practice, you only need to go through superclasses in the same library, finding the frontier into other libraries, because the moment you see a declaration from another library, it must be consistent with its super-interfaces, and its restriction cannot be ignored in the current library.

The actual implementation can still be done step-wise, like the "inherited-base" above.
I'll try defining it using a predicate instead:

A class and/or mixin declaration S in library L is unimplementable if:

  • S has the type of a declaration D as its declared superclass (class extends clause), declared mixin (class with clause) or declared superclass restriction (mixin on clause), and:
    • D is declared in another library than L, and is marked base, final or sealed. (The final or sealed can only apply to a mixin on clause).
    • Or D is declared in L, and D is unimplementable.

It's a compile-time error if a class or mixin declaration is unimplementable, and is not marked base, final or sealed. (That's the "(Changed)" case above.)
It's a compile-time error to implement (implements clause) the interface of a class or mixin declaration, from the same library, which is unimplementable. (You can ignore your own base declarations, but not those you inherited from other libraries. That's the "(New)" case above.)

This can be computed per-library, going from super-class to subclass in the order we normally resolve classes. The declaration and use never refers to whether a declaration in another library is unimplementable, because for those we can use the modifiers. Inside a library, we can ignore modifiers, but we can't ignore restrictions inherited from other libraries. Being unimplementable means having such a restriction from anther library.

@eernstg
Copy link
Member

eernstg commented Jan 5, 2023

[Edit Jan 6: Mixin applications are now considered type by type, and with is handled implicitly as part of extends. This is needed because we could otherwise fail to include constraints from the superclass in a mixin application.]

Here is a table that shows the constraints we need, as far as I can see. Consider a declaration of a class, mixin, or mixin class named A in a library L, along with a subtype of A named B (which could again be a class, mixin, or mixin class).

The table shows the requirements around the use of interface, base, and final; it is a compile-time error if these constraints are not satisfied. Every constraint amounts to a requirement that the subtype B has certain modifiers. When the constraint is 'Error', it is not possible to satisfy the constraint, B is just an error; when the constraint is 'OK', any modifier is allowed; when the constraint is 'interface/final', the class B must have the modifier interface or the modifier final; when the constraint is base/final, the class B must have the modifier base or the modifier final; when the constraint is final, the class B must have the modifier final.

On top of these rules, a declaration carrying @reopen as metadata can have arbitrary modifiers, no constraints.

(Of course, I'd much prefer that we did not have a @reopen at all, but it is at least possible for developers to search for @reopen in the current library, and mentally erase all the modifiers before they start reasoning about the available guarantees.)

Treatment of mixin applications

We need to consider the treatment of a mixin application, because a mixin application can appear as the superclass of another class.

Hence, the symbol A below in the cases B extends A may have the form S with M1, .. Mk. In this case, the constraints are taken into account for each of the types S, M1, .., Mk, and the status of being inside or outside L is computed based on the currently processed type (so when we're checking S, we're asking whether B is inside or outside the library where S is declared, and similarly for each Mj, 1 <= j <= k).

In short, when the superclass written as A is of the form S with M1, .. Mk, each of S, M1, .., Mk is checked separately, according to the B extends A rule.

Constraints on subtypes of interface entities

This table is applicable when A has the modifier interface.

Subtype \ Location of B Inside L Outside L
B extends A interface/final Error
B implements A, B on A OK OK

Constraints on subtypes of base entities

This table is applicable when A has the modifier base.

Subtype \ Location of B Inside L Outside L
B extends A base/final base/final
B implements A, B on A base/final Error

Constraints on subtypes of final entities

This table is applicable when A has the modifier final. Note that this is a simple conjunction: Any final entity is both an interface entity and a base entity, so we must require the satisfaction of the constraints on each of those.

Subtype \ Location of B Inside L Outside L
B extends A final Error
B implements A, B on A base/final Error

Relative to @munificent's rules, I think the following 26 cases aren't errors, but they are errors according to the tables above.

If we include the modifier base given in a comment (/*base*/) then I believe the cases aren't errors according to the first four rules from @lrhn, and they are still errors according to the tables above. Taking the fifth rule from @lrhn into account, every declaration using implements in a library named n00?.dart is turned into an error, which reduces the number of cases to 11.

The comments in the libraries whose name is of the form n00?.dart indicate the perceived problems caused by not flagging these situations as errors. The point is that I'm not convinced it is a good idea to allow all these situations silently (without @reopen).

// Library 'n005lib.dart'.

interface class A { // Or `mixin class`.
  void foo() {}
}

class B1 extends A {}
base class B2 extends A {}

interface mixin M {
  void foo() {}
}

class B3 extends Object with M {} // Or `with A` if mixin class.
base class B4 extends Object with M {} // Or `with A` if mixin class.
// Library 'n005.dart'.
import 'n005lib.dart';

class C1 extends B1 {} // Inherits `A.foo`.
base class C2 extends B2 {} // Inherits `A.foo`.
class C3 extends B3 {} // Inherits `M.foo`.
base class C4 extends B4 {} // Inherits `M.foo`.
// Library 'n006lib.dart'.

base class A {
  void _foo() {}
}

class B1 implements A {}
interface class B2 implements A {}

base mixin M {
  void foo() {}
}

class B3 implements M {}
interface class B4 implements M {}

mixin B5 on M {}
interface mixin B6 on M {}
// Library 'n006.dart'.
import 'n006lib.dart';

// Not a subclass of any class in 'n006lib.dart'.
/*base*/ class C1 implements B1 {}

/*base*/ class C2 implements B2 {} // Ditto.
/*base*/ class C3 implements B3 {} // Ditto.
/*base*/ class C4 implements B4 {} // Ditto.
/*base*/ class C5 implements B5 {} // Ditto.
/*base*/ class C6 implements B6 {} // Ditto.
// Library 'n007lib.dart'.

final class A {
  void _foo() {}
  void foo() {}
}

class B1 extends A {}
interface class B2 extends A {}
base class B3 extends A {}

final mixin M {
  void _foo() {}
  void foo() {}
}

class B4 extends Object with M {} // `Object with M` is not final, right?
interface class B5 extends Object with M {} // Ditto.

mixin B6 on M {}
interface mixin B7 on M {}
base mixin B8 on M {}
// Library 'n007.dart'.
import 'n007lib.dart';

// Inherits `A.foo`.
/*base*/ class C1 extends B1 {}

// Inherits `A.foo`.
base class C3 extends B3 {}

// Inherits `M.foo`.
/*base*/ class C4a extends B4 {}

// Implements `M`, does not have a `_foo`.
/*base*/ class C4b implements B4 {}

// Implements `M`, does not have a `_foo`.
/*base*/ class C5 implements B5 {}

// Implements `M`, does not have a `_foo`.
/*base*/ class C6a implements B6 {}

// Implements `M`, does not have a `_foo`.
/*base*/ class C6b extends C5 with B6 {}

// Implements `M`, does not have a `_foo`.
/*base*/ class C7 implements B7 {}

// Implements `M`, does not have a `_foo`.
base class C8 extends C5 with B8 {}
// Library 'n008lib.dart'.

final class A {
  void _foo() {}
  void foo() {}
}

class B1 implements A {}
interface class B2 implements A {}

final mixin M {
  void _foo() {}
  void foo() {}
}

class B3 extends Object with M {} // `Object with M` not final.
interface class B4 extends Object with M {} // Ditto.

mixin B5 on M {}
interface mixin B6 on M {}
// Library 'n008.dart'.
import 'n008lib.dart';

// Inherits `A.foo`. Implements `A`, does not have a `_foo`.
/*base*/ class C1a extends B1 {}

// Implements `A`, does not have a `_foo`.
/*base*/ class C1b implements B1 {}

// Implements `A`, does not have a `_foo`.
/*base*/ class C2 implements B2 {}

// Inherits `M.foo`. Implements `M`, does not have a `_foo`.
/*base*/ class C3 extends B3 {}

// Implements `M`, does not have a `_foo`.
/*base*/ class C4 implements B4 {}

// Implements `M`, does not have a `_foo`.
/*base*/ class C5 implements B5 {}

// Implements `M`, does not have a `_foo`.
/*base*/ class C6 implements B6 {}

@lrhn
Copy link
Member

lrhn commented Jan 5, 2023

Not sure I agree on the tables here. In particular, there is no reason to disallow B on A outside of L, no matter which modifier A has, as long as B itself is base, final or sealed when A is one of those.

You can declare a mixin which must be applied to a final type. It won't help you, unless you can find a subtype of that type which is base or open, because then you can't apply the mixin.
But if you can find one, because the original library provides such a class using @reopen, you will definitely inherit the implementation of that superclass in the mixin application.

The constraint of the on type doesn't apply to the mixin, as much as to the eventual mixin application. (Except for the implicit interface).


OK, I did my own tables:

Dart class modifiers, constraints

Assume we want to make non-implementability (implied by base, final) be something you cannot withdraw from any subclass of a class hierarchy. You can ignore the restriction if it was introduced inside your own library, but the implementing subclass must still be marked at least base.

You can reallow extension/mixin-application in a subclass of your own library, if you want to.

Direct restrictions from modifiers, inside same library:

superclass modifier relation: extends/with` relation: implements` relation: on subclass/mixin allowed modifiers
none ✔️ ✔️ ✔️ any
interface ✔️ ✔️ ✔️ any
base ✔️ ✔️ ✔️ base, final, sealed
final ✔️ ✔️ ✔️ base, final, sealed
sealed ✔️ ✔️ ✔️ Depends on transitive superclasses!

Direct restrictions from modifiers, from other library:

superclass modifier relation: extends/with relation: implements relation: on subclass/mixin allowed modifiers
none ✔️ ✔️ ✔️ any
interface ✔️ ✔️ any
base ✔️ ✔️ base, final, sealed
final ✔️ base, final, sealed
sealed ✔️ base, final, sealed

Transitive restrictions from superclass modifiers, from other library. Restrictions apply for every superinterface from another library:

superinterface class modifier subclass/mixin allowed modifiers
none any
interface any
base base, final, sealed
final base, final, sealed
sealed base, final, sealed

Looking at the rightmost columns, it's pretty clear that everything falls into two groups:

  • No restriction on implements, any modifier is allowed.
  • Must not allow implements, only base, final and sealed allowed.

If any superclass does not allowimplements, then the subclass must also not allow implements. Allowing extends (inheriting implementation) is something that can be chosen per class, and sealed can be thrown in anywhere, and just delays the superclass restrictions for one level (while also itself definitely not introducing an implementable interface).

Unified definition

A class and/or mixin declaration has a number of flags, representing capabilities, derived from modifiers on the class and flags of superclasses.

The flags and their possible values are:

  • instantiate: ✔️/❌ (yes or no, controller entirely by abstract on the same class, not inherited, ignored in the rest.)
  • implement (as interface, by implements): ✔️/ 🏠 / ❌ (yes, local only, no, controlled by interface, base, final, sealed)
  • inherit (implementation, by extends or with): ✔️/❌ (yes, no)
  • sealed: ➕ /➖ (yes or no, overrides the other flags locally for one level, in other libraries).

If a class or mixin declaration A refers to a declaration B in the same library, then the flags are inferred as follows:

A imp A inh A sea B can implements B can extends/with B can on B valid modifiers B imp B inh B sea
✔️ any any ✔️ ✔️ ✔️ none ✔️ ✔️
✔️ any any ✔️ ✔️ ✔️ interface ✔️
✔️ any any ✔️ ✔️ ✔️ base 🏠 ✔️
✔️ any any ✔️ ✔️ ✔️ final 🏠
✔️ any any ✔️ ✔️ ✔️ sealed ✔️
🏠 any any ✔️ ✔️ ✔️ base 🏠 ✔️
🏠 any any ✔️ ✔️ ✔️ final 🏠
🏠 any any ✔️ ✔️ ✔️ sealed 🏠
any any ✔️ ✔️ base ✔️
any any ✔️ ✔️ final
any any ✔️ ✔️ sealed

If A and B are in different libraries, restrictions matter more:

A imp  A inh A sea B can implements B can extends/with B can on B valid modifiers B imp B inh B sea
✔️ X ✔️ X ✔️ none ✔️ ✔️
✔️ X ✔️ X ✔️ interface ✔️
✔️ X ✔️ X ✔️ base 🏠 ✔️
✔️ X ✔️ X ✔️ final 🏠
✔️ X ✔️ X ✔️ sealed ✔️
🏠❌ X X ✔️ base ✔️
🏠❌ X X ✔️ final
🏠❌ X X ✔️ sealed
any any ✔️ base ✔️
any any ✔️ final
any any ✔️ sealed

If B has multiple relations to other types A, then the flags are merged, to the most restrictive among the possible outcomes (:x: is more restrictive than :house:, which is again more restrictive than :heavy_check_mark:).

Missing combinations require modifiers which are not allowed (a non base/final/sealed subtype of a base/final supertype).

@munificent
Copy link
Member Author

We've talked a bunch about various use cases for being able to control a class's capabilities. In particular, how controlling them (if we're careful!) might let library authors ensure certain invariants about how their code is used. But the proposal itself doesn't directly encode those invariants.

Partially that's because we aren't sure if we exhaustively know all of the invariants a user might want to and be able to encode. (Also, there are entirely valid software engineering use cases for capability control that don't create rigid statically enforced invariants. Just like you might define an abstract class that doesn't actually contain any abstract members.)

I have a hunch that when it comes to extends/implements and base/final/interface, there may really be just two primary invariants:

  • Whether you can't inherit/reuse a class's code in a subtype of it.

  • Whether you must inherit/reuse a class's code in a subtype of it.

That sounds like I'm just restating what extends/implements means, but I think focusing on the invariants might help guide designing the semantics when it comes to entire subtype hierarchies and being able to loosen the restrictions within the current library while still understanding what invariants are being defined.

So here's a stab at defining base/final/interface by focusing directly on the invariants and seeing what mechanical restrictions fall out:

Can't inherit

The "can't inherit" invariant means "T's code can only be reused within the same library as T".

The rule is:

  • It is a compile-time error to extend or mixin a class or mixin marked interface or final.

There's no transitive rule because once a subtype has stopped inheriting from T, there's no way to any further subtypes to restart inheriting from it. The chain has been broken. Thus, this one is simple.

Must inherit

The strict "must inherit" invariant is "any instance of T inherits from T". A library author can opt in to a looser invariant of "any instance of T inherits from some class in T's library" by choosing to implement a "must-inherit" class from their own library.

The rules are:

  • Let the implementation library of a class be:

    1. If the superclass has an implementation library, then that library.

    2. Else if the class is marked base or final, then the library where the class is declared.

    3. Else the class has no implementation library.

  • It is a compile-time error if a class in an implements or on clause has an implementation library and that library is not the same as where the clause appears. This is the basic rule that you must inherit from a class that demands it, except in the library where that requirement is first instilled.

    Note that this is different from the other proposal's rule that the implementing class must be in the same library as the supertype. In particular, by retaining the original implementation library of the superclass, it closes this loophole:

    // a.dart
    base class A {}
    
    // b.dart
    import 'a.dart';
    
    base class B extends A {}
    
    base class C implements B {} // Error.

    Class C is erroneous because while B is in the same library, B's implementation library is "a.dart", which is different from C's library.

  • If a class has a supertype (superclass or superinterface) with an implementation library, then the class must be marked base or final. This accomplishes two things:

    1. It ensures that if you break the strict invariant by implementing a must-inherit class from your own library then you still uphold the loose invariant by giving the implementing class have an implementation library which requires all external subtypes of it to inherit from it.

    2. It makes it clearer from the subtype's declaration that it can't be implemented. You would get an error if you tried to implement it anyway (since it has an implementation library other than its own) but this makes it clearer from looking at the class.

      There are scenarios where this rule is unnecessarily strict in the presence of cyclic imports. The example below upholds the invariant but fails this rule:

      // a.dart
      import 'b.dart';
      
      base class A {}
      
      base class C implements B {}
      
      // b.dart
      import 'a.dart';
      
      class B extends A {}

      I think it's worth prohibiting out weird cases like this to make it easier to tell from looking at a class that you can't implement it.

    A class may have two supertypes with different implementation libraries:

    // a.dart
    base class A {}
    
    // b.dart
    import 'a.dart';
    
    base class B {}
    
    base class C extends A implements B {}

    This is OK. C's implementation library will be A (the "farther" of the two) and C must be marked base or final. There can only be up to two implementation libraries in supertypes and if there is more than one, one will always be the current library, since you can't implement classs with implementation libraries from other libraries.

Both constraints

A library author might want both constraints. This way, they can ensure that every instance of their type was constructed by one of their generative constructors, has the private members they expect, and doesn't have any unexpected method overrides.

The only way for it to be true that "every subtype of T inherits from T" and "every subtype of T does not inherit from T" is if there are no subtypes of T, which is what final accomplishes.

Summary

I think this is similar what Lasse is proposing by defining unimplementable, but my hope was that maybe formulating it like this made it easier to connect the syntax to the intended user goal instead of the invariants emerging (we hope!) from a set of mechanical rules.

Note that this doesn't mean that the only use case for these modifiers is these invariants. You might mark a class base even if the library doesn't need to access private members on instances of class passed in just because it helps guide users towards how you intend the class to be used. But by formulating the modifiers in terms of invariants, I hope it makes it easier to ensure the design does actually secure them.

@lrhn
Copy link
Member

lrhn commented Jan 6, 2023

I think setting up user-understandable goals is good. The phrasing of the actual semantic rules don't have to follow that (I think phrasing in terms of adding or removing capabilities instead of restrictions is easier to work with, because capabilities saying "can" can't conflict, but "must" rules can). It should be possible to connect the dots from the intent to the specification.

(The definition of "implementation library" doesn't account for mixins, so it needs to be extended. A single class declaration can add superclasses with code from more than one library, and it can have - or lack - implementation of more than one interface from the same library).

So the intent is:

  • A base or final class/mixin defines the intent that any subclass outside of this library which implements this interface, must also inherit the implementation from this class or mixin (have this class, or an application of this mixin, in its superclass chain).
  • An interface or final class/mixin defines the intent that the implementation of this class/mixin must not be inherited by any subclass outside of this library.

You can ignore those intents inside the same library.
If you start creating subclasses inside the same library, they can skirt or break those intentions, and can expose public classes which allows others to do it too. That's on you. If you follow some conventions, you can still get similar, but weaker, guarantees (if all the subclasses are also marked at least base, any subclass in other libraries has the implementation of some class or mixin from this library in its superclass chain).
If you do so, we have a lint which warns, and a @reopen annotation to express that it was intentional, and silence the warning.

It's still all about "follow the implementation".

@eernstg
Copy link
Member

eernstg commented Jan 6, 2023

Core Guarantees

@munificent, that's a very nice characterization of the core of the desired constraints!

Those two invariants ("can't inherit" and "must inherit") are also equivalent, I think, to the things that I was checking in the tables I wrote here: The first one is "no other library inherits implementation declared by this class" (to avoid that others have detailed dependencies on 'our' implementation), and conversely that "every object of type A is a subclass of some subtype of A in this library" (in particular, such that private members can safely be called on a receiver of type A).

I think it would be nice if it requires a @reopen to do anything that destroys each of those guarantees (and even nicer if we just don't destroy them ;-).

Tables and Holes

@lrhn, I think the two rulesets we have are converging (mine, yours).

The most obvious difference is that you are using transitive constraints, and I'm only using constraints on the direct superinterface relationship. I'm maintaining the soundness with respect to the provided guarantees by ensuring that every class/mixin declaration preserves the required properties.

This could potentially make your rules more flexible than mine, while still maintaining some useful guarantees (because I don't allow any "bad apples" in the middle of a path in the superinterface graph). The other side of the coin is that local rules are simpler (and perhaps more "self-documenting"), because they never rely on searching through the entire superinterface graph.

Some situations where your rules allow something that I consider unsafe are the following:

// Situation 1, one library L1.
// Table: direct restrictions, same library, row `interface`.

interface class A { void foo() {}}
class B1 extends A {}
base class B2 extends A {}

interface mixin M { void foo() {}}
class B3 extends Object with M {}
base class B4 extends Object with M {}

In this situation, a class outside L1 can be a subclass of B1 or B2, and thus inherit a member implementation from A. This means that the implementation independence guarantee (interface) is silently eliminated. With my ruleset, B1 and B2 must be interface or final, which preserves the guarantee. The mixin cases are similar.

// Situation 2, one library L2.
// Table: direct restrictions, same library, row `final`.

final class A { void foo() {}}
base class B1 extends A {}

final mixin M { void foo() {}}
base class B2 extends Object with M {}

Again, a class outside L2 can be a subclass of B1 or B2, and it would inherit foo, which again means that the implementation independence guarantee is gone (that's the interface part of final). As before, B1 and B2 must be final with my rules, which preserves the guarantee.

(As always, if you want to destroy the guarantees then use @reopen.)

Turning to another row, I'm surprised to see that a mixin M can have a final class A from a different library in its on clause.

// Situation 3, libraries L3a and L3b.
// Table: direct restrictions, other library, row `final`.

// Library L3a, file 'l3a.dart'.

final class A {
  void foo() {}
  void _foo() {}
}

base class A2 extends A {} // Or `implements`.

// Library L3b.
import 'l3a.dart'; // Declares `final class A ...` and a subtype `base class A2`.

final mixin M1 on A { void bar() {}}
base mixin M2 on A { void bar() {}}

final class B1 extends A2 with M1 {} // Or `M2`.
base class B2 extends A2 with M1 {} // Or `M2`.

final class C1a implements B1 {...}
base class C1b implements B1 {...}
base class C2 implements B2 {...}

This means that we have obtained a subtype of the final class A declared outside the library of A, because A2 allows us to (silently!) eliminate the guarantee against that. With my rules, A2 is an error.

Similarly, the declarations named C... allow us to obtain classes that are subtypes of the final class A, but doesn't have _foo.

It looks like there is a need for the old rule 5 from here, that is, the rule which is marked (New). I can't see how that rule is derivable from the tables and text given here.

Finally, I can't see how the treatment of sealed can maintain this exact same discipline: We can have a superinterface graph with a path to a final or base class/mixin if we interject a sealed class:

// Situation 4, libraries L4a and L4b.

// Library L4a, file 'l4a.dart'.
base class A { void _foo() {}}

// Library L4b, file 'l4b.dart'.
import 'l4a.dart';

sealed class B extends A {}
base class C implements B {}

The class C implements A, but it does not have _foo. Again, I think this is accepted silently.

Updated Rules

Here is an adjusted version of the rules that I mentioned earlier.

Sealedness

I believe the treatment of sealed in all proposals until now is an anomaly. We agreed already a while ago that the properties of a class/mixin should be explicit (it is an 'interface' if it has the modifier interface, it doesn't get that property implicitly). However, sealed does not occur together with other modifiers in existing proposals. Also, the transitive restrictions here are just another way to maintain that properties like being 'base' or 'final' are implicitly added to a sealed declaration.

I think we should stick to the rule that properties are shown explicitly, which means that sealed can be specified together with base, final, and interface.

This means that sealed can have a clear and simple purpose: Exhaustiveness. For this, we have one simple error rule: It is a compile-time error if a class, mixin, or mixin class has a direct superinterface which is a sealed declaration in a different library.

That rule is orthogonal to the rules about base, final, and interface, and it just creates confusion and potential loopholes if we insist that they must be kept implicit on a sealed declaration.

Note that sealed base makes sense: We want exhaustiveness, and we want to make invocations of private members sound. The effect of sealed is gone after one step in the superinterface graph whereas the effect of base is maintained an unlimited number of steps, so we shouldn't pretend that one of these is subsumed by the other. Similarly, sealed interface makes sense if we want to specify implementation independence for the entire, sealed type hierarchy. Finally sealed final yields a type hierarchy which is local to the current library (which is by the way what 'sealed' means in some other contexts).

Proposed Constraint Rules

The tables below are similar to the ones I've mentioned earlier, but adjusted to handle mixin applications explicitly.

We consider a class or mixin or mixin class B which is created as a subtype of a class A which is declared in a library L.

The tables below show the constraints on modifiers. 'Any' indicates that B can have any modifiers, none of them is an error; 'Error' indicates that B is an error; 'interface/final' indicates that B must have the modifier interface or the modifier final; 'base/final' indicates that B must have the modifier base or the modifier final; and 'final' indicates that B must have the modifier final.

Treatment of mixin applications

We need to consider the treatment of a mixin application, because a mixin application can appear as the superclass of another class.

Hence, the symbol A below in the cases B extends A may have the form S with M1, .. Mk. In this case, the constraints are taken into account for each of the types S, M1, .., Mk, and the status of being inside or outside L is computed based on the currently processed type (so when we're checking S, we're asking whether B is inside or outside the library where S is declared, and similarly for each Mj, 1 <= j <= k).

In short, when the superclass written as A is of the form S with M1, .. Mk, each of S, M1, .., Mk is checked separately, according to the B extends A rule.

Constraints on subtypes of interface entities

This table is applicable when A has the modifier interface.

Subtype \ Location of B Inside L Outside L
B extends A interface/final Error
B implements A, B on A Any Any

For example:

// Library L1.

interface class A {}
base class B1 extends A {} // Error, must be `interface` or `final`.

// Library L2.
import 'L1';

interface class B2 extends A {} // Error, can't extend A at all.
base class B3 implements A {} // OK, with 'any' modifiers you want.

Constraints on subtypes of base entities

This table is applicable when A has the modifier base.

Subtype \ Location of B Inside L Outside L
B extends A base/final base/final
B implements A, B on A base/final Error

Constraints on subtypes of final entities

This table is applicable when A has the modifier final. Note that this is a simple conjunction: Any final entity is both an interface entity and a base entity, so we must require the satisfaction of the constraints on each of those.

Subtype \ Location of B Inside L Outside L
B extends A final Error
B implements A, B on A base/final Error

@lrhn
Copy link
Member

lrhn commented Jan 6, 2023

Turning to another row, I'm surprised to see that a mixin M can have a final class A from a different library in its on clause.
...
This means that we have obtained a subtype of the final class A declared outside the library of A, because A2 allows us to (silently!) eliminate the guarantee against that.

The on clause is not about inheritance, it doesn't state either way whether the a mixin application inherits member implementation of its on type or not. Only that actual superclass can determine that.
It's not an implementation inheritance relation at all, just a constraint or upper bound on a actual later inheritance relation created at the mixin application.

The reason you can subtype A outside its library is because that library exposes A2. You could also just write class B extends A2 {} and not bother with mixins. That's still a subtype of A.

The mixin declaration changes nothing. If you cannot produce an extensible superclass implementing A that you can apply the mixin to, it won't allow you to create any new class. It might then be a useless mixin, but that doesn't make it invalid. Having no superclass that it can apply to vacuously preserves every restriction on creating subclasses.
(If the on type is base/final/sealed in another library, the mixin must also be one of base/final/sealed, because otherwise the mixin's interface can be implemented, and that'd be bad.)

Sealed

The way we have treated sealed so far is as an alias for final which also causes exhaustiveness-guarantees, and with a different intent of only being a one-level restriction. That doesn't change how it works, it still works exactly as final+exhaustiveness, but it affects whether we'll give reopening-lint-warnings for subclasses.

If you do:

final class A1 { }
base class B1 extends A1 {} // Warning, reallowing extension

interface class A2 {}
base class B2 extends A2 {} // Warning, reallowing extension

sealed class A3 { }
base class B3 extends A3 {} // No warning, there was no promise to disallow extension *transitively*

final class Z4 {}
sealed class A4 extends Z4 {}
base class B4 extends A4 {} // Warning, reallowing extension of *Z4*.

the B3 example shows the difference in intent between final and sealed, even though both prevents any implementation or extension in other libraries (and prevent nothing by themselves in the current library).
The final has an intent of "no subclasses" and sealed just means "no immediate subclasses". They have the same effect on what you can do to that class in other libraries (nothing).

Or, in other words, we are being explicit. Every modifier (or lack of modifier) states precisely what other libraries can do with that declaration. We don't need to combine sealed and base because sealed prevents everything, just like final, and adds exhaustiveness on top.

We also treat interface and base differently, because they have different transitive properties.

interface class A {}

// Warning about exposing implementation of `interface` clas.
class A2 extends A {}
// No warning, removing `interface` doesn't matter when we don't inherit implementation
class A3 implements A {}

base class B {}

// Both warns about reopening for `implements`, whether you inherit implementation or not.
class B2 extends B {}
class B3 implements B {}

And inside the same library, we can still allow you to ignore all the restrictions in subclasses. Can can choose to require subtypes of "cannot implement" classes to be base/final/sealed, but only because it makes it easier to reason about things for both us an users, not because it's technically necessary. Every subclass is then also explicit about what you can do with it.

Whether those individual class restrictions add up to a consistent invariant depends on how you combine them.
The lint + @reopen marker is intended to help you reach a set of consistent invariants, and to know when you don't.
But it's all up to the author, we can allow any combination.

We must just make sure that you cannot also ignore constraints from other libraries when you ignore the constraints of your own library, which is why I have the transitive rules.

@eernstg
Copy link
Member

eernstg commented Jan 7, 2023

@lrhn wrote:

It's not an implementation inheritance relation at all

Exactly, and that's the reason why I'm writing 'obtained a subtype', rather than something like 'inherited a method implementation'.

The reason you can subtype A outside its library is because that library exposes A2

Yes, and consequently the declaration of A2 should be considered to be a reopening, and hence we should have a rule to detect it.

But the broader issue is: If we actually establish a guarantee (modulo @reopen) that a final declaration A has no non-bottom subtypes outside its declaring library L then a mixin with A as an on-type is inherently impossible to apply outside L (because then we've just violated the guarantee that we just assumed we had). So the only way it could be meaningful to have such mixin declarations outside L is in the situation where that guarantee does not exist.

We cannot expect the maintainer of a library to study every detail of the subtype/inheritance hierarchies in that library in order to detect whether or not they can trust any particular guarantees. If we let this happen silently then final is a mere nuisance: It will pester developers by saying "You can't do that" in various situations, but they get nothing in return. In other words, the community advice will rightfully be "Don't use that".

@lrhn
Copy link
Member

lrhn commented Jan 7, 2023

But the broader issue is: If we actually establish a guarantee (modulo @reopen) that a final declaration A has no non-bottom subtypes outside its declaring library L then a mixin with A as an on-type is inherently impossible to apply outside L (because then we've just violated the guarantee that we just assumed we had). So the only way it could be meaningful to have such mixin declarations outside L is in the situation where that guarantee does not exist.

Agree. I did not intend that guarantee to exist. We could make it, but I didn't find it necessary.
What I did intend was that any class which implements a final type F from another library must also implement another, non-final type S from the same library as F, such that S <: F.
You cannot create instances implementing F (which is-an F) unless the library of F gives you a way.
We can't promise that it won't give you such a way, so we can't say that the mixin is useless.

Being able to create a type which is a subtype of F is not a problem, if the type is empty, and it doesn't provide an implementable interface.
And if it's not empty, it means the library of F has provided you with a viable extensible class or mixin implementing F, so the mixin has a class it can be applied to. That class could also just be extended directly, the mixin changes nothing.

One of the reasons I don't want to make too many restrictions is that it makes it easier to handle sealed the way it's current defined. (And I'm fine with that way.)
Here's a use-case for allowing on types to ignore restrictions:

Someone defines a sealed class Widget { sharedStuff } with two subclasses class StatelessWidget extends Widget { simpleStuff } and class StatefulWidget extends Widget { stateBasedStuff }.
The subclasses are completely open. You can implement them however you want, but every widget, no matter how it's created, must be either stateful or stateless.

I'd very much want to be able to create a mixin on Widget. Widget is sealed and cannot be extended or implemented outside of its declaring library, but a mixin does neither, because it is not a class, it has no instances by itself.
And because Widget is sealed, every actual subclass will be either a StatelessWidget or a StatefulWidget. And the mixin can be applied to any such subclass, and the result will again have one of those two types.

This relies on sealed having a slightly different intent than final, but you can do the same with final, if you don't want to add exhaustiveness promises on top.

@eernstg
Copy link
Member

eernstg commented Jan 8, 2023

@lrhn wrote, about sealed declarations:

it still works exactly as final+exhaustiveness

Well, sealed introduces different constraints and doesn't have the ones of final. I'd say that they are completely different concepts. Anyway, it really doesn't matter how different we think they are if we can agree on how they should work. And it seems that we agree almost perfectly!

Here is your final/base example, unchanged:

final class A1 { }
base class B1 extends A1 {} // Warning, reallowing extension

interface class A2 {}
base class B2 extends A2 {} // Warning, reallowing extension

sealed class A3 { }
base class B3 extends A3 {} // No warning, there was no promise to disallow extension *transitively*

final class Z4 {}
sealed class A4 extends Z4 {}
base class B4 extends A4 {} // Warning, reallowing extension of *Z4*.

Those warnings occur in exactly the situations where my tables said they should occur.

I just don't agree that it's a good idea to omit final from class A4, I'd very much prefer that we stick to the model where each class lists its properties explicitly. You do admit that B3 should be treated differently than B4, and this implies that there is an implicit difference between the status of A3 and A4. The obvious way to avoid that spooky action at a distance is to say that we should declare it as sealed final class A4 ....

The situation is the same with the interface examples: Your warnings occur in the situations where my tables say they should occur.

Wow! Apart from the bit about representing every property explicitly, I just don't see any disagreement! 😄

(OK, I'd prefer to not have @reopen at all, and to turn the warnings into errors. But I know I'm basically the only person who wants to give such strict guarantees, and developers can still trust the guarantees if there are no warnings and a search for @reopen returns zero matches).

@lrhn
Copy link
Member

lrhn commented Jan 9, 2023

I'm considering the modifier rules independently of the lint+@reopen warnings. We add those on top, but you don't have to enable the lint, so the rules must work anyway.

The modifiers alone restricting what other libraries can do (extend, implement, mix-in) to the declared class or mixin.
Declarations in the same library can ignore the modifiers.

We have the desired guarantee that if a library exposes a base declaration T (and no interface or no-modifier subclass), no other library must be able to implement the interface of that declaration without also inheriting implementation from the original library. Meaning no implements of an interface which has the interface of T as as superinterface.
We added that as an extra, transitive rule.

We also decided to add the extra rule that any subclass of a base or final class must itself be base or final. It makes reasoning easier for us and users, because it removes most of the edge-cases of the rule above. It isn't strictly necessary, but it makes things easier. With the rule above, it also closes all the potential mixin/on holes (#2757).

I think we agree on the result of this, but let me write it up.

Then we also want a lint which warns if you "reopen" a declaration in violation of its expected spirit.
That's less well-defined, because it depends on which intent we assign to each modifier.
I'm mainly worried about assigning any intent to final - it already prevents others from creating direct subclasses, so if you create any, we'll have to assume you know what you're doing.

Anyway, another writeup:

Modifier semantics

We define four modifiers on class and mixin declarations which allow you to prevent other libraries from some combinations of extending, mixing-in and implementing the class or mixin: interface, base, final and sealed, where each class or mixin declaration can have at most one of these before the class or mixin keyword. (Also, a class declaration can add mixin before the class keyword, in which case it cannot have extends or with clauses.)

With no modifier, other classes can freely extend classes, mix-in mixins and implement their interface (as long as all other requirements are satisfied, like a superclass needing to have a public generative constructor). With a modifier, one of base, interface, final or sealed, declarations in other libraries are inhibited as follows:

Let S be a class or mixin declaration in library L.

Let C be a class declaration in a library other than L. (Includes mixin class declarations, but those cannot have extends or with clauses.)

Let M be a mixin declaration in a library other than L. (Also includes mixin class declarations, but those cannot have on clauses.)

It's a compile-time error if C has an extends T clause, T denotes the class declaration S, and S has an interface, final or sealed modifier.

It's a compile-time error if C has an with ..., T, ... clause, T denotes the mixin declaration S, and S has an interface, final or sealed modifier.

It's a compile-time error if C or M has an implements …, T, … clause, T denotes the class or mixin declaration S, and S has a base, final or sealed modifier.

The restrictions do not apply to declarations in the same library.

That is: A class or mixin declaration can extend, mix-in or implement another class or mixin declaration in the same library without regard for any interface, base, final or sealed modifier. Those only matter to other libraries.

Per the last language-team meeting, we add the "base-propagation" restriction that:

It's a compile-time error if a class or mixin D, in any library, any superinterface S of D has a declaration with a final or base modifier, and the declaration of D does not have a base, final or sealed modifier.

This applies to all superinterfaces, not just immediate ones, including the ones from on-types of mixins. It applies whether both declarations are in the same library or not. You can implement a base class inside its own library, but the result must still be marked at least base.

We assign an intent to each modifier, which relates to the concrete member implementation of the declaration. It's the primary use-case supported by that modifier.

  • interface: The implementation must not be inherited by subclasses outside of this library.
  • base: The implementation must be inherited by all subclasses outside of this library.
  • final: There are no immediate subclasses not known to this library.
  • sealed: There will be a finite non-empty set of known immediate subtypes, all declared in this library.

We then add further rules to ensure that these intents are satisfied, unless explicitly broken by another declaration inside the same library.

That is, the intents are not promises, they are use-cases that we support, and that you can achieve by following simple rules for adding modifiers on declarations in the same library. We make sure that other libraries cannot break these use-case without the original library's consent and cooperation.

The interface modifier prevents implementation from being inherited. A class in another library cannot extend an interface class or mix in an interface mixin. If a class or mixin in another library implements the interface of the interface class or mixin, it does not inherit the implementation. There is no further requirements on the subclass or mixin because of this, because it cannot accidentally pass on an implementation that it doesn't have. If a class in the same library inherits implementation and drops the interface modifier, then that's allowed. Other libraries can then freely extend that class.

The base modifier requires implementation being inherited in subclasses. That's a transitive property, so we require that any subclass inherits the implementation, and such a subclass must also pass that implementation on to its own subclasses. The base-propagation rule requires all subtypes to be base, final or sealed, which means they cannot be implemented by other libraries. We also have to ensure that they are not implemented in their own library, if that's another library than the original base declaration. This closes the base-class loop-hole.

It's a compile-time error if C or M has an implements …, T, … clause, T denotes a type V, V has S as a superinterface, and S has a base or final modifier.

Because of the base-propagation rule above, V must have a base, final or sealed modifier. For C/M to implement it, V must be in the same library as C/M, a library other than L. This rule overrides the general rule that you can ignore restrictions from modifiers inside the same library, because the restrictions against implementing S takes precedence over allowing you to implement V.

This is one rule which is made easier by the base-propagation rule. There cannot be a non-base subtype of a base-type that it is possible to implement from another library.

Further, to close the mixin-loophole:

It's a compile-time error if M has an on ..., T, ... clause, T denotes S, the declaration of S has a sealed modifier, and the declaration of M does not have a base, final or sealed modifier.

This ensures that if a mixin has an on type that does not have an implementable interface, the mixin does not get an implementable interface. In the rules above we only did that for base and final declarations, because sealed doesn't imply restrictions extended further than immediate subclasses. The sealed type cannot be directly extended, mixed-in or implemented from another library, but it can be denoted as a supertype constraint on a mixin, and that would make the mixin implement the sealed type. The mixin must not expose an implementable interface, because then it will be possible to create a subtype of the sealed type, which is not also a subtype of one of the subclasses in the sealed type's library. The only thing that the mixin can then be used for is to apply it to an existing subtype of the sealed type.

Linting

A subclass can ignore modifiers of superclasses in the same library. They can also declare their own modifier almost independently, only restricted by the base-propagation rule.

If a subclass has a less restrictive modifier than the superclass (other than a sealed superclass), we say that the subclass is reopening the superclass. That's allowed, but not necessarily desired in all cases. Because of that, we introduce a lint that warns if a reopening goes against the stated intents above.

These only apply when subclass and superclass are inside the same library.

We say that a declaration D disallows inheritance if the declaration has an interface modifier, or if D has a final or sealed modifier, D has an extends T or with …, T, … clause where T denotes a class or mixin which disallows inheritance.

A lint warning occurs if C has an extends T or with ..., T, ... clause where T denotes a class or mixin D declared in the same library as C, D disallows inheritance, and C does not have an interface, final or sealed modifier,

We say that a class or mixin D requires inheritance if D has a base modifier, or D has a final or sealed modifier, D has an extends T or with …, T, … clause where T denotes a class or mixin which requires inheritance.

A lint warning occurs if C has an extends T or with …, T, … clause where T denotes a class or mixin D declared in the same library as C, D requires inheritance, and C does not have a base, final or sealed modifier.

Maybe: A lint warning occurs if C has an implements …, T, … clause where T denotes a class or mixin in the same library which requires inheritance.

This warns if the intent of a base or interface modifier is not respected by a subclass, by allowing other classes to inherit an interface class or mixin's implementation, or allowing other classes to implement a base class or mixin's interface.

There is no rule to warn if a final type has a subtype.

We can, but might not want to, add:

We say that a class or mixin D disallows subtyping if D has a final modifier, or if D has a sealed modifier, D has an extends T or with …, T, … or implements …, *T*, … clause where T denotes a class or mixin which disallows subtyping.

A lint warning occurs if C has an extends T or with …, T, … or implements …, *T*, … clause where T denotes a class or mixin D declared in the same library as C, D disallows subtyping, and C does not have a final or sealed modifier.

This would warn if a final type has any non-final subtype. There may be reasonable use-cases for having such. It's unclear how many false positives it would cause. (Read: I'm not entirely certain about the intent assigned to final, if someone inside the same library creates subtypes anyway.)

The lint warning can be silenced by adding a @reopen annotation or an // ignore: no_reopen (or what the lint will be named) comment.

The lint does not distinguish between public and library-private declarations. It could be argued that a private declaration cannot expose anything to other libraries. However, if you can removing a restriction in a private declaration, without any warning, it's not possible to determine whether that was an accident or a deliberate choice, and then it will be harder to warn correctly for any public subclasses of the private class.

@eernstg
Copy link
Member

eernstg commented Jan 17, 2023

@lrhn, this looks very good!

There's just one thing left, which is about orthogonality and consistency. I think the specification (and the feature) would be simpler and more consistent if we treat sealed as a completely separate mechanism.

I'll refer to your proposal as the current proposal, and the proposal I'm making here as the proposal about orthogonal sealedness.

With orthogonal sealedness we would then do as follows:

  • Syntactically, sealed can be combined freely with interface/base/final, we just maintain that abstract sealed is an error (because sealed implies abstract).
  • The only applicable rule about a sealed class or mixin is that it is a compile-time error to be an immediate subtype of a sealed declaration in a different library.

There is no interaction between sealed and the modifiers base/interface/final, and hence no need to mention sealed in any rule about the latter. This enables declarations that are sealed interface class, sealed base mixin etc, (but not sealed interface base class—we are not allowing any new combinations of interface/base/final, we're just pulling out sealed such that it's handled separately).

The properties interface/base/final would now be declared explicitly on every declaration where they are enabled, because all the rules of the form "error/lint ... unless the subtype has the modifier base, final, or sealed" would now be "... unless the subtype has the modifier base or final".

This is different from the current proposal where the use of sealed prevents the use of interface/base/final, but transitive rules ensure that the latter are still treated as if they were present, if, roughly, they are present in a superinterface. We can now avoid the transitive rules because every property is specified explicitly at every level in the subtype graph.

Here are some reasons why this is an improvement:

We agreed already a long time ago that it would be helpful for a reader of the code if every class modifier is present syntactically in every case where it is enabled semantically. With orthogonal sealedness that is true for sealed classes, which is not the case with the current proposal. This will arguably improve the readability of the declarations, because their properties can be seen locally, they don't have to be looked up somewhere in the superinterface graph.

With the current proposal, it may be necessary to introduce an artificial supertype in order to obtain a specific set of properties for a given sealed class. With orthogonal sealedness it can be declared directly:

// Current proposal.

sealed class A extends Abase {...} // To enable the `base` rules in the subtype hierarchy ...
base class Abase {} // ... we introduce this extra class, just so we can say `base`.

// With orthogonal sealedness.

sealed base class A {...} // Same thing: Simple and readable.

Another issue is that a sealed class can't reopen anything in the current proposal, but we can do that with orthogonal sealedness:

// With orthogonal sealedness.

final class A {}

@reopen // Relax the `final` discipline to a `base` discipline.
sealed base class B extends A {...}

base class C1 extends B {...} // We get a heads-up if we forget `base`.
base class C2 implements B {...} // Same freedom as in the current proposal: can use `implements`.

Here's a way to express approximately the same thing using the current proposal:

// Current proposal.

final class A {}

// Can't express reopening to `base`, do it repeatedly below.
sealed class B extends A {...}

@reopen
base class C1 extends B {...}
@reopen
base class C2 implements B {...}

It would again be possible to use an artificial @reopen base class Bbase extends A {} as a superclass of B, but that's again an indirect and not so readable way to express the reopening to base.

@munificent
Copy link
Member Author

I believe we have fully closed the door with the current design.

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.
Projects
None yet
Development

No branches or pull requests

3 participants