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