Class modifiers
Author: Bob Nystrom, Lasse Nielsen
Status: Accepted
Version 1.8
Experiment flag: class-modifiers
This proposal specifies four modifiers that can be placed on classes and mixins to allow an author to control whether the type allows being implemented, extended, and/or mixed in from outside of the library where it's defined.
Informally, the new syntax is:
-
No modifier: Mostly as today where the class or mixin has no restrictions, except that we no longer allow a class to be used as a mixin by default.
-
base
: As a modifier on a class, allows the class to be extended but not implemented. As a modifier on a mixin, allows it to be mixed in but not implemented. In other words, it takes away being able to implement the interface of the declaration. This also applies transitively to all subtypes, since implementing a subtype also means implementing the superinterface. -
interface
: As a modifier on a class or mixin, allows the type to be implemented but not extended or mixed in. In other words, it takes away being able to inherit from the type. -
final
: As a modifier on a class or mixin, prohibits extending, implementing, or mixing in. -
mixin class
: A declaration that defines both a class and a mixin.
This proposal is a blend of a few earlier proposals:
The type modifiers document has some motivation and discussion around defaults and keyword choice which may be a useful reference. Unlike that proposal, this proposal is mostly non-breaking.
Motivation
Dart's ethos is to be permissive by default. When you declare a class, it can be constructed, subclassed, and even exposes an implicit interface which can be implemented—a feature (possibly) unique to Dart. Users generally appreciate this flexibility and the power it places in the hands of library consumers.
Why might the author of a class or mixin author want to remove capabilities? Doesn't that just make the type less useful? The type does end up more restricted, but in return, there are more invariants about the type that the type author and users can rely on being true. Those invariants may make the type easier to understand, maintain, evolve, or even just to use.
Here are some use cases where restricting capabilities may lead to more robust software:
Adding methods
It's a compile-time error to have an implements
clause on a non-abstract
class unless it contains definitions of every member in the type that you claim
to implement. This is a useful error because it ensures that any member
someone can access on a type is actually defined and will succeed. It helps you
in case you forget to implement something.
But it also means that if a new member is added to a class then every single class implementing that class's interface now has a new compile-time error since they are very unlikely to coincidentally already have that member.
This makes it hard to add new members to existing public types in packages. Since anyone could be implementing that type's interface, any new member is potentially a breaking API change which necessitates a major version bump. In practice, many API authors just document "please don't implement this class" and then rely on users to not do that.
However, for widely used packages, that polite agreement isn't sufficient. Instead, they are simply prevented from adding new members and APIs get frozen in time.
If a type disallows being implemented, then it becomes easier to add new members without worrying about breaking existing users. If the type also prevents being extended, then it's entirely safe to add new members to it. This makes it easier to grow and evolve APIs.
Unintended overriding
Most types contain methods that invoke other methods on this
, for example:
class Account {
int _balance = 0;
bool canWithdraw(int amount) => amount <= _balance;
bool tryWithdraw(int amount) {
if (amount <= 0) {
throw ArgumentError.value(amount, "amount", "Must be positive");
}
if (!canWithdraw(amount)) return false;
_balance -= amount;
return true;
}
// ...
}
The intent is that _balance
should never be negative. There may be other code
in this class that breaks if that isn't true. However, there's nothing
preventing a subclass from doing:
class BustedAccount extends Account {
// YOLO.
bool canWithdraw(int amount) => true;
}
Extending a class gives you free rein to override whatever methods you want,
while also inheriting concrete implementations of other methods that may
assume you haven't done that. If we prevent Account
from being subclassed,
we ensure that when tryWithdraw()
calls canWithdraw()
, it calls the actual
canWithdraw()
method we expect.
Note that it's not necessary to prevent implementing Account
for this use
case. If you implement Account
, you inherit none of its concrete
implementation, so you don't end up with methods like tryWithdraw()
whose
behavior is broken.
Safe private members
Consider:
class Account {
int _balance = 0;
bool tryTransfer(int amount, Account destination) {
if (amount > _balance) return false;
_balance -= amount;
destination._balance += amount;
}
// ...
}
What would happen here if a class from another library implemented Account
and
was passed as destination
to tryTransfer()
? When you implement a class's
interface from outside of the library where it's defined, none of its private
members are part of that interface. (If they were, you couldn't implement them.)
This isn't a widely known corner of the language, but if you try to access a
private member on a object that doesn't implement it (because the class only
implements the public part of the interface being implemented), Dart throws a
NoSuchMethodException
at runtime.
In general, it's not safe to assume any object coming in to your library actually has the private members you expect, because it could be an outside implementation of your class's interface.
Dart users generally prefer to catch bugs at compile time. If we could prevent
other libraries from implementing the Account
class's interface, then we
could be certain that any Account
passed to tryTransfer()
would be an
instance of our Account
class (or a subclass of it) and thus be ensured
that all private members we expect are defined.
Guaranteed initialization
Here's another example:
/// Assigns a unique ID to each instance.
class Handle {
static int _nextID = 0;
final int id;
Handle() : id = _nextID++;
}
class Cache {
final Map<int, Handle> _handles = {};
void add(Handle handle) {
_handles[handle.id] = handle;
}
Handle? find(int id) => _handles[id];
}
The Cache
class assumes each Handle
has a unique id
field. The Handle
class's constructor ensures that (ignoring integer overflow for the moment).
This even works if you subclass Handle
, since the subclass's constructor must
chain to and run the superclass constructor on Handle
.
But if an unrelated type implements Handle
's interface, then there's no
guarantee that every instance of Handle
has actually gone through that
constructor.
If the constructor is doing validation or caching, you might require that all instances of the type have run it. But if the class's interface can be implemented, then it's possible to route around the constructor and break the class's invariants.
Guardrails and intention
The previous sections show concrete, mechanical reasons why you might want to remove type capabilities in order to enforce invariants and prevent bugs or crashes.
But there are softer reasons to remove capabilities too. You may simply not intend a type to be used in certain ways. There may be better ways for a user of your API to solve their problem. Removing a capability helps guide them towards how your API is supposed to be used.
It can make it simpler and easier to evolve your API. That in turn makes you more productive, which lets you improve your API in ways that also directly benefit your users.
Restrictions within the same library
The previous sections show why you might want to prevent a type from being extended or mixed in outside of the library where it's defined. But what about within the same library? If it's my type, and I choose to prevent outside code from extending or implementing it, can I ignore those restrictions within my own library?
A closely related modifier we are working on is sealed
. This works
in concert with the new pattern matching features to let you define a closed
family of subtypes used for exhaustiveness checking. You put sealed
on a
supertype. Then you are only allowed to directly extend, implement, or mix in
that supertype from within the same library.
In return for that restriction, in a switch, if you cover all of those subtypes, then the compiler knows that you have exhaustively covered all possible instances of the supertype. This is a big part of enabling a functional programming style in Dart.
The sealed
modifier prevents direct subtyping from outside of the library
where the sealed type is defined. But it doesn't prevent you from subtyping
within the same library. In fact, the whole point of sealed
is to define
subtypes within the same library so that you can pattern match on those to cover
the supertype.
Extending non-extensible classes in the same library
Preventing a class from being extended gives you an important invariant: Calls
to members on this
from within that class won't end up in overrides you don't
control. This invariant remains even if we let you extend the class in the same
library. Calls to those members may end up in overrides, but they will be
overrides you yourself wrote in that same library.
Extending non-extensible classes is also really useful in API design. It lets you offer a class hierarchy to users that is closed to further extension.
Consider the earlier example where you have a Shape
base class and a couple of
subclasses. Let's say you also have code in that library for performing
intersection tests on pairs of shapes. That intersection code needs special
support for each pair of types: square and square, square and circle, circle
and circle. That means it would be hard to correctly support users adding their
own new subclasses of Shape
and passing them to the library.
As the shape library author, you want to subclass Shape
yourself so that you
can define Square
and Circle
, but disallow others from doing so. (In this
specific example, you probably also want to prohibit Shape
from being
implemented too.)
Implementing non-implementable types in the same library
A key invariant you get by preventing a type from being implemented is that it becomes safe to access private members defined on that type without risking a runtime exception. You are ensured that any instance of the type is an instance of a type from your library that includes all of its private members.
This invariant is still preserved if we allow you to implement the type from
within the same library. When you implement a type inside its library, the
private members are part of the interface. So any type implementing it must
also define those private members and you'll never hit a
NoSuchMethodException
.
Transitive restrictions
The previous two sections suggest that we can ignore extends and implements restrictions within the same library, and I think there are compelling use cases for why we should, at least for extends, if not both.
If we do, what restrictions do those secondary types have? Let's say I write:
interface class NoExtend {}
class MySubclass extends NoExtend {}
The interface
modifier means that NoExtend
can only be implemented outside
of this library and not extended. We ignore the restriction internally and
extend it with MySubclass
, which doesn't have any modifiers. What capabilities
does MySubclass
now expose externally? We have a few options:
-
Inherit restrictions. We could say that
MySubclass
implicitly gets aninterface
modifier which it inherits fromNoExtend
. This way, if you add a restriction to some type and temporarily ignore it, the language continues to enforce that restriction externally all throughout the subtype hierarchy.This means that you cannot just look at a single type declaration to see what you're allowed to do with it. You have to walk up the hierarchy looking for modifiers. I think it's important for users to be able to quickly tell what they can do with a type just by looking at its declaration, so I don't like this.
-
No inherited restrictions. The simplest option is to say that each type gets whatever restrictions you put on it. Since
MySubclass
has no modifiers, it has no restrictions. That's what you wrote, so that's what you get. If that's not what you want, then you should put a modifier on it.I like the simplicity of this. I think it's consistent with the rest of Dart which is permissive by default. Right now, you can make a class effectively
interface
by giving it only private generative constructors. Since there's no way for a class outside of the library to call one of those constructors, it cannot be extended externally. But you could subclass it inside the library with a new class that calls that private generative constructor from its own public one. That subclass is now externally extensible and the language quietly lets you do that. -
Disallow removing restrictions. We could say that you can ignore a type's restrictions within the same library, but any types that do that must have the same restrictions as the type they extend or implement. So if you implement a class marked
base
in the same library, that implementing class must also be markedbase
orfinal
.This avoids any confusion about whether a subtype removes a restriction. But it comes at the expense of flexibility. If a user wants to remove a restriction, they have no ability to.
This would contrast with
sealed
where you can have subtypes of a sealed type that are not themselves sealed. This is a deliberate choice because there's no need for the direct subtypes of a sealed to be sealed in order for exhaustiveness checking to be sound. Since exhaustiveness is the goal and Dart is permissive by default, we allow subtypes of sealed types to be unsealed.It also prevents API designs that seem reasonable and useful to me. Imagine a library for transportation with classes like:
abstract final class Vehicle {} class LandVehicle extends Vehicle {} class AquaticVehicle extends Vehicle {} class FlyingVehicle extends Vehicle {}
It allows you to define new subclasses of the various modalities. You can add cars, bikes, canoes, and gliders to it. But it deliberately does not want to support adding entire new modalities by extending
Vehicle
directly. You cannot add vehicles that, say, fly through space because the library isn't designed to support that.If we require subclasses to have the same restrictions, then there's no way to make
Vehicle
final
while allowingLandVehicle
and friends to be extended. -
Trust but verify. In the earlier example, it's not clear what the author intends. Maybe they deliberately didn't put any modifiers on
MySubclass
because they want to re-add the capability that its superclass removed. But maybe they just didn't notice thatNoExtend
removed them, or they forgot to putinterface
onMySubclass
.Since it's not clear what they meant, the language could require them to clarify. If you define a subtype of a type that has removed a capability, we could require you to annotate specifically when you re-add that capability. If you don't intend to re-add a capability, you restate the restriction:
interface class NoExtend {} interface class MySubclass extends NoExtend {}
And if you do intend to loosen it, you make that explicit by some marker like:
interface class NoExtend {} reopen class MySubclass extends NoExtend {}
Here "reopen" means, "I know I didn't put any other modifier here and that means this class has more capabilities than my parent."
Personally, I think this is probably more modifiers than we want and is more trouble than it's worth. I worry about having to explain to users that a class marked
reopen
means the same thing as a class not marked with it. But I do think it could be useful to offer this as a lint with a metadata annotation for users that are more cautious, like:interface class NoExtend {} @reopen class MySubclass extends NoExtend {}
This proposal takes the last option where types have exactly the restrictions they declare but a lint can be turned on for users who want to be reminded if they re-add a capability in a subtype.
Inherited restrictions
Allowing you to ignore restrictions on your own types allows some useful architectural patterns, but it's important that doing so doesn't let you ignore restrictions on types from other libraries because then you could break the invariants the library expects. In particular, consider:
// lib_a.dart
base class A {
void _private() {
print('Got it.');
}
}
callPrivateMethod(A a) {
a._private();
}
This library declares a class and marks it base
to ensure that every instance
of A
in the program must be an A
or a class that inherits from it. That in
turn ensures that the call to _private()
in callPrivateMethod()
is always
safe.
Now consider:
// lib_b.dart
import 'lib_a.dart';
base class B extends A {} // OK: Inheriting.
class C implements B {} // OK: Ignoring restriction on own type B.
These two class declarations each seem to be fine. But put together, the result
is a class C
that is a subtype of A
but doesn't inherit from it and doesn't
have the _private()
method that lib_a.dart expects.
So we want to allow libraries to ignore restrictions on their own types, but we
need to be careful that doing so doesn't break invariants in other libraries.
In practice, this means that when a class opts out of being implemented using
base
or final
, then that particular restriction cannot be ignored.
Mixin classes
In line with Dart's permissive default nature, Dart allows any class declaration
to also be used as a mixin (in spec parlance, it allows a mixin to be "derived
from a class declaration"), provided the class meets the restrictions that
mixins require: Its immediate superclass must be Object
and it must not
declare any generative constructors.
In practice, mixins are quite different from classes and it's uncommon for users
to deliberately define a type that is used as both. It's easy to define a class
without intending it to be used as a mixin and then accidentally forbid that
usage by adding a generative constructor or superclass to the class. That is a
breaking change to any downstream user that had that class in a with
clause.
Using a class as a mixin is rarely useful, but it is sometimes, so we don't want
to prohibit it entirely. We just want to flip the default since allowing all
classes to be used as mixins makes them more brittle with relatively little
upside. Under this proposal we require authors to explicitly opt in to allowing
the class to be used as a mixin by adding a mixin
modifier to the class:
class OnlyClass {}
class FailUseAsMixin extends OtherSuperclass with OnlyClass {} // Error.
mixin class Both {}
class UsesAsSuperclass extends Both {}
class UsesAsMixin extends OtherSuperclass with Both {} // OK.
Syntax
This proposal builds on the existing sealed types proposal so the grammar
includes those changes. The full set of modifiers that can appear before a class
declaration are abstract
, sealed
, base
, interface
, final
, and
mixin
. Only the base
modifier can appear before a mixin
declaration.
The modifiers do not apply to other declarations like enum
, typedef
, or
extension
.
Many combinations don't make sense:
base
,interface
, andfinal
all control the same two capabilities so are mutually exclusive.sealed
types cannot be constructed so it's redundant to combine withabstract
.sealed
types already cannot be mixed in, extended or implemented from another library, so it's redundant to combine withfinal
,base
, orinterface
.mixin
as a modifier can obviously only be applied to aclass
declaration, which makes it also introduce a mixin.mixin
as a modifier cannot be applied to a mixin-applicationclass
declaration (theclass C = S with M;
syntax for declaring a class). The remaining modifiers can.- A
mixin
ormixin class
declaration is intended to be mixed in, so its declaration cannot have aninterface
,final
orsealed
modifier. - A
mixin
declaration cannot be constructed, soabstract
is redundant. enum
declarations cannot be extended, implemented, mixed in, and can always be instantiated, so no modifiers apply toenum
declarations.
The remaining valid combinations and their capabilities are:
Declaration | Construct? | Extend? | Implement? | Mix in? | Exhaustive? |
---|---|---|---|---|---|
class |
Yes | Yes | Yes | No | No |
base class |
Yes | Yes | No | No | No |
interface class |
Yes | No | Yes | No | No |
final class |
Yes | No | No | No | No |
sealed class |
No | No | No | No | Yes |
abstract class |
No | Yes | Yes | No | No |
abstract base class |
No | Yes | No | No | No |
abstract interface class |
No | No | Yes | No | No |
abstract final class |
No | No | No | No | No |
mixin class |
Yes | Yes | Yes | Yes | No |
base mixin class |
Yes | Yes | No | Yes | No |
abstract mixin class |
No | Yes | Yes | Yes | No |
abstract base mixin class |
No | Yes | No | Yes | No |
mixin |
No | No | Yes | Yes | No |
base mixin |
No | No | No | Yes | No |
The grammar is:
classDeclaration ::= (classModifiers | mixinClassModifiers) 'class' typeIdentifier
typeParameters? superclass? interfaces?
'{' (metadata classMemberDeclaration)* '}'
| classModifiers 'mixin'? 'class' mixinApplicationClass
classModifiers ::= 'sealed'
| 'abstract'? ('base' | 'interface' | 'final')?
mixinClassModifiers ::= 'abstract'? 'base'? 'mixin'
mixinDeclaration ::= 'base'? 'mixin' typeIdentifier typeParameters?
('on' typeNotVoidList)? interfaces?
'{' (metadata classMemberDeclaration)* '}'
Static semantics
The modifiers introduce restrictions on which other declarations can depend on the modified declaration, and how. To express this, we first introduce some terminology that makes it easy to express the relations between declarations.
Terminology.
We distinguish libraries by whether they have this feature enabled, and whether they are platform libraries.
-
A pre-feature library is a library whose language version is lower than the version this feature is released in.
-
A post-feature library is a library whose language version is at or above the version this feature is released in.
-
A platform library is a library with a
dart:...
URI. A platform library is always a post-feature library in an SDK supporting the feature, but for backwards compatibility, pre-feature libraries may ignore some modifiers in platform libraries, as if the library was also a pre-feature library.
We define the relations between declarations and the other declarations they are declared as subtypes of as follow.
-
A declaration S is the declared superclass of a
class
declaration D iff:- D has an
extends T
clause andT
denotes S. - D has the form
... class ... = T with ...
andT
denotes S.
A type clause
T
denotes a declaration S ifT
of the formid
orid<typeArgs>
, and id is an identifier or qualified identifier which resolves to S, or which resolves to a type alias with a right-hand-side which denotes S. _(This allows us to refer to the "declared superclass" uniformly across mixin-applicationclass
declaration and a "normal"class
declaration, even though the former cannot have anyextends
clause. Aclass
declaration has at most one declared superclass declaration, it can have none if it's a non-mixin application declaration with noextends
clause.) - D has an
-
A declaration S is a declared mixin of a
class
orenum
declaration which has awith T1, ..., Tn
clause where any ofT1
,...,Tn
denotes S. -
A declaration S is a declared interface of a
class
,mixin class
,mixin
orenum
declaration which has animplements T1, ..., Tn
clause where any ofT1
,...,Tn
denotes S. -
A declaration S is a declared
on
type of amixin
declaration which has anon T1, ..., Tn
clause where any ofT1
,...,Tn
denotes S.
We need these independently, but we also need the union of these relations, capturing that a declaration depends directly on another in any way.
- A declaration S is a direct superdeclaration of a declaration D
iff S is a declared superclass, mixin, interface or
on
type of D.
We then define the transitive closure of this relation, expression that a declaration depends on another through any number of intermediate declarations.
- A declaration S is a proper superdeclaration of a declaration D iff either S is a direct superdeclaration of D, or there exists a declaration P such that P is a direct superdeclaration of D and S is a proper superdeclaration of P.
The language prevents dependency cycles in declarations, because cycles prevent subtyping from being well-defined. Because of that, the "proper superdeclaration" relation is a directed acyclic relation. Or alternatively, we could write the rule against cycles as it being a compile-time error if any declaration S is a proper superdeclaration of itself.
Finally we define the reflexive closure of the proper superdeclaration relations, because it's sometimes useful to talk about a the entire super-hierarchy of a declaration including itself.
- A declaration is a superdeclaration of a declaration D iff S is D or S is a proper superdeclaration of D.
With all these syntactic relations between declarations in place, we can specify the restrictions imposed by modifiers.
Basic restrictions
With respect to compile-time errors caused by missing required class
modifiers, every enum declaration is considered to have the modifier
final
. An enum declaration is always subject to restrictions which are at
least as strong as the restrictions implied by that modifier. This ensures
that we can specify those errors without mentioning an exception for enum
declarations in every rule.
It's a compile-time error if:
-
A declaration depends directly on a
sealed
declaration from another library. No exceptions, not even for platform libraries.More formally: A declaration D from library L has a direct superdeclaration S marked
sealed
(so necessarily aclass
declaration) in a library different from L.// a.dart sealed class S {} // b.dart import 'a.dart'; class E extends S {} // Error. class I implements S {} // Error. mixin O on S {} // Error. class M with S {} // Error, for several reasons.
-
A declaration has a direct super declaration from another library which is marked
final
(with some exceptions for platform libraries).More formally: A declaration D from library L has a direct superdeclaration S marked
final
(so necessarily aclass
declaration) in library K, and neither- L and K is the same library, nor
- K is a platform library and L is a pre-feature library.
// a.dart final class F {} // b.dart import 'a.dart'; class C1 extends F {} // Error. class C2 implements F {} // Error. mixin class C3 implements F {} // Error. mixin M1 implements F {} // Error. mixin M2 on F {} // Error. enum E1 implements F {} // Error.
-
A class extends or mixes in a declaration marked
interface
from another library (with some exceptions for platform libraries).(You cannot inherit implementation from a class marked
interface
except inside the same library. Unless you are in a pre-feature library and you are inheriting from a platform library.)More formally: A declaration C from library L has a declared superclass declaration S marked
interface
from library K, and neither- L and K is the same library, nor
- K is a platform library and L is a pre-feature library.
// a.dart interface class I {} // b.dart import 'a.dart'; class C1 extends I {} // Error.
-
A declaration implements another declaration, and the other declaration itself, or any of its super-declarations, are marked
base
orfinal
and are not from the first declaration's library (with some exceptions for platform libraries).(You can only implement an interface if all
base
orfinal
superdeclarations are inside your own library. Or if you're in a pre-feature library and allbase
orfinal
superdeclarations are in platform libraries.)More formally: A declaration C in library L has a declared interface P, and P has any superdeclaration S, from a library K, which is marked
base
orfinal
(including S being P itself), and neither:- K and L is the same library, mor
- K is a platform library and L is a pre-feature library.
// a.dart base class S {} base mixin M {} final class F {} // b.dart import 'a.dart'; // Direct implementation of other-library `base` class. base class D implements S {} // Error mixin N implements M {} // Error. enum E implements F { e } // Error. // Indirect implementation of other-library `base` class. base class P extends S {} base class C implements P {} // Error.
-
A declaration has a
base
orfinal
superdeclaration, and is not itself markedbase
,final
orsealed
. This also applies to declarations inside the same library.(A
base
orfinal
declaration doesn't expose an implementable interface, and for that to matter, nor must any of its subclasses. The entire subclass tree below such a declaration must prevent implementation too.)More formally: A
class
,mixin class
ormixin
declaration D in a post-feature library has any proper superdeclaration markedbase
orfinal
, and D is not itself markedbase
,final
orsealed
.// a.dart base class B {} sealed class S extends B {} enum E extends S { e } class C0 extends B {} // Error. class C1 implements B {} // Error. base mixin BM {} mixin M0 implements B {} // Error mixin M1 on B {} // Error // b.dart import 'a.dart'; base class V1 extends B {} final class V2 extends B {} sealed class V3 extends B {} enum E2 with BM { e } // Not a class/mixin class/mixin declaration. class C2 extends B {} // Error. class C3 with BM {} // Error.
An enum
declaration still cannot be implemented, extended or mixed in
anywhere, independently of modifiers.
A type alias (typedef
) cannot be used to subvert these restrictions
or any of the restrictions below. The actual superdeclaration used in
these checks is the one that the type alias expands to. Note that
the library where the type alias is defined does not come into play.
Type aliases cannot be marked with any of the new modifiers.
Mixin restrictions
As before, a declared superclass declaration must be a class
declaration
(you can only extend another class) and a declared interface declaration
must be a class
or mixin
declaration, and now it may also
be a mixin class
declaration (you can only implement something which
has an interface, and not enum
s which cannot be implemented at all).
The new mixin class
declaration has a set of syntactic rules which
ensures that it can be used as both a class
and a mixin
.
It's a compile-time error if a mixin class
declaration:
- has an
interface
,final
orsealed
modifier. This is baked into the grammar, but it bears repeating. - does not have
Object
fromdart:core
as immediate superclass. - declares any non-trivial generative constructor.
A mixin class declaration has Object
from dart:core
as superclass iff it’s either:
- A mixin application class declaration where the declared
superclass is the
Object
class fromdart:core
, and which has precisely one declared mixin. E.g.,mixin class C = Object with M;
- A non-mixin-application class declaration with no declared mixins,
and either no declared superclass, or with a type denoting
Object
fromdart:core
as the declared superclass. E.g.,mixin class C {}
ormixin class C extends Object {}
_The mixin class declarations can also have interfaces, type parameters, _
and modifiers, but no extends
or with
clauses other than those shown here.
A trivial generative constructor is a generative constructor that:
- Is not a redirecting constructor _(
Foo(...) : this.other(...);
), - declares no parameters (parameter list is precisely
()
), - has no initializer list (no
: ...
part, so no asserts or initializers, and no explicit super constructor invocation), - has no body (only
;
), and - is not
external
. Anexternal
constructor is considered to have an externally provided initializer list and/or body.
A trivial generative constructor may be named or unnamed,
and may be const
or non-const
.
A non-trivial generative constructor is a generative constructor which
is not a trivial generative constructor.
A trivial generative constructor has no effect on object construction,
so it can be safely ignored and omitted when the mixin class
is used
as a mixin, but it allows the mixin class
declaration to also be used a
superclass, even for subclasses with constant constructors.
Examples:
mixin class C {
// Trivial generative constructors:
C();
const C();
C.named();
const C.alsoNamed();
// Non-trivial generative constructors:
C(int x); // Error.
C(this.x); // Error.
C() {} // Error.
C(): x = 0; // Error.
C(): assert(true); // Error.
C(): super(); // Error.
C(): this.named(); // Error.
// Not generative constructors, so neither trivial generative nor non-trivial
// generative:
factory C.f = C;
factory C.f2() { ... }
int? x;
}
mixin class C2 extends Object implements I {}
abstract base mixin class C3 = Object with M implements I {
const C3();
}
// Invalid mixin classes.
mixin class E extends C {} // Error.
mixin class E extends Object with M {} // Error.
mixin class E with M {} // Error.
mixin class E = C with M; // Error
mixin class E = Object with M1, M2; // Error
There are also changes to which declarations can be mixed in.
A post-feature class can no longer be used as a mixin unless it's declared
as a mixin class
. In post-feature code, you can only mix in
mixin
or mixin class
declarations
Pre-feature code is not changed, so some pre-feature classes can still
be mixed in, and the SDK exception allows pre-feature code to pretend
platform libraries are still pre-feature libraries.
The formal rules for which declarations can be mixed in become:
It's a compile-time error if a class
or enum
declaration D from
library L has S from library K as a declared mixin, unless:
S
is amixin
ormixin class
declaration (necessarily from a post-feature library), orS
is a non-mixinclass
declaration which hasObject
as superclass and declares no generative constructor, and either- K is a pre-feature library, or
- K is a platform library and L is a pre-feature library.
That is, a class not marked mixin
can still be used as a mixin when the
class's declaration is in a pre-feature library and it satisfies specific
requirements.
For pre-feature libraries, we cannot tell if the intent of class
was
"just a class" or "both a class and a mixin". For compatibility, we assume
the latter, even if the class is being used as a mixin in a post-feature
library where it does happen to be possible to distinguish those two
intents.
@reopen
lint
We don't specify lints and metadata annotations in the language specification, so this part of the proposal will not become a formal part of the language. Instead, it's a suggested part of the overall user experience of the feature.
A metadata annotation @reopen
is added to package meta and a lint
"implicit_reopen" is added to the linter. When the lint is enabled, a lint
warning is reported if a class is not annotated @reopen
and it:
- extends a class marked
interface
orfinal
and is not itself markedinterface
orfinal
, or - extends a
sealed
class which itself transitively extends a class markedinterface
orfinal
.
Runtime semantics
There are no runtime semantics.
Versioning
The changes in this proposal are guarded by a language version. This makes the restriction on not allowing classes to be used as mixins by default non-breaking.
-
base
,interface
,final
,sealed
andmixin
can only be applied to classes and mixins in post-feature libraries. -
When the
base
,interface
,final
,mixin
, orsealed
modifiers are placed on a class or mixin, the resulting restrictions apply to all other libraries, even pre-feature libraries.In other words, we gate being able to author the restrictions to post-feature libraries. But once a type has those restrictions, they apply to all other libraries, regardless of the versions of those libraries. "Ignorance of the law is no defense."
-
We will add modifiers to some classes in platform (i.e.,
dart:
) libraries when this feature ships. But we will also like to not immediately break existing code. To avoid forcing users to immediately migrate, declarations in pre-feature libraries can ignore somebase
,interface
andfinal
modifiers on some declarations in platform libraries, and can mix in non-mixin
classes from platform libraries, as long as such a class hasObject
as superclass and declares no constructors. (legacy-mixin-tests). Instead, users will only have to abide by those restrictions when they upgrade their library's language version to 3.0 or later. It will still not be possible to, e.g., extend or implement theint
class, even if will now have afinal
modifier. Going through a pre-feature library does not remove transitive restrictions for code in post-feature libraries. Any post-feature library declaration which has a platform library class markedbase
orfinal
as a superinterface must be markedbase
,final
orsealed
, and cannot be implemented locally, even if the superinterface chain goes through a pre-feature library declaration, and even if that declaration ignores thebase
modifier.This ability to ignore modifiers only apply to platform libraries accessed from pre-feature libraries, because code doesn't get to decide the version of the SDK that it runs on, unlike how a package can depend on specific versions of another package. Packages should use package versioning to introduce breaking restrictions instead (a major version semantic version upgrade), but those libraries can then rely on the restrictions being enforced. The platform libraries will bear the cost of not being able to rely on its own modifiers until all code in a program is language version 3.0 later.
Compatibility
When upgrading your library to the new language version, you can preserve the
previous behavior by adding mixin
to every class declaration that can be used
as a mixin. If the class defines a generative constructor or extends anything
other than Object
, then it already cannot be used as a mixin and no change is
needed.
Implementation and documentation suggestions for usability
This section is non-normative. It's a set of suggestions to implementation and documentation teams to help ensure that the feature is easy for users to use and discover.
Errors, error recovery, and fixups
First of all, to the extent that it's reasonably feasible to do so, we should
try to make the parser understand that any time it sees a top level sequence of
any of the keywords sealed
, abstract
, final
, interface
, base
, mixin
,
or class
, the user is trying to declare something class-like or mixin-like,
even if they left out an important keyword, used conflicting keywords, or put
keywords in the wrong order. That way we can issue errors whose IDE fixups will
help the user clean up their class or mixin declaration, rather than just
unexpected {
or something. For example, this should be recognized by the
parser as an attempt to make a mixin or class:
interface sealed C {
...
}
(The parser will obviously issue an error, but it should still fire the
appropriate events to allow the analyzer to create a ClassDeclaration
AST
node, and it should analyze the things inside the curly braces as class
members).
If the keywords aren't in the proper order (sealed
/abstract
, then
final
/interface
/base
, then mixin
, then class
), or if a keyword was
repeated, the parser error should be on the first keyword token that's out of
order or repeated, and the fixup should offer to fix the order by sorting and
de-duplicating the keywords appropriately. So in the example above, the "wrong
order" error should be on the keyword sealed
, and the fixup should change it
to sealed interface
, which is still an error for other reasons, but is at
least in the right order now.
With order and duplication out of the way, that leaves 127 possible combinations of the 7 keywords. The remaining error cases (and their associated IDE fixups) are:
-
Did you say both
abstract
andsealed
? Dropabstract
; it’s redundant. Now there's only 95 possibilities. -
Did you say both
interface
andfinal
? Dropinterface
; it’s redundant. Now there's only 71 possibilities. -
Did you say both
base
andfinal
? Dropbase
; it’s redundant. Now there's only 59 possibilities. -
Did you say both
interface
andbase
? Sayfinal
instead. Now there's only 47 possibilities. -
Did you say neither
mixin
norclass
? You have to pick one or the other or both. The fixup can probably safely assume you meanclass
. (Exception: if you just saidinterface
and no other keywords, you probably meanabstract class
). Now there's only 36 possibilities. -
Did you say both
sealed
andfinal
? Dropfinal
; it’s redundant. Now there's only 33 possibilities. -
Did you say both
sealed
andbase
? Dropbase
; it’s redundant. Now there's only 30 possibilities. -
Did you say both
sealed
andinterface
? Dropinterface
; it’s redundant. Now there's only 27 possibilities. -
Did you say both
mixin
andclass
, as well as one of the following keywords:sealed
,interface
, orfinal
? Dropclass
and replaceextends M
withwith M
wherever it appears in your library. Now there's only 22 possibilities. -
Did you say both
abstract
andmixin
, but notclass
? Dropabstract
; it’s redundant. Now we are down to the 18 permitted possibilities.
If we take this sort of approach, then users who don't love reading documentation will be able to just experimentally string together combinations of the keywords we've made available to them, and the errors and fixups will guide them to something valid, and then they can play around and see the effect.
Introducing the feature to users
If we assume that most users will have access to the IDE fixups noted above, it suggests that a nice way to introduce the feature to folks would be to gloss over what combinations are redundant or contradictory, and just tell them in plain English what each keyword does. Users who love reading documentation can read further and find out which combinations are prohibited; users who don't can just try them out, and the IDE will train them which combinations are valid over time. So the core of the feature becomes explainable in just seven lines, three of which are just restatements of things the user was already familiar with. Something like:
-
sealed
means "this type has a known set of direct subtypes, so switching on it will require the switch to be exhaustive". -
abstract
means "this type can't be constructed directly", but you already knew that. It's only included in the list to help clarify thatabstract
is one of the seven keywords users should try combining together at the top of your declaration. -
interface
means "this type can't be extended from outside this library". -
base
means "this type can't be implemented from outside this library". -
final
means "this type can neither be extended nor implemented from outside this library". -
mixin
means "this type can be used in mixin-like ways, i.e. it can appear in the 'with' clause of other classes". Granted, this is kind of a circular definition, but this explanation is intended for programmers familiar with Dart 2.19, and they're already familiar with mixins. -
class
means "this type can be used in class-like ways, i.e. it can be extended, or constructed, unless otherwise forbidden". Again, this is a circular definition, but our audience obviously already knows what classes are. Including it in the list helps make it clear that we're putting mixins and classes on equal footing, and helps clarify whymixin class
is a reasonable thing.
(Note that this list is deliberately in the order required by the grammar).
Obviously there are plenty of details left out of this description. But hopefully it should be enough to get people started using the feature, and the errors and fixups would help keep them on the rails.
Changelog
1.8
- Allow any class declaration with
Object
as superclass to be amixin class
.
1.7
- Update the modifiers applied to anonymous mixin applications to closer match the superclass/mixin modifiers.
- State that
enum
declarations count asfinal
. - Rephrase semantics completely, based only on relations between declarations.
- Say that pre-feature libraries can mix in non-
mixin
platform library classes which satisfy the old requirements for being used as a mixin.
1.6
- Add implementation suggestions about errors, error recovery, and fixups for class modifiers.
1.5
- Fix mixin application grammar to match prose where
mixin
can't be applied to a mixin application class.
1.4
- Update rules to close loopholes on classes that don't want to expose interfaces (#2755, #2757).
- Only allow mixing in
mixin
andmixin class
declarations, even inside the same library. - Specify modifiers for anonymous mixin application classes.
1.3
-
Specify and update restrictions on
mixin class
declarations to allow trivial generative constructors. -
Specify that "mixin application" class declarations (
class C = S with M
) cannot bemixin class
declaration, but can use other modifiers
1.2
- Specify how all modifiers interact with language versioning (#2725).
1.1
-
Clarify that all modifiers are gated behind a language version.
-
Rationalize which modifiers can be combined with
mixin class
and specify behavior ofmixin class
. -
Rename to "Class modifiers" with the corresponding experiment flag name.