Skip to content

Commit 332eabc

Browse files
committed
Strawman proposal for capability modifiers on types.
1 parent 503d651 commit 332eabc

File tree

1 file changed

+339
-0
lines changed

1 file changed

+339
-0
lines changed
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
# Type Modifiers
2+
3+
Author: Bob Nystrom
4+
5+
Status: Strawman
6+
7+
Version 1.0
8+
9+
This is a proposal for capability modifiers on classes and mixins. It's based on
10+
[Erik's proposal][erik] as well as some informal user surveys.
11+
12+
[erik]: https://github.com/dart-lang/language/blob/master/resources/class-capabilities/class-capabilities.md
13+
14+
## Goals
15+
16+
With [pattern matching][], we want to support [exhaustiveness checking][] over
17+
class hierarchies in order to support algebraic datatype-style code. That means
18+
a way to define a supertype with a fixed, closed set of subtypes. That way, if
19+
you match on all of the subtypes, you know the supertype is exhaustively
20+
covered.
21+
22+
[pattern matching]: https://github.com/dart-lang/language/blob/master/working/0546-patterns/patterns-feature-specification.md
23+
24+
[exhaustiveness checking]: https://github.com/dart-lang/language/blob/master/working/0546-patterns/exhaustiveness.md
25+
26+
We don't necessarily need to express that using a modifier, but that's what
27+
most other languages do and it seems to work well. If we're going to add one
28+
modifier on types, it's a good time to look at other modifiers so that we can
29+
design them holistically.
30+
31+
This proposes a small number of language changes to give users better control
32+
over the main capabilities a type exposes:
33+
34+
* Whether it can be constructed.
35+
* Whether it can be subclassed.
36+
* Whether it defines an interface that can be implemented.
37+
* Whether it can be mixed in.
38+
* Whether it has a closed set of subtypes for exhaustiveness checks.
39+
40+
## Mixins
41+
42+
When we added support for `on` clauses, we also added dedicated syntax for
43+
defining mixins since `on` clauses don't make sense for class declarations.
44+
However, Dart still allows you do treat a class as a mixin if it fits within
45+
restrictions.
46+
47+
Since those restrictions are easy to forget, it's easy for a class maintainer
48+
to accidentally cause the class to no longer be a valid mixin and break users
49+
that were using it as one. For that and other reasons, we've long wanted to
50+
remove the ability to use a class as a mixin ([#1529][], [#1643][]).
51+
52+
[#1529]: https://github.com/dart-lang/language/issues/1529
53+
[#1643]: https://github.com/dart-lang/language/issues/1643
54+
55+
Based on our survey feedback, users seem OK with that. So the first change is
56+
to **stop allowing classes to appear in `with` clauses.**
57+
58+
I think this change makes sense. Classes and mixins are fundamentally different
59+
things. A mixin *has no superclass*. It's *not* a class. You can't construct it
60+
or extend it because it's not a complete entity. It's more like a function that
61+
*produces* a class when "invoked" with a superclass.
62+
63+
This is technically a breaking change, but I believe it will be relatively
64+
minor.
65+
66+
## Capability defaults
67+
68+
When designing modifiers, you have to decide what the default behavior is. What
69+
do you get *without* the modifier. The modifier then toggles that. For types,
70+
do we default to making them permissive with all the capabilities and then have
71+
modifiers to remove those? Or do we default to classes being restricted and
72+
allow modifiers to add capabilities?
73+
74+
I propose that we default to permissive: **Without any modifiers, a type has all
75+
capabilities.** Since mixins are separate from classes that means a class
76+
defaults to being constructible, extensible, and implementable. A mixin defaults
77+
to being miscible and implementable.
78+
79+
My reasoning is:
80+
81+
* **It's nonbreaking.** Classes are permissible by default right now, so
82+
keeping that default behavior lets us add modifiers without breaking
83+
existing code. If we made `class` and `mixin` default to *not* allowing
84+
these capabilities, every existing Dart type would break. Fixing that is
85+
mechanically toolable, but that doesn't magically repair every line of
86+
documentation, blog post, or StackOverflow answer. It would be a *lot* of
87+
churn.
88+
89+
* **We've gotten by so far.** Permissive is the current default and it can't
90+
be *that* bad, or we would have changed it years ago. Users *do* ask for
91+
more declarative control over these capabilities, but it has never risen to
92+
the top of any lists of user priorities.
93+
94+
* **It's consistent.** Dart does already have one capability modifier for
95+
classes: `abstract`. It *removes* a capability: the ability to construct
96+
instances of the class. (`abstract` also *enables* the ability to define
97+
abstract members inside the class body, but that's a secondary effect. A
98+
class you can construct can't contain abstract members. Mixins can also
99+
contain abstract members, but don't need an `abstract` modifier.)
100+
101+
If we switch the defaults, we'd either have to get rid of `abstract` and add
102+
a "constructible" modifier, or have a mixture of some capabilities that
103+
default to on (construction) and others that default to off (extension and
104+
implementation). Having everything default to on is simpler for users to
105+
reason about.
106+
107+
* **Users like the current defaults.** From our limited survey, users
108+
generally seem to prefer the current permissive defaults. We have long heard
109+
that automatic implicit interfaces are a *beloved* feature of the language.
110+
111+
* **It stays out of the way for app developers.** Removing capabilities helps
112+
package maintainers because it lets them change their classes in more ways
113+
without breaking users. For example, if a class doesn't expose an implicit
114+
interface, then it's safe to add new methods to the class. Removing
115+
capabilities can also help app developers understand very large codebases.
116+
If they see that a class is closed to extension, they don't have to wonder
117+
if there are subclasses floating around elsewhere in the codebase.
118+
119+
But for developers writing smaller applications over shorter timescales,
120+
these restrictions are likely unnecessary and may just be distracting. Dart
121+
and Flutter are used particularly heavily for small-scale client
122+
applications, often written quickly at agencies. A large fraction of Dart
123+
code is in relatively small "leaf" codebases like this. Defaulting to
124+
permissive lets developers of those programs do what they want with their
125+
types without any potentially distracting or confusing ceremony.
126+
127+
Meanwhile, package authors who do want to restrict capabilities still have
128+
the ability to opt in to those restrictions.
129+
130+
For those reasons, I suggest we default to types being permissive. Since we are
131+
separating mixins out from classes, we don't need a modifier to remove the
132+
capability of mixing in a class. We already have a modifier `abstract` to opt
133+
out of construction. What remains are modifiers for opting out of extension and
134+
implementation.
135+
136+
## Prohibiting subclassing
137+
138+
Most other object-oriented languages give users control over whether a class can
139+
be subclassed. C# defaults to allowing subclassing and uses a `sealed` modifier
140+
to prohibit it. Java and Scala also default to allowing it and use `final` to
141+
opt out. Swift and Scala default to disallowing and use `open` to allow
142+
subclassing.
143+
144+
We could use `sealed` for Dart, but that keyword is used for exhaustiveness
145+
checking in Swift and Kotling, which could be confusin. We could use `final`,
146+
but I think it would be confusing if a class marked `final` could still be
147+
implemented. "Final" sounds, well, *final* to me.
148+
149+
I like to reuse keywords from other languages when possible, but given that the
150+
existing keywords have conflicting meanings in different languages, it may be
151+
*less* confusing to use a new keyword that has no existing association. Swift
152+
and Kotlin use `open` to mean that a class is "open to extension". We need the
153+
opposite, a modifier that means the class is "closed to extension".
154+
155+
Based on that, I suggest we **use `closed` to mean that the class isn't open to
156+
being subclassed.** A user coming from Kotlin or Swift can probably infer that
157+
it means the opposite of the `open` keyword they are familiar with.
158+
159+
## Prohibiting implementing
160+
161+
Disabling implementation is harder. No other language I know supports implicit
162+
interfaces, so there isn't much prior art or user intuition to lean on. We are
163+
defaulting to allowing an unfamiliar (but much loved) behavior and now need a
164+
keyword to mean the *opposite* of an unfamiliar concept.
165+
166+
I considered `concrete` since a class with no interface is a "concrete class",
167+
but that keyword implies that it means the opposite of `abstract` when it would
168+
have nothing to do with that other modifier.
169+
170+
Here's one way to look at it: Think about a user who applies this modifier.
171+
What are they trying to accomplish? What does the class they end up with
172+
represent?
173+
174+
When a class exposes no implicit interface it means that every instance of that
175+
class's type is either a direct instance of the class itself, or one of its
176+
concrete subclasses. Every instance will have that class's instance fields and
177+
will inherit its method implementations.
178+
179+
If this class happened to also be abstract so that there were no direct
180+
instances of it, how would you describe? It would be a class that existed
181+
solely to be extended: an [abstract base class][].
182+
183+
[abstract base class]: https://en.wikipedia.org/wiki/Class_(computer_programming)#Abstract_and_concrete
184+
185+
If the class isn't abstract and can be both constructed and extended, you might
186+
think of it as a "base class".
187+
188+
Given that, I suggest we use `base` to mean "no interface". In other words, this
189+
class defines a *base* that all subtypes of this class (if there are any) must
190+
inherit from.
191+
192+
It's short. I think it reads very naturally in `abstract base class` and
193+
`base class`. It works OK in `base mixin` to define a mixin with no implicit
194+
interface.
195+
196+
If you want a class that can't be implemented *or* extended (in other words, a
197+
fully "final" or "sealed" leaf class), it would be a `closed base class`. I
198+
admit that reads a little like an oxymoron. It's not *great*, but maybe that's
199+
acceptable?
200+
201+
## Exhaustiveness checking
202+
203+
In order for exhaustiveness checking to be sound, we need to ensure that there
204+
are no [non-local][global] subtypes of the class being matched. If we defaulted
205+
to allowing exhaustiveness checks on all classes, that would require us to
206+
default to *prohibiting* extending and implementing the class outside of its
207+
library.
208+
209+
[global]: https://github.com/dart-lang/language/blob/master/working/0546-patterns/exhaustiveness.md#global-analysis
210+
211+
So we want to default exhaustiveness checks *off* and provide a way to opt in.
212+
We *could* say that any abstract class that disables extension and
213+
implementation implicitly enables exhaustiveness checks. But I don't think
214+
that's what users will want in many cases. Once a type has enabled
215+
exhaustiveness checks, it is a breaking API change for the maintainer of that
216+
type to add a new subtype. A package maintainer may want to prohibit *others*
217+
from subtyping the types it exposes while still retaining the flexibility to add
218+
them themselves in minor versions of the package.
219+
220+
Kotlin and Scala use `sealed` to opt in to exhaustiveness checks. We could use
221+
that but it might be confusing since `sealed` just means "can't subclass" in C#.
222+
223+
Given that I suggested novel keywords for the other two modifiers, we could use
224+
a new one here too. I propose `switch`. This keyword directly points to where
225+
the modifier's behavior comes into play: exhaustiveness checks on switch cases.
226+
(`case` is another obvious choice. But, confusingly, `case class` in Scala has
227+
nothing to do with exhaustiveness even though match cases are where
228+
exhaustiveness comes into play.)
229+
230+
I'm not in love with this. Maybe it's best to stick with `sealed` and risk a
231+
little confusion from C# users.
232+
233+
## Modifier combinations
234+
235+
Given all of the above, here are the valid capability combinations and the
236+
keywords to express them:
237+
238+
```dart
239+
closed abstract base class // (none)
240+
closed base class // Construct
241+
abstract base class // Extend
242+
switch base class // Extend Exhaustive
243+
base class // Extend Construct
244+
closed abstract class // Implement
245+
switch closed class // Implement Exhaustive
246+
closed class // Implement Construct
247+
abstract class // Implement Extend
248+
switch class // Implement Extend Exhaustive
249+
class // Implement Extend Construct
250+
251+
base mixin // Mix-In
252+
switch base mixin // Mix-In Exhaustive
253+
mixin // Mix-In Implement
254+
switch mixin // Mix-In Implement Exhaustive
255+
```
256+
257+
Note that all 32 Boolean combinations are not present:
258+
259+
* `Mix-In` can't be combined with `Extend` or `Construct` because a mixin has
260+
no superclass and thus isn't a thing you can use directly without applying a
261+
superclass first by mixing it in.
262+
263+
* `Exhaustive` implies that the class itself can't be directly constructed
264+
(otherwise checking its subtypes isn't exhaustive), so it can't be combined
265+
with `Construct`. Also, it implies `abstract` so the latter doesn't need to
266+
be written.
267+
268+
* Likewise, `Exhaustive` is meaningless without any subtypes, so `switch`
269+
can't be combined with both `closed` and `base`.
270+
271+
A grammar for the valid combinations is:
272+
273+
```
274+
classHeader ::= 'closed'? 'abstract'? 'base'? 'class'
275+
| 'switch' ( 'closed' | 'base' )? 'class'
276+
277+
mixinHeader ::= 'switch'? 'base'? 'mixin'
278+
```
279+
280+
## Scope
281+
282+
That's syntax, but what are the semantics? I don't want to get into detail but
283+
one important question is the scope where the restrictions are applied. Can
284+
they be ignored in some places? For example, can you subclass a class marked
285+
`closed` within the same library? Same Pub package? Can you use a `base class`
286+
in an `implements` clause in the class's package's tests?
287+
288+
For exhaustiveness checking, we need to allow *some* subtypes to exist, but they
289+
must be prohibited outside of scope known to the compiler so that users can
290+
[reason about them in a modular way][global]. The principle I suggest for that
291+
is: *The compile errors in a file should be determined solely by the files that
292+
file depends on, directly or indirectly.*
293+
294+
That implies that for `switch`, the restriction on subtyping is ignored in the
295+
current library. I suggest we use the same scope for the other modifiers. So
296+
when a class is marked `closed`, you can still extend it from another class in
297+
the same library. Likewise, a type marked `base` can still be implemented in the
298+
same library.
299+
300+
The restriction is *not* ignored outside of the library, even in other libraries
301+
in the same package, including its tests. My thinking is:
302+
303+
* Libraries are the boundary for privacy, so they are already establishes as
304+
the natural unit of capability restrictions.
305+
306+
* Since the default behavior is permissive, it's not onerous if the user does
307+
want to access these capabilities outside of the current library. They get
308+
that freedom by default unless they go out of their way to remove it.
309+
310+
* We would like to be able to use static analysis to improve modular
311+
compilation in Dart. Knowing that a type can't be extended and/or
312+
implemented might give a compiler the ability to devirtualize members or
313+
apply other optimizations.
314+
315+
The library is the natural granularity for that. Our compilers don't
316+
currently work at the granularity of a pub package and are unlikely to since
317+
a package's library code, tests, and examples all have very different
318+
package dependencies. Also, some packages contain different libraries that
319+
each target different platforms. That would make it hard for a modular
320+
compiler to look at an entire pub package as a single "unit".
321+
322+
* If a user does want to ignore these restrictions across multiple files in
323+
their package, they can always split the library up using part files. If
324+
we ship [augmentation libraries][], they can even give each of those files
325+
their own imports and private scope.
326+
327+
[augmentation libraries]: https://github.com/dart-lang/language/tree/master/working/augmentation-libraries
328+
329+
## Summary
330+
331+
This proposal gives Dart users full control over all meaningful combinations of
332+
capabilities a class can expose. It does so mostly without breaking existing code.
333+
334+
It splits mixins out completely from classes. Then it adds three new modifiers:
335+
336+
* A `closed` modifier on a class disables extending it.
337+
* A `base` modifier on a class or mixin disables its implicit interface.
338+
* A `switch` modifier on a class or mixin defines the root of a sealed type
339+
family for exhaustiveness checking. It also implies `abstract`.

0 commit comments

Comments
 (0)