dart-lang / dart-lang/language
If-variables
- Dominant language
- TeX
- Stars
- 2.9k
- Forks
- 239
- Avg merge
- 2d 18h
- Merged PRs (30d)
- 14
Description
*This is a proposal for how to handle the lack of field promotion with null safety (though it covers more than just that).*
The big hammer in the language for making nullable types usable is flow analysis and type promotion. This lets the imperative code that users naturally write also seamlessly and soundly move nullable variables over to the non-nullable type where the value can be used.
Unfortunately, this analysis isn't sound for fields and getters, so those do not promote:
```dart
class C {
Object obj;
test() {
if (obj is int) obj + 1; // Error. :(
}
}
```
One option is to [enable promotion in the cases where using the field *is* sound](https://github.com/dart-lang/language/issues/1167), but the boundary there is subtle, it's easy to move a variable across it, and may be too narrow to cover most cases. Another option is to automatically [promote fields when failure to do so would cause a static error](https://github.com/dart-lang/language/issues/1188). That trades static failures, which let users *know* their code is unsound, with a runtime error that could cause their program to crash.
Given that the trend in Dart is *away* from code that may *silently* fail at runtime, I'm not enthusiastic about the latter approach. This proposal describes a feature called **"if-variables"** that is local, sound, efficient, explicitly opted in (while being concise), cannot fail at runtime, and covers a larger set of painful cases than any of the other proposals.
It looks like this:
```dart
class C {
Object obj;
test() {
if (var obj is int) obj + 1; // OK!
}
}
```
Basically, take the code you would write today that doesn't promote and stick a `var` (or `final`) in front of the `if` condition. That keyword means "if the type test succeeds, bind a new local variable with the same name but with the tested type". In other words, the above example is roughly syntactic sugar for:
```dart
class C {
Object obj;
test() {
if (obj is int) {
var obj = this.obj as int;
obj + 1; // OK!
}
}
}
```
This binds a *new* local variable. That means reading it later does not read the original backing field and assigning to it does not assign to the field, only to the local variable. This is what makes the proposal efficient and sound. The `var` keyword should hopefully make it clear enough that there is a new local variable in play.
### Promoting on null checks
You can also use `if-var` with nullability checks:
```dart
class C {
int? n;
test() {
if (var n != null) n + 1; // OK!
}
}
```
### Promoting getters
The tested value can be any expression as long as it ends in a named getter:
```dart
class C {
List> points = [Point(1, 2)];
test() {
if (var points[0].x is int) x.isEven; // OK!
}
}
```
In this case, the last identifier in the selector chain is the one whose name is used for the newly bound variable. The expression is only evaluated once, eagerly, and the result is stored in the new variable.
So not only does this let you promote a getter, it gives you a very nice shorthand to access the value repeatedly.
### Negative if-vars
The above examples all test that some value *has* a promotable type. You can also test that the variable does *not* have the type and then exit:
```dart
class C {
Object obj;
test() {
if (var obj is! int) return;
obj + 1; // OK!
}
}
```
When using `is!` and `== null`, the then branch of the `if` statement *must* exit by `return`, `throw`, etc. The newly-bound variable goes into the block scope *surrounding* the `if` statement and continues to the end of the block. In other words, the desugaring is something like:
```dart
class C {
Object obj;
test() {
int obj;
if (obj is! int) return;
obj = this.obj as int;
obj + 1; // OK!
}
}
```
## Proposal
There are basically two separate statements here:
* A positive `if-var` that uses `is` or `!= null` in the condition and scopes the new
variable only inside the then branch. It's somewhat like `if-let` in Swift.
* A negative `if-var` that uses `is!` or `== null` in the condition and scopes the new variable to the code *after* the if statement. It's akin to `guard-let` in Swift.
Here is a somewhat more precise description. We change the grammar like so:
```
ifStatement ::= "if" "(" expression ")" statement ( "else" statement )?
| positiveIfVariable
| negativeIfVariable
positiveIfVariable ::= "if" "(" ifVariable positiveTest ")" statement ( "else" statement )?
negativeIfVariable ::= "if" "(" ifVariable negativeTest ")" statement
ifVariable ::= ( "var" | "final" ) ifValue
ifValue ::= ( ( primary selector* | "super" ) ( "." | "?." ) ) ? identifier
positiveTest ::= receiver? identifier ( "is" typeNotVoid | "!=" "null" )
negativeTest ::= receiver? identifier ( "is" "!" typeNotVoid | "==" "null" )
```
As far as I know, this is unambiguous and compatible with the existing grammar.
### Positive if variables
It is a compile time error if the then statement is a block that declares a local variable whose name is the same as the `identifier` in `ifValue`. In other words, the new variable goes in the same block scope as the then block and you can't have a collision.
To execute a `positiveIfVariable`:
1. Evaluate the expression `ifValue` to a value `v`.
2. Use that value to perform the appropriate type or `null` test in the `positiveTest`. If the result is `true`:
1. Create a new scope and bind the `identifer` from `ifValue` to `v`.
2. Execute the then statement in that scope.
3. Discard the scope.
3. Else, if there is an else branch, execute it.
### Negative if variables
It is a compile time error if the end of the then statement is reachable according to [flow analysis](https://github.com/dart-lang/language/blob/master/resources/type-system/flow-analysis.md).
It is a compile time error if the block containing the `if-var` statement declares a local variable whose name is the same as the `identifier` in `ifValue`. The scope of the declared variable begins before the `if-var` statement and ends at the end of the surrounding block. The variable is considered definitely unassigned inside the then branch of the `if-var` statement and definitely assigned afterwards.
To execute a `negativeIfVariable`:
1. In the current scope, declare a new variable named with the `identifer` from `ifValue`.
2. Evaluate the expression `ifValue` to a value `v`.
3. Use that value to perform the appropriate type or `null` test in the `negativeTest`. If the result is `true`:
4. Execute the then statement.
4. Else:
5. Assign `v` to the variable.
## Questions
### Compatibility?
Since this claim new currently-unused syntax, it is backwards compatible and non-breaking. We can add it before or after shipping null safety.
### Is the local variable's type *declared* to be the promoted type or *promoted* to it?
In other words, is the desugaring like:
```dart
class C {
Object obj;
test() {
if (obj is int) {
int obj = this.obj as int;
}
}
}
```
Or:
```dart
class C {
Object obj;
test() {
if (obj is int) {
Object obj = this.obj as int;
}
}
}
```
I suggest the former. Mainly because this prevents assigned an unexpectedly wide type to the local variable. Attempting to do so likely means the user thinks they are assigning to the original field and not the shadowing local variable. Making that a static error can help them catch that mistake.
### What about pattern matching?
You can think of this feature as a special pattern matching construct optimized for the common case where the value being matched and the name being bound are the same. I think it's unlikely that this syntax will clash with a future syntax for pattern matching, even if we allow patterns in `if` statements. The `var foo is Type` syntax is pretty distinct because it mixes both a little bit of an expression and a bit of a pattern.
### What about other control flow combinations?
The positive and negative forms allowed here don't cover every possible valid combination of control flow, scoping, and unreachable code. In particular, we could also allow:
```dart
class A {
Object obj;
test() {
if (var obj is! int) {
...
} else {
obj; // If-variable in scope here.
}
// And not here.
}
}
```
This isn't particularly useful. You can always swap the then and else cases and turn it into a positive conditional variable.
Also:
```dart
class B {
test() {
if (var obj is! int) {
return;
} else {
obj; // If-variable in scope here.
}
obj; // And also here.
}
}
```
There's no real value in allowing an `else` clause when the then always exits. You can just move the code out of the else to after the if.
Finally:
```dart
class C {
test() {
if (var obj is int) {
obj; // If-variable in scope here.
} else {
obj; // Definitely unassigned here?
return;
}
obj; // If-variable in scope here too.
}
}
```
This one is particularly confusing, since there's a region in the middle where you really shouldn't use the variable.
I don't propose we support these forms. I want it to be clear to users when the conditional variable is scoped to the if statement's then branch and when it goes to the end of the surrounding block. The fewer forms we support, the easier it is for users to understand that.
Contributor guide
Research direction
Start with the proposal and the linked resources/type-system/flow-analysis.md to understand how the proposed scopes interact with existing flow analysis. Done would require resolving the open design questions and producing an accepted Dart language specification change; no implementation files or tests are named.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart
- Domain
- compilers
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100