Skip to content

Commit 4a48c96

Browse files
committed
[patterns] Handle nullable types and other patterns in exhaustiveness.
- Specify that nullable and FutureOr types are expanded as if they were sealed supertypes. - Define how null-check, null-assert, cast, and declaration matcher patterns are lifted.
1 parent 3ae78d7 commit 4a48c96

File tree

10 files changed

+274
-36
lines changed

10 files changed

+274
-36
lines changed

working/0546-patterns/exhaustiveness.md

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Author: Bob Nystrom
44

55
Status: In progress
66

7-
Version 1.0
7+
Version 1.1 (see [CHANGELOG](#CHANGELOG) at end)
88

99
## Summary
1010

@@ -386,8 +386,8 @@ into spaces:
386386
* **Variable pattern:** An extract space whose type is the variable's type
387387
(which might be inferred).
388388
389-
* **Literal and constant patterns:** These are handled a little specially
390-
depending on the constant value's type:
389+
* **Literal or constant matcher:** These are handled specially depending on
390+
the constant's type:
391391
392392
* We treat `bool` like a sealed supertype with subtypes `true` and
393393
`false`. The Boolean constants `true` and `false` are lifted to extract
@@ -405,19 +405,59 @@ into spaces:
405405
Dart language. This is just a way to model enums for exhaustiveness
406406
checking.)
407407
408-
* We lift other constants to an extract pattern of that constant's type.
409-
This means there is effectively no exhaustiveness checking for constants
410-
of other types.
408+
* We lift other constants to an extract pattern whose type is a
409+
synthesized subtype of the constant's type based on the constant's
410+
identity. Each unique value of the constant's type is a singleton
411+
instance of its own type, and the constant's type behaves like an
412+
*unsealed* supertype. Two constants have the same synthesized subtype if
413+
they are identical values.
414+
415+
This means you don't get exhaustiveness checking for constants, but do
416+
get reachability checking:
417+
418+
```dart
419+
String s = ...
420+
switch (s) {
421+
case 'a string': ...
422+
case 'a string': ...
423+
}
424+
```
425+
426+
Here, there is an unreachable error on the second case since it matches
427+
the same string constant. Also the switch has a non-exhaustive error
428+
since it doesn't match the entire `String` type.
429+
430+
* **Null-check matcher:** An extract space whose type is the underlying
431+
non-nullable type of the pattern. It contains a single field, `this` that
432+
returns the same value it is called on. That field's space is the lifted
433+
subpattern of the null-check pattern. For example:
411434
412-
* **Extractor patterns:** An extract space whose type is the extractor
435+
```dart
436+
Card? card;
437+
switch (card) {
438+
case Jack(oneEyed: true)?: ...
439+
}
440+
```
441+
442+
The case pattern is lifted to:
443+
444+
```
445+
Card(this: Jack(oneEyed: true))
446+
```
447+
448+
* **Extractor pattern:** An extract space whose type is the extractor
413449
pattern's type and whose fields are the lifted fields of the extractor
414450
pattern. Positional fields in the extractor pattern get implicit names like
415451
`field0`, `field1`, etc.
416452
417-
**TODO: Specify how null-check patterns are lifted.**
453+
* **Declaration matcher:** The lifted space of the inner subpattern.
454+
455+
* **Null-assert or cast binder:** An extract space of type `top`. These binder
456+
patterns don't often appear in the matcher patterns used in switches where
457+
exhaustiveness checking applies, but can occur nested inside a [declaration
458+
matcher][] pattern.
418459
419-
**TODO: Specify lifting binder patterns too (cast, null assert, declaration)
420-
since they can appear in matcher patterns through declaration matchers.**
460+
[declaration matcher]: https://github.com/dart-lang/language/blob/master/working/0546-patterns/patterns-feature-specification.md#declaration-matcher
421461
422462
**TODO: Once generics are supported, describe how type patterns are lifted to
423463
spaces here.**
@@ -653,7 +693,7 @@ result of that subtraction is obvious:
653693
Jack|Queen|King - Jack = Queen|King
654694
```
655695
656-
**Expanding** a type replaces a sealed supertype with its list of subtypes. We
696+
**Expanding a type** replaces a sealed supertype with its list of subtypes. We
657697
only do this when the left type is sealed and the right type is a subtype.
658698
Expanding is recursive:
659699
@@ -691,6 +731,24 @@ And now it's easier to see that `Jack` subtracts the first arm leaving:
691731
Queen(suit: heart)|King(suit: heart)
692732
```
693733
734+
If the left type is nullable and the right type is `Null` or non-nullable, then
735+
expanding expands the nullable type to `Null` and the underlying type, as if the
736+
nullable type was a sealed supertype of the underlying type and `Null`:
737+
738+
```
739+
Face? - Face expands to: Face|Null - Face = Null
740+
Jack? - Null expands to: Jack|Null - Null = Jack
741+
```
742+
743+
Likewise, if the left type is `FutureOr<T>` for some type `T` and the right type
744+
is a subtype of `Future` or `T`, then expanding expands the `FutureOr<T>` to
745+
`Future<T>|T`.
746+
747+
```
748+
FutureOr<int> - int expands to: Future<int>|int - int = Future<int>
749+
FutureOr<int> - Future expands to: Future<int>|int - Future = int
750+
```
751+
694752
### Subtraction
695753
696754
OK, that's enough preliminaries. Here's the algorithm. To subtract two
@@ -953,3 +1011,16 @@ separately.
9531011
Type promotion can safely assume any switch is exhaustive and promote
9541012
accordingly. If that turns out to not be a case, a compile error will be
9551013
reported anyway, so promotion doesn't matter.
1014+
1015+
1016+
## Changelog
1017+
1018+
### 1.1
1019+
1020+
- Specify that constants are treated as subtypes based on identity. This way,
1021+
we can get reachability errors on duplicate constant cases.
1022+
1023+
- Specify how null-check, null-assert, cast, and declaration matcher patterns
1024+
are lifted.
1025+
1026+
- Handle nullable and `FutureOr` types in expand type.

working/0546-patterns/exhaustiveness_prototype/lib/intersect.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,18 @@ StaticType? intersectTypes(StaticType left, StaticType right) {
6666
if (left.isSubtypeOf(right)) return left;
6767
if (right.isSubtypeOf(left)) return right;
6868

69+
if (left.isNullable) {
70+
if (right.isNullable) {
71+
var intersection = intersectTypes(left.underlying, right.underlying);
72+
if (intersection == null) return null;
73+
return intersection.nullable;
74+
} else {
75+
return intersectTypes(left.underlying, right);
76+
}
77+
} else if (right.isNullable) {
78+
return intersectTypes(left, right.underlying);
79+
}
80+
6981
// If we allow sealed types to share subtypes, then this will need to be more
7082
// sophisticated. Here:
7183
//

working/0546-patterns/exhaustiveness_prototype/lib/static_type.dart

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,21 @@ class StaticType {
99
/// Built-in top type that all types are a subtype of.
1010
static final top = StaticType('top', inherits: []);
1111

12+
static final nullType = StaticType('Null');
13+
1214
final String name;
1315

16+
late final StaticType nullable = StaticType._nullable(this);
17+
18+
/// If this type is a nullable type, then this is the underlying type.
19+
///
20+
/// Otherwise `null`.
21+
final StaticType? _underlying;
22+
23+
/// The underlying type of this nullable type. It's an error to call this on
24+
/// a non-nullable type.
25+
StaticType get underlying => _underlying!;
26+
1427
/// Whether this type is sealed. A sealed type is implicitly abstract and has
1528
/// a closed set of known subtypes. This means that every instance of the
1629
/// type must be an instance of one of those subtypes. Conversely, if an
@@ -31,44 +44,40 @@ class StaticType {
3144
/// one of those two types*.
3245
final bool isSealed;
3346

47+
bool get isNullable => _underlying != null;
48+
3449
/// The static types of the fields this type exposes for record destructuring.
3550
///
3651
/// Includes inherited fields.
3752
Map<String, StaticType> get fields {
38-
return {for (var supertype in supertypes) ...supertype.fields, ..._fields};
53+
return {for (var supertype in _supertypes) ...supertype.fields, ..._fields};
3954
}
4055

4156
final Map<String, StaticType> _fields;
4257

43-
final List<StaticType> supertypes = [];
58+
final List<StaticType> _supertypes = [];
4459

4560
/// The immediate subtypes of this type.
4661
Iterable<StaticType> get subtypes => _subtypes;
4762
final List<StaticType> _subtypes = [];
4863

49-
Iterable<StaticType> get allSupertypes sync* {
50-
for (var supertype in supertypes) {
51-
yield supertype;
52-
yield* supertype.allSupertypes;
53-
}
54-
}
55-
5664
StaticType(this.name,
5765
{this.isSealed = false,
5866
List<StaticType>? inherits,
5967
Map<String, StaticType> fields = const {}})
60-
: _fields = fields {
68+
: _underlying = null,
69+
_fields = fields {
6170
if (inherits != null) {
6271
for (var type in inherits) {
63-
supertypes.add(type);
72+
_supertypes.add(type);
6473
type._subtypes.add(this);
6574
}
6675
} else {
67-
supertypes.add(top);
76+
_supertypes.add(top);
6877
}
6978

7079
var sealed = 0;
71-
for (var supertype in supertypes) {
80+
for (var supertype in _supertypes) {
7281
if (supertype.isSealed) sealed++;
7382
}
7483

@@ -86,9 +95,37 @@ class StaticType {
8695
if (sealed > 1) throw ArgumentError('Can only have one sealed supertype.');
8796
}
8897

89-
bool isSubtypeOf(StaticType supertype) {
90-
if (this == supertype) return true;
91-
return allSupertypes.contains(supertype);
98+
StaticType._nullable(StaticType underlying)
99+
: name = '${underlying.name}?',
100+
_underlying = underlying,
101+
isSealed = true,
102+
// No fields because it may match null which doesn't have them.
103+
_fields = {} {}
104+
105+
bool isSubtypeOf(StaticType other) {
106+
if (this == other) return true;
107+
108+
// Null is a subtype of all nullable types.
109+
if (this == nullType && other._underlying != null) return true;
110+
111+
// A nullable type is a subtype if the underlying type and Null both are.
112+
var underlying = _underlying;
113+
if (underlying != null) {
114+
return underlying.isSubtypeOf(other) && nullType.isSubtypeOf(other);
115+
}
116+
117+
// A non-nullable type is a subtype of the underlying type of a nullable
118+
// type.
119+
var otherUnderlying = other._underlying;
120+
if (otherUnderlying != null) {
121+
return isSubtypeOf(otherUnderlying);
122+
}
123+
124+
for (var supertype in _supertypes) {
125+
if (supertype.isSubtypeOf(other)) return true;
126+
}
127+
128+
return false;
92129
}
93130

94131
@override

working/0546-patterns/exhaustiveness_prototype/lib/subtract.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,17 @@ bool _isLeftSubspace(StaticType leftType, List<String> fieldNames,
156156
/// Recursively replaces [left] with a union of its sealed subtypes as long as
157157
/// doing so enables it to more precisely match against [right].
158158
List<StaticType> expandType(StaticType left, StaticType right) {
159+
// If [left] is nullable and right is null or non-nullable, then expand the
160+
// nullable type.
161+
if (left.isNullable && (right == StaticType.nullType || !right.isNullable)) {
162+
return [...expandType(left.underlying, right), StaticType.nullType];
163+
}
164+
165+
// If [right] is nullable, then expand using its underlying type.
166+
if (right.isNullable) {
167+
return expandType(left, right.underlying);
168+
}
169+
159170
// If [left] is a sealed supertype and [right] is in its subtype hierarchy,
160171
// then expand out the subtypes (recursively) to more precisely match [right].
161172
if (left.isSealed && left != right && right.isSubtypeOf(left)) {

working/0546-patterns/exhaustiveness_prototype/test/expand_type_test.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,33 @@ void main() {
198198
expectExpand(e, d, 'E');
199199
expectExpand(e, e, 'E');
200200
});
201+
202+
test('nullable', () {
203+
// (A)
204+
// / \
205+
// B C
206+
// / \
207+
// D E
208+
var a = StaticType('A', isSealed: true);
209+
var b = StaticType('B', inherits: [a]);
210+
var c = StaticType('C', inherits: [a]);
211+
var d = StaticType('D', inherits: [c]);
212+
var e = StaticType('E', inherits: [c]);
213+
214+
expectExpand(a.nullable, a, 'A|Null');
215+
expectExpand(a, a.nullable, 'A');
216+
expectExpand(a.nullable, a.nullable, 'A|Null');
217+
218+
// Sealed subtype.
219+
expectExpand(a.nullable, b, 'B|C|Null');
220+
expectExpand(a, b.nullable, 'B|C');
221+
expectExpand(a.nullable, b.nullable, 'B|C|Null');
222+
223+
// Unsealed subtype.
224+
expectExpand(c.nullable, d, 'C|Null');
225+
expectExpand(c, d.nullable, 'C');
226+
expectExpand(c.nullable, d.nullable, 'C|Null');
227+
});
201228
}
202229

203230
void expectExpand(StaticType left, StaticType right, String expected) {

working/0546-patterns/exhaustiveness_prototype/test/intersect_empty_test.dart

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,8 @@ void main() {
2626
test('records', () {
2727
expectIntersectEmpty(rec(x: a, y: a), rec(x: a, y: a), isFalse);
2828
expectIntersectEmpty(rec(x: a, y: a), rec(x: a), isFalse);
29-
expectIntersectEmpty(
30-
rec(w: a, x: a), rec(y: a, z: a), isFalse);
31-
expectIntersectEmpty(rec(w: a, x: a, y: a), rec(x: a, y: a, z: a),
32-
isFalse);
29+
expectIntersectEmpty(rec(w: a, x: a), rec(y: a, z: a), isFalse);
30+
expectIntersectEmpty(rec(w: a, x: a, y: a), rec(x: a, y: a, z: a), isFalse);
3331
});
3432

3533
test('types', () {

working/0546-patterns/exhaustiveness_prototype/test/intersect_types_test.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ void main() {
7373
expectIntersect(d, e, null);
7474
expectIntersect(e, e, e);
7575
});
76+
77+
test('nullable', () {
78+
// A
79+
// |
80+
// B
81+
var a = StaticType('A');
82+
var b = StaticType('B', inherits: [a]);
83+
84+
expectIntersect(a, a.nullable, a);
85+
expectIntersect(a, StaticType.nullType, null);
86+
expectIntersect(a.nullable, StaticType.nullType, StaticType.nullType);
87+
88+
expectIntersect(a, b.nullable, b);
89+
expectIntersect(a.nullable, b, b);
90+
expectIntersect(a.nullable, b.nullable, b.nullable);
91+
});
7692
}
7793

7894
void expectIntersect(StaticType left, StaticType right, StaticType? expected) {

0 commit comments

Comments
 (0)