Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Write separate more detailed spec for sealed types. #2591

Merged
merged 2 commits into from
Oct 28, 2022
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions working/sealed-types/feature-specification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# Sealed types

Author: Bob Nystrom

Status: In-progress

Version 1.0

This proposal specifies *sealed types*, which is core capability needed for
[exhaustiveness checking][] of subtypes in [pattern matching][]. This proposal
is a subset of the [type modifiers][] proposal. (We may wish to do all or parts
of the rest of that proposal, but they aren't needed for pattern matching, so
this proposal separates them out.) For motivation, see the previously linked
documents.

[exhaustiveness checking]: https://github.com/dart-lang/language/blob/master/working/0546-patterns/exhaustiveness.md

[pattern matching]: https://github.com/dart-lang/language/blob/master/working/0546-patterns/patterns-feature-specification.md

[type modifiers]: https://github.com/dart-lang/language/blob/master/working/type-modifiers/feature-specification.md

## Introduction

Marking a type `sealed` applies two restrictions:

* If it's a class, the type itself can't be directly constructed. The class is
implicitly `abstract`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reasoning for this restriction?

I'm thinking that we can allow the superclass to be instantiated. Then we need to include that class itself in the set of types to check to achieve exhaustiveness. You can still write abstract to avoid it.
Disallowing it should have a reason. I'm sure there are plenty of good reason, I'm just curious which ones we've used to reach the conclusion :)

I can come up with:

  • It's what you'd usually want. Avoids having to write abstract on the superclass.
  • If not, people may forget to write abstract on the superclass, then their switches are not exhaustive.
  • If we use abstract on superclass to decide whether it needs to be part of exhaustiveness checking, we are adding implicit meaning on top of just being abstract. That really should be explicit opt-in.
  • If the superclass itself is part of exhaustiveness checking, ordering of switch cases matter. Breaks what should be nicely symmetric.
  • It makes exhaustiveness checking easier to just disallow it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reasoning for this restriction?

It's necessary to make sealed types useful. If you can have direct instances of the supertype, then in pattern matching, you have to explicitly check the supertype in addition to its subtypes. But if you have to check the supertype anyway... then you derive absolutely no benefit from marking it sealed in the first place. The whole point of sealing is to know that if you've handled all the subtypes you are done. Direct instances of the supertype break that.


* All direct subtypes of the type must be defined in the same library. Any
types that directly implement, extend, or mixin the sealed type must be
defined in the library where the sealed type is defined.

In return for those restrictions, sealed provides two useful properties for
exhaustiveness checking:

* All of the direct subtypes of the sealed type can be easily found and
enumerated.

* Any concrete instance of the sealed type must also be an instance of at
least one of the known direct subtypes. In other words, if you match on a
value of the sealed type and you have cases for all of the direct subtypes,
the compiler knows those cases are exhaustive.

### Open subtypes

Note that it is *not* necessary for the subtypes of a sealed type to themselves
be sealed or closed to subclassing or implementing. Given:

```dart
sealed class Either {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sold on sealed not meaning transitively sealed.
I think people will expect sealed to mean that nobody can create instances implementing the type, not just that nobody can create immediate subtypes.

Copy link
Member Author

@munificent munificent Oct 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sold on sealed not meaning transitively sealed.

This is consistent with how sealed works in Kotlin, Scala, and Java (which does require you to explicitly opt the subclass in by marking it non-sealed, but the capability is there).

I was surprised too, but my informal understanding of sealed turned out to have been wrong once I dug into the semantics.

not just that nobody can create immediate subtypes.

If you want that, you can always mark the subtypes sealed too. (Or closed/base if we get those.)

Fundamentally, the way I've approached this design is, "What is the simplest set of restrictions that we need for exhaustiveness checking to work." As far as pattern matching cares, if you have a sealed type Either and two subtypes Left and Right, it does not matter at all if Left or Right happen to have their own subtypes. It's perfectly sound either way. Given that, I don't see any reason to add an unneeded restriction (which users can opt into already if they want).


class Left extends Either {}
class Right extends Either {}
```

Then this switch is exhaustive:

```dart
test(Either either) {
switch (either) {
case Left(): print('Left');
case Right(): print('Right');
}
}
```

And this is still true even if some unrelated or unknown library contains:

```dart
class LeftOut extends Left {}
```

Or even:

```dart
class Ambidextrous implements Left, Right {}
```

The only property we need for exhaustiveness is that *all instances of the
sealed type must also be an instance of a direct subtype.* More precisely, any
instance of the sealed supertype must have at least one of the direct subtypes
in its superinterface graph.

### Sealed subtypes

At the same time, it can be useful to seal not just a supertype but one or more
of its subtypes. Doing so lets you define a sealed *hierarchy* where matching
various subtypes will exhaustively cover various branches of the hierarchy. For
example:

```dart
// UnitedKingdom --+-- NorthernIreland
// |
// +-- GreatBritain --+-- England
// |
// +-- Scotland
// |
// +-- Wales
sealed class UnitedKingdom {}
class NorthernIreland extends UnitedKingdom {}
sealed class GreatBritain extends UnitedKingdom {}
class England extends GreatBritain {}
class Scotland extends GreatBritain {}
class Wales extends GreatBritain {}
```

By marking not just `UnitedKingdom` `sealed`, but also `GreatBritain` means that
all of these switches are exhaustive:

```dart
test1(UnitedKingdom uk) {
switch (uk) {
case NorthernIreland(): print('Northern Ireland');
case GreatBritain(): print('Great Britain');
}
}

test2(UnitedKingdom uk) {
switch (uk) {
case NorthernIreland(): print('Northern Ireland');
case England(): print('England');
case Scotland(): print('Scotland');
case Wales(): print('Wales');
}
}

test3(GreatBritain britain) {
switch (britain) {
case England(): print('England');
case Scotland(): print('Scotland');
case Wales(): print('Wales');
}
}
```

Note that the above examples are all exhaustive regardless of whether
`NorthernIreland`, `England`, `Scotland`, and `Wales` are marked `sealed`.

In short, `sealed` is mostly a property that affects how you can use the
*supertype* and does not apply any restrictions to the direct subtypes of the
sealed type, except that they must be defined in the same library.

## Syntax

A class or mixin declaration may be preceded with the built-in identifier
`sealed`:

```
classDeclaration ::=
( 'abstract' | 'sealed' )? 'class' identifier typeParameters?
superclass? interfaces?
'{' (metadata classMemberDeclaration)* '}'
| ( 'abstract' | 'sealed' )? 'class' mixinApplicationClass

mixinDeclaration ::= 'sealed'? 'mixin' identifier typeParameters?
('on' typeNotVoidList)? interfaces?
'{' (metadata classMemberDeclaration)* '}'
```

*Note that the grammar disallows `sealed` on a class marked `abstract`. All
sealed types are abstract, so it's redundant to allow both modifiers.*

**Breaking change:** Treating `sealed` as a built-in identifier means that
existing code that uses `sealed` as the name of a type will no longer compile.
Since almost all types have capitalized names in Dart, this is unlikely to be
break much code.
munificent marked this conversation as resolved.
Show resolved Hide resolved

### Static semantics

It is a compile-time error to extend, implement, or mix in a type marked
`sealed` outside of the library where the sealed type is defined. *It is fine,
however to subtype a sealed type from another part file or [augmentation
library][] within the same library.*

[augmentation library]: https://github.com/dart-lang/language/blob/master/working/augmentation-libraries/feature-specification.md

A typedef can't be used to subvert this restriction. If a typedef refers to a
sealed type, it is also a compile-time error to extend, implement or mix in that
typedef outside of the library where the sealed the typedef refers to is
defined. *Note that the library where the _typedef_ is defined does not come
into play.*

A class marked `sealed` is implicitly an *abstract class* with all of the
existing restrictions and capabilities that implies. *It may contain abstract
member declarations, it is a compile-time error to directly invoke its
constructors, etc.*

### Runtime semantics

There are no runtime semantics.

### Core library

The "dart:core" types `bool`, `double`, `int`, `Null`, `num`, and `String` are
all marked `sealed`. *These types have always behaved like sealed types by
relying on special case restrictions in the language specification. That
existing behavior can now be expressed in terms of this general-purpose
feature.*
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is precisely the reason using the word sealed worries me.
Of these types, only num is actually exhustive-sealed. The rest are really sealed.
They have not "behaved like sealed types" with this definition of sealed.

I don't want people to start using exhaustive-sealed instead of really-sealed, just because it's the only thing they have. For one thing, it doesn't work, because the class becomes abstract. I guess it can work, but then you need to introduce a private non-abstract subclass, just to get the sealing.

We generally pretend that int, double and String are not abstract classes, and the values are instances of those classes (even if the VM has subclasses, dart2js doesn't always).
We definitely treat Null and bool as non-abstract, with null, true and false as actual instances of those types. (I don't want to consider the effect on our type system if Null has a subtype.)
At least all their constructors are factory constructors.


An alternative is to actually use sealed, and iff the sealed class is abstract, then it's gives exhaustiveness checking of immediate subtypes. If not abstract, it's just not implementable outside the library, but you don't get exhaustiveness checking of immediate subtypes.

Then you can seal all your types, abstract or not, in order to prevent implementations from outside the library.
(Still worries me that doing so opts you into exhaustiveness if you ever have an abstract superclass, with no way to opt out.)

So, I worry about using sealed as the word, and this section is a good example of the confusion it can cause. Exhaustiveness is not the traditional "sealed" meaning.

Copy link
Member Author

@munificent munificent Oct 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is precisely the reason using the word sealed worries me. Of these types, only num is actually exhustive-sealed. The rest are really sealed. They have not "behaved like sealed types" with this definition of sealed.

I believe these types do behave like types marked sealed according to this proposal. The sealed modifier means "the only direct subtypes of this type must be in this library". It doesn't say that there must actually be any subtypes. And if there are none (or they are all sealed too), then a sealed type is also effectively transitively sealed, which is what we observe with the listed core lib types.

It is true that marking a class sealed while it having no subtypes in the same library means that it doesn't really give you any exhaustiveness checking benefit. It's effectively synonymous with closed base. But, if we don't get those modifiers, then marking them sealed has the same effect.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about Null. It's just not really a class (what is its superclass?), so talking about it as sealed is fine loosely speaking, but I think kind of meaningless. The rest seems to work out to me. I think we may also have the SIMD types in this category? Are there other things we should go ahead and try to seal?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we may also have the SIMD types in this category? Are there other things we should go ahead and try to seal?

I'm fine with whatever we want to seal, though adding to this list might mean breaking changes, which I'd like to avoid in the interests of expedience.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, it's the TypedData classes. But the point is they're already sealed. dart-lang/sdk#45115

Copy link
Member

@lrhn lrhn Oct 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null is definitely a class. It has no superclass. That's the same as Object, and nobody doubts that Object is a class.

dartlangspec.tex:8665:

The null object is the sole instance of the built-in class \code{Null}.

Anyway,

I believe these types do behave like types marked sealed according to this proposal. The sealed modifier means "the only direct subtypes of this type must be in this library".

This sealed also implies abstract, and those classes are not abstract. They all have instances.
We can claim that the instances are actually instances of a private subclass that we never tell anyone about, and that's even true for numbers and strings on Native. It's not true on the web.
It's not true for bool or Null on native.

These classes are a different kind of sealed, the transitive one, not just the exhaustiveness one.
Nobody ever cared about exhaustivness for them. If we could make them closed base class, we wouldn't even consider using this sealed as defined here.

For typed-data, there really are subclasses in the library, because all the constructors are factory ones. We'd still use closed base class for those if we could, not sealed.

Misusing sealed to mean "cannot be subclassed outside of the library at all" is what I'd consider a failure of the design. It's one of those things that kind-of works if you squint at it correctly, but if you forget that it's a hack and introduces a publicly visible subtype (even using implements), you break the protection. It simply is not the protection we want here, we want the full, transitive protection against anyone ever creating anything implementing int.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding to this list might mean breaking changes

As long as those breaking changes are gated by the language version it shouldn't be a deal breaker. We should definitely consider whether there are any other classes worth sealing if we think there is an easy enough migration path.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sealed also implies abstract, and those classes are not abstract. They all have instances.

For the purposes of making exhaustiveness checking work, this distinction doesn't matter for types like int and double which have no subtypes. You're correct that they aren't abstract. But they don't need to be. Since there is no set of subtypes you could match on, you have to match on the sealed type itself in order to cover it exhaustively. And once you do that, it's harmless for it to be non-abstract.

(We could even potentially spec sealed to say when applied to user-defined classes, they can be non-abstract if there are no subtypes. But I suspect that would just make things more confusing for users. It's probably better to just have something like closed/base/whatever to let them express that directly without getting exhaustiveness involved.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null is definitely a class. It has no superclass. That's the same as Object, and nobody doubts that Object is a class.

Every class has a single superclass

Every class has a single superclass except class \code{Object} which has no superclass.

The spec says a lot of things, some of them even true. :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some of them are even true

Some truths have been updated, and may even be landed at some point. ;-)

In particular, https://github.com/dart-lang/language/pull/2605/files#diff-0e2f05538272f3fb682a361d7738d860125088af3ac0044e5472cac965cfdb61R24431 has:

The \code{Null} class declares exactly the same members
with the same signatures as the class \code{Object}.

and the text weasels around the question about the superclass, because I'd prefer that we introduce a class Any which is the top type (with no attached strings, unlike dynamic and void), and then we would say that Object has superclass Any, and Null also has superclass Any, and then we don't have to deal with a situation any more where Object? somehow isn't a class and the relationship between Null and Object is an anomaly.