Skip to content

Declaration expressions and declaration promotion #1420

Open
@lrhn

Description

@lrhn

Edit 2021-11-24 - Restructure, move "maybe we can also" parts into separate section.


This is inspired by #1091, #1201 and #1210, but is slightly different in approach/scope.

We have an issue with promotion of instance members (or any non-local variable in general).
The #1091 approach is to do a binding at the promotion point. The #1201 approach is to introduce new variables in tests (but do it implicitly in some cases), and #1210 introduces new variables with a new syntax (and also potentially implicitly).

This is a proposal for two features which takes some of #1201 and #1210, but do not introduce any names implicitly, and where the binding can be used independently of the need to promote the variable.

Feature: Assignment Promotion

(Note: discussed in #1844)

First, allow a local variable assignment of the form id assignmentOp expression (potentially parenthesized) to act like the variable itself, when used in a test. We currently allow if (x != null) ... to promote x. This change would also allow if ((x = something) != null) ... to promote x.

It only affects the left-most variable of an assignment, so if ((x = y = something) != null) ... will only promote x, not both x and y (although that's also an option if we really want it - treat both x and the assigned expression as being tested)

If you do if ((x += 1) != null) ... that still works, but there aren't that many operators which return a nullable result, so the usefulness is limited.

This is a very small feature, but it allows you to do “field promotion” as:

int? c;
if ((c = this.capacity) != null) { ... use(c)... }

Feature: Variable declaration expression

Allow var x = e and final x = e as expressions. No types, only var and final. Must have an “initializer expression”.

The expression introduces a new variable named x in the current block scope, and it’s a compile-time error to refer to that variable prior to its declaration.

For statement-level declarations, the “prior to declaration” is anything prior to the source location of the declaration. For a declaration with an initializer expression, that’s equivalent to hoisting the declaration to the top of the current scope block, but keeping the assignment at the original declaration point, and saying you must not refer to the variable where it’s not definitely assigned.

These expression variables, which must have initializer expressions, has the same behavior:

  • The scope of the variable is the current scope, and the variable is initially unassigned (even if it’s nullable). Even if that scope is not one that normally allows local variable declarations (like an initializer list scope or the scope of a => function body).
  • That variable is assigned only where the declaration expression occurs.
  • It’s a compile-time error to refer to the variable where it’s not definitely assigned. Even for assigning to the variable.

For type inference, the context type of the declaration becomes the context type of the RHS, the static type of the RHS becomes the declared type of the variable.

The construct is an <expression>:

<expression> ::= (`var'|`final') <identifier> `=' <expression> |

An <expressionsStatement> expression also cannot start with final or var, just like it currently cannot start with {.

The new constructs, being <expression>s, need to be parenthesized in most places, including before an is check, but also that it can contain any expression as a RHS, including a cascade.

Usage

This feature can introduce a variable at the first point where you need its value, rather than having to go back and declare it further up, even though that’s effectively what it does.

If you have an expression where a sub-expression is repeated twice, you can name it the first time, and then refer to that name the second time:

foo(v.property.name, v.property.value);

Can become:

foo((var p = v.property).name, p.value);

The variable declaration works anywhere there is a surrounding scope (which is any expression), even if you can’t normally have statement-level variable declarations there.

As opposed to let x = e1 in e2-like constructs, where the scope is only e2, the variable uses the same scoping as other local variables, which is “until the end of the current scope”. Because of that, it can also be used where the expressions do not share a common (or at least not close) parent expression, like lists (potentially deeply nested inside another expression):

var list = [1, 2, var x = compute(), 3, x];
fooWithArgumentList(1, 2, var x = compute(), 3, x);

or where we can’t technically have variable declarations, like initializer lists:

class C<T> {
  final StreamController<T> controller;
  final Stream<T> stream;
  C() : controller = (var c = StreamController()), stream = c.stream;
}

(which would currently be implemented using an extra helper constructor), or => function bodies:

BTree<T> buildDag<T>(int depth, T leafValue) => 
   depth == 0 
      ? BTree.leaf(leafValue) 
      : BTree.node(var dag = buildDag(depth - 1, leafValue), dag);

(where we would currently use a {} body to be able to declare the variable up-front.)

Promotion

The var id = e or final id = e counts as assignments for assignment promotion (above), so if ((var c = this.capacity) != null) { ... } would both read capacity into a local variable, then promote that local variable to non-null if possible.

This is the proposed solution to promoting non-local-variable expressions: Introduce a new local variable with the same value, then promote that, and do it in a single expression. Only, unlike #1191, the binding feature is generally useful and not restricted to checks or promotion, and it doesn't clash with a potential pattern syntax for is checks. The binding is not linked to the test, the features are orthogonal, and should therefore also work if we introduce more tests (like pattern matching) in the future.

Alternatives and similar features

There is no implicit assignment of a name to an expression, unlike #1201 and #1210. I personally found those hard to read. We can introduce those as well, so, for example, var foo.bar.fieldName as an expression would be equivalent to (var fieldName = foo.bar.fieldName). Basically: var selector.chain.last, not followed by = and in an expression position, is equivalent to var last = selector.chain.last (and similar for final).

Since this is based on the same logics as the current definite-assignment analysis, it can make variables available in only some continuation of the declaration.

For example:

if ((var y = this.y) != null && (var x = this.x) != null) {
  // `y` definitely assigned here, and not null.
  // `x` definitely assigned here, and not null
} else {
  // `y` definitely assigned here, may be null.
  // `x` not definitely assigned here.
}

Control flow statement scopes

Currently there is no special scope for the conditions of an if or while statement. The condition expression belongs to the surrounding scope. (Or rather, there is no way to tell since you cannot introduce variables inside the condition expression

For a for (;;) statement, there is a new scope introduced for the for statement itself, separate from the block scope of the body (it’s the parent scope of the body scope). The variables declared in the initializer part of the for (;;) loop are declared in that scope.

We could introduce a condition-scope for if and while statements, so that variable declarations in the test belongs only to that scope, and not the surrounding block scope.

Example:

if ((var x = this.x) != null) {
  doSomethingWith(x);  
}
// Should x be available here, unpromoted?

It would be consistent to introduce a wrapper scope for the control flow statement itself, like for for (;;). It’s not necessary, but it means that the variable belongs to the outer scope, and may conflict with other variables in that scope.

On the other hand, it also prevents constructs like:

if ((var x = this.x) == null) return;
// Use x as non-null.

which is a logical extension of the same pattern that we support for promoting local variables.

I’d recommend not introducing that scope.

That means that a variable unconditionally declared in test of while (test) { ... } is available in the body.

Feature: Shorter syntax for local declaration

Alternatively, maybe preferably, introduce x := e as an in-line final declaration, equivalent to the above final x = e, and do not introduce any way to declare non-final local variables.

That means that locally declared variables will all be final. I think that's a good thing. It prevents some use case, like:

 var list = [f(x := 0), f(++x), f(++x), f(++x)];

but I'm not sure such uses are really that essential.

If the local variable declaration expressions can only introduce final variables, it means closures over them can just capture the value, and not worry about getting the correct variable. (Also x := e; can still be used as an expression statement as a short declaration of a final local variable).

Examples (using := only).

class C {
  final int? nullable;
  C(this.nullable);
  String doSomerhing() {
    if ((n := nullable) != null) {  // Use to promote non-local variables.
      return n.toRadixString(16);
    }
    return "0"
  }
}

class Streamer<T> {
  final StreamController<T> _controller;
  final Stream<T> stream;
  // Introduce local variables in initializer list, avoids the "two-constructor-hack".
  Streamer([StreamController<T>? controller>])
      : _controller = c := controller ?? StreamController<T>(),
        _stream = c.stream;  

  // Two-constructor hack for introducing shared computed value.
  StreamController.hack([StreamController<T>? controller>]) 
      : this._hack(controller ?? StreamController<T>()); // Create and forward.
  StreamController._hack(this._controller) : stream = _controller.stream; // Use twice.
}

// Binary tree structure of nodes with two subtrees, and leaves with a value.
abstract class BTree<T> {
  Btree();
  factory Btree.leaf(T value) = BtreeLeaf<T>;
  factory Btree.node(Btree<T> left, BTree<T> right) = BtreeNode<T>;
  BTreeLeaf<T>? asLeaf() => null;
  BTreeNode<T>? asNode() => null;
}
class BTreeLeaf<T> extends Btree<T> {
  final T value;
  BTreeLeaf(this.value);
  BTreeLeaf<T>? asLeaf() => this;
}
class BTreeNode<T> extends Btree<T> {
  final BTree<T> left, right;
  BTreeNode(this.left, this.right);
  BTreeNode<T>? asNode() => this;
}

/// Builds single-width DAG of [depth] nodes and one leaf value.
BTree<T> buildDag<T>(int depth, T leafValue) => 
    // Is useful inside `=>` functions. 
    // Would otherwise require a block body and local variable, or a helper function.
    depth == 0 
        ? BTree.leaf(leafValue) 
        : BTree.node(dag := buildDag(depth - 1, leafValue), dag);

void main() {
  // Just use it as a plain final declaration inside a method body, 
  // but not outside of it (it's an expression).
  // No conflict with local `var`/`final` declarations, unlike `final x = 42`.
  x := 42;  
  
  // Works just like the variable itself when tested for promotion.
  if ((y := intOrNull()) != null) { ... y promoted to int ...}

  LinkedListNode<T> current = ...;
  // Can be used in loop conditions and seen into the body.
  while ((next := current.next) != null) {
    current = next;
  }
  // `current` is last element of in linked list.

  // Useful in collection comprehensions:
  var list = [
      for (var v in someElements) if ((p := v.some.property) != null) p  // Good!
  ];
  // Instead of having to repeat it:
  var list = [
      for (var v in someElements) if (v.some.property != null) v.some.property // Bad!
  ];
  // Or make a hack to bind a variable using `for`:
  var list = [
      for (var v in someElements) for (var p in [v.some.property]) if (p != null) p // Ugly!
  ];
}

Summary

This is really three features:

  • Assignment promotion: Allow (x = e) is T and (x = e) == null to promote x.
    • Nice feature by itself.
  • One of:
    • Local variable declarations: var x = e and final x = e are expressions. They introduce variables into the current block (just like the similar statement-level declarations), but must not be used where the variable is potentially unassigned. Both are also subject to assignment promotion.
    • Local final variable declaration: x := e is an expression. It introduces a final variable into the current block (like final x = e above, but nothing similar to var). Also subject to assignment promotion.
  • Or both, they’re compatible, but that’s probably overkill.

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureProposed language feature that solves one or more problemsfield-promotionIssues related to addressing the lack of field promotion

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions