dart-lang / language

Design of the Dart language
Other
2.65k stars 201 forks source link

Binding expressions #1210

Open eernstg opened 3 years ago

eernstg commented 3 years ago

Binding expressions are expressions that introduce a local variable v with a name which may be taken from the expression itself or it may be specified explicitly. The variable is introduced into the enclosing scope which is limited to the nearest enclosing statement (e.g., an enclosing if statement or loop, or an enclosing expression statement). The variable is accessible but not usable before the binding expression, and it is promoted based on the treatment of the binding expression.

NB: A variant of this proposal using postfix @ is available in this comment below.

The inspiration for this mechanism is #1201 'if-variables', where the conciseness of introducing a variable with an existing name was promoted, and #1191, 'binding type cast and type check', where the ability to introduce a new variable was associated with a general expression.

[Edit Oct 15 2020: Now assuming the proposal from @lrhn that each composite statement introduces a new scope. Nov 24 2021: Mention proposal about using @ rather than var. Nov 25 2021: Make the scope restriction a bit more tight, limiting it to the enclosing expression statement if that's the nearest enclosing statement, and similarly for other statements that include an expression, e.g., <returnStatement>.]

Examples and Motivation

In general, a binding expression can be recognized by having var and :.

It may or may not introduce a new name explicitly: var x: ... introduces the name x, and var:... does not introduce a name explicitly. When a binding expression does not introduce a name explicitly, a name is obtained from the rest of the binding expression. This means that names can be introduces very concisely, and they can be "well-known" in the context because it is already a name which is being used for some purpose.

A binding expression can be used to "snapshot" the value of a subexpression of a given expression:

void main() {
  int i = 42;
  // '42 has 6 bits':
  print('$i has ${i.var:bitLength} bit${bitLength != 1? 's':''}');
  print('$i has ${i.var length:bitLength} bit${length != 1? 's':''}');
}

The construct var:bitLength works as a selector (such that the getter bitLength is invoked), and it also introduces a local variable named bitLength whose value is the result returned by that getter invocation. The construct var length: bitlength gives the new variable the name length, and otherwise works the same. This works for method invocations as well:

void main() {
  var s = "Hello, world!";
  var s2 = s.var:substring(7).toUpperCase() + substring;
  print(s2); // 'WORLD!world!'.
}

In case of name clashes, it is possible to produce names based on templates where $ plays the role as the "default" name:

class Link {
  Link next;
  Link(this.next);
}

void main() {
  Link link = someExpression..var $1:next.var $2.next;
  if (next1 == next2) { /* Cycle detected */ }
}

Apart from selectors and cascade sections, binding expressions can also bind the value of a complete expression to a new variable:

class C {
  int? i = 42;

  void f1() {
    if (var:i != null) { // Snapshot instance variable `i`, create local `i`, scoped to the `if`.
      Expect.isTrue(i.isEven); // Local `i` is promoted by test.
      this.i = null; // Assignment to instance variable; `i = null` is an error.
      Expect.isTrue(i.isEven); // The local variable still has the value 42.
    } else {
      // Local `i` is in scope, with declared type `int?` from initialization.
      Expect.isTrue(i?.isEven);
    }
  }
}

The binding expression var:i introduces a new local variable named i into the scope of the if statement (the if statement is considered to be enclosed by a new scope, and the variable goes into that scope). It is an error to refer to that local variable before the binding expression, so if we have foo(var v1:e1, var v2:e2), e2 can refer to v1, but e1 cannot refer to v2.

A binding expression always introduces a final local variable. The main reason for this is that it is highly error prone to have a local variable whose name is the same as an instance variable, which serves as a proxy for the instance variable because it has the same value (at least initially), and then assignment to the local variable is interpreted to be an assignment to the instance variable.

A binding expression is intended to be a "small" syntactic construct. In particular, parentheses must be used whenever the given expression uses operators (var x: (a + b)) or other constructs with low precedence. It is basically intended to snapshot the value of an expression of the form <primary> <selector>*, that is, a receiver with a chain of member invocations, or even smaller things like a single identifier.

In return for this high precedence, we get the effect that expressions like var:i is T or var:i as T parses such that the binding expression is var:i, which is a useful construct because it introduces a variable i and possibly promotes it to T.

The conflict between two things named i is handled by a special lookup rule: For the construct var:i, the fresh variable i is introduced into the current scope, and the initialization of that variable is done using the i which is looked up in the enclosing scope. In particular, if i is an instance, static, or global variable, var:i will snapshot its value and provide access to that value via the newly introduced local variable with the same name.

This implies several things: It is an error to create a new variable using a binding expression if the resulting name clashes with a declaration in the current scope. (The template based naming that makes $1 expand to next1 and such helps creating similar, non-clashing names). Also, in the typical case where a binding expression introduces a name i for a local variable which is also the name of an instance variable, every access to the instance variable in that scope must use this.i. This may serve as a hint to readers: If a function uses this.i = 42 then it may be because the name i is introduced by a binding expression.

Update Oct 15: @lrhn's proposal that every composite statement should introduce a scope containing just that statement is assumed in this proposal, and it is extended to wrap every <expressionStatement>, <returnStatement>, and a few others, in a new scope as well. This implies that when a binding expression introduces a variable and it is added to the current scope, it will be scoped to the enclosing statement S, which may include nested statements if S is a composite statement like a loop. If a binding expression occurs in a composite statement then it will introduce a variable which is available in that whole composite statement (e.g., also the else branch of an if statement), but only there.

Grammar

The grammar is updated as follows:

<postfixExpression> ::= // Modified rule.
  <assignableExpression> <postfixOperator> |
  <expressionBinder>? <primary> <selector>*

<unconditionalAssignableSelector> ::= // Modified rule.
  '[' <expression> ']' |
  '.' <expressionBinder>? <identifier>

<assignableSelector> ::= // Modified rule.
  <unconditionalAssignableSelector> |
  '?.' <expressionBinder>? <identifier> |
  '?' '[' <expression> ']'

<cascadeSelector> ::= // Modified rule.
  '[' <expression> ']' |
  <expressionBinder>? <identifier>

<expressionBinder> ::= // New rule.
  'var' <identifier>? ':'

The overall rule is that a binding expression can be recognized by having var and :, which makes it different from a local variable declaration (with var and =), thus helping both readers and parsers to disambiguate. Apart from the fact that both = and : are used to provide values for a variable in Dart (e.g., for variable initializers and named parameters), the rationale for using : is that it makes var:x introduce a variable named x and obtains its value using the x that we would get if this new variable had not been created. Various other syntaxes seem less suggestive.

Static Analysis

Every form of binding expression e introduces a final local variable into the current scope of e. Below we just specify the name and type of the variable, and the scope and finality is implied.

This proposal includes the following change to the scoping rules: Each statement S is immediately enclosed in a new scope if S is derived from one of the following: <forStatement>, <ifStatement>, <whileStatement>, <doStatement>, <switchStatement>, <expressionStatement>, <returnStatement>, <assertStatement>, <yieldStatement>, <yieldEachStatement>. For instance print('Hello!'); is treated as { print('Hello'); } and if (b) S is treated as { if (b) S }.

A local variable declaration D is treated such that variables introduced by a binding expression in D are in scope in that local variable declaration, and not outside D. This cannot be specified as a syntactic desugaring step, but it can be specified in a similar manner as the scoping for local variable introduced by an initializing formal parameter.

The main rationale for making the variable final in all cases is that it is highly error prone to save the value of an existing variable x (for instance, an instance variable) in a local variable whose name is also x, and then later assign a new value to x under the assumption that it is an update to that other variable. In this case the assignment must use this.x = e because the local variable x is in scope and is final.

If a variable x is introduced by a binding expression e then it is a compile-time error to refer to x in the current scope of e at any point which is textually before the end of e.

A binding expression of the form var x: e1 introduces a variable named x with the static type of e1 as its declared type.

A binding expression of the form var:e1 is a compile-time error unless e1 is an identifier.

A binding expression of the form var:x where x is an identifier introduces a variable named x with the static type of x in the enclosing scope as the declared type.

Note that x is looked up in the enclosing scope, not the current scope. It would be a compile-time error to look it up in the current scope (because that's a reference to the variable itself before it's defining expression ends), and it is likely to be useful to look it up in the enclosing scope because the intended variable/getter would often be a non-local variable.

A binding expression of the form e1?.var x:m<types>(arguments) where ? and <types> may be omitted introduces a variable named x whose declared type is the static type of e1.m<types>(arguments) when this selector does not participate in null shorting, and the static type of e1?.m<types>(arguments) when it participantes in null shorting.

A binding expression of the form e1?.var:m<types>(arguments) where ? and <types> may be omitted introduces a variable named m whose declared type is the static type of e1.m<types>(arguments) when this selector does not participate in null shorting, and the static type of e1?.m<types>(arguments) when it participates in null shorting.

If the previous two cases are not applicable, a binding expression of the form e1?.var x:m where ? may be omitted introduces a variable named x whose declared type is the static type of e1.m when this selector does not participate in null shorting, and the static type of e1?.m when it participates in null shorting; and a binding expression of the form e1.var:m introduces a variable named m, with the same static types as the previous variant.

A binding expression of the form e?..var x:m<types>(arguments) where ? and <types> may be omitted introduces a variable named x whose declared type is the static type of e0.m<types>(arguments) when this cascade section does not participate in null shorting, and the static type of e0?.m<types>(arguments) when it participates in null shorting, where e0 is the receiver of the cascade.

A binding expression of the form e?..var:m<types>(arguments) where ? and <types> may be omitted introduces a variable named m whose declared type is the same as in the corresponding situation in the previous case.

If the previous two cases are not applicable, a binding expression of the form e?..var x:m where ? may be omitted introduces a variable named x whose declared type is the static type of e0.m when this cascade section does not participate in null shorting, and the static type of e0?.m when it participates in null shorting, where e0 is the receiver of the cascade. Similarly, a binding expression of the form e?..var:m where ? may be omitted introduces a variable named m with the same declared type as the corresponding case.

In all these cases, the expression that provides the declared type of the newly introduced variable is known as the initializing expression for the variable.

In all these cases, and in expressions with control flow (conditional expressions, logical operators like && and ||, and so on), it is a compile-time error to evaluate a variable v which has been introduced by a binding expression e unless e is guaranteed to have been evaluated at the location where the evaluation of v occurs.

A special mechanism is provided for the case where a selector has a name that clashes with an existing name in the current scope: If a $ occurs in the specified name then the name of the newly introduced variable replaces $ by the name that would be used if no name had been specified, capitalizing it unless $ is the first character.

For example, a.var $1:next.var $2:next introduces a variable named next1 and another variable named next2; a.var first$:bar.var second$:bar introduces variables firstBar and secondBar; and var x$x: 'Hello' is an error.

If a variable x is introduced by a binding expression e then let e0[[e]] be a notation for the enclosing expression e0 with a "hole" that contains e. Promotion is then applied to x as if it had occurred in an expression of the form e0[[x]].

For example, var x: e is T promotes x to type T iff x is T would have promoted x.

Dynamic Semantics

A local variable x introduced into a scope by a binding expression e is initialized to the value of the initializing expression of x at the time where e is evaluated. If the initializing expression of x is not evaluated (due to null shorting), x is initialized to null.

Discussion

About Generated Names

The mechanism that generates a name from a template may be considered arbitrary: It depends on $, it uses capitalization (which is only fit for camelCasedNaming), and it is unique in Dart in that it creates a name based on a textual transformation. It was included because it seems likely that the use of binding expressions will create name clashes where there is a genuine need for creating several similar names.

The syntax is quite bulky: a.b.var firstName: b.var secondName: b makes it difficult to see the core expression a.b.b.b. It may be helpful to format this kind of construct with plenty of whitespace, such that the introduction of new variables is emphasized:

void main() {
  print(a.b
      .var firstName: b
      .var secondName: b);
}

Alternative Syntax: Use @ and @name:

In this comment @jodinathan proposed using @ rather than var as the syntactic element that initiates the variable declaration:

// As proposed above:

void main() {
  int i = 42;
  // '42 has 6 bits':
  print('$i has ${i.var:bitLength} bit${bitLength != 1? 's':''}');
  print('$i has ${i.var length:bitLength} bit${length != 1? 's':''}');
}

// With the `@` based proposal:
void main() {
  int i = 42;
  // '42 has 6 bits':
  print('$i has ${i.@bitLength} bit${bitLength != 1? 's':''}');
  print('$i has ${i.@length:bitLength} bit${length != 1? 's':''}');
}

One (admittedly subjective) benefit is that the visually disruptive space after var is avoided, and another one is that @ can be used before any <selector>, e.g., myList@firstElement[0] would correspond to var firstElement = myList[0].

One (similarly subjective) drawback is that there is no hint in the syntax itself about the fact that a variable is being declared. For instance, a.@b would not be seen as a construct that declares a variable named b by any developer who hasn't been told explicitly that this is exactly what that @ does. However, if it's used frequently enough then it probably doesn't matter much whether or not we can guess what it means the very first time we see it, no explanations given.

lrhn commented 3 years ago

It's a very interesting concept.

It is necessarily limited to selectors because it has to link to an identifier to make the name optional. That means that it doesn't work for foo(bar(42), ... same value as bar(42)...) unless you can also write foo(var b: bar(42), b) as a non-selector. Then we are really back to making variable declarations valid expressions, evaluating to their values like a normal assignment, and we could just write foo(var b = bar(42), b).

I'm not sold on the syntax. The : seems like it separates more than the . so foo.var x:bar seems more like the var x binds to the previous foo than to the bar. (Maybe that's a different approach: Suffix .var or .var x binds the previous expression. Then we'd have print('$i has ${i.bitLength.var} bit${bitLength != 1? 's':''}'); or foo(bar(36).var, bar). If the previous expression is a named function call or variable, we bind to that name. Probably an issue if you do foo.var and introduce a new variable named foo in the same scope, though, unless we really restrict the scope to downstream from the declaration, not just to the entire same block).

eernstg commented 3 years ago

It's a very interesting concept.

Thanks, it builds on nice sources of inspiration, too. ;-)

It is necessarily limited to selectors

That's actually not true: Any binding expression can be named (var x: e), and it is also possible to use an unnamed binding expression when the expression is an identifier, as in var:x. In that case the x is introduced into the current scope, and the initializing expression which is also x is looked up in the enclosing scope.

The construct is deliberately limited to "small" constructs (<primary> <selector>*), such that it fits in with tests like var:x is T (meaning (var:x) is T). This means that we need parentheses for var x: (a + b), but my hunch is that it's better to use a regular local variable declaration for "big" expressions anyway.

foo(bar(42), ... same value as bar(42)...)

That would be handled as foo(var b: bar(42), ...b...), as you mentioned.

we could just write foo(var b = bar(42), b).

I think it would be difficult to parse a <localVariableDeclaration> as an expression unambiguously, or it would have to have a very low precedence. That's one of the reasons why I'm using var along with :, and giving the whole construct a rather high precedence (making it very similar to null shorting in binding power).

The : seems like it separates more than the .

Yes, that is definitely an issue. I think we can at least milden the reading difficulty in a few ways: We could use a standard formatting where .var is forced to go on a new line, except the shortest constructs like a.var:b. So we basically use newline if there is a binding expression that needs a space, and then the eye can catch on to .var and understand what is going on.

I'm also thinking that var will light up in a different color in most IDEs, so a.var:b will self-announce the variable to some extent, which makes it more readable in the case where we don't have .var first on a line.

Suffix .var or .var x binds the previous expression

It would be interesting to study the implications of that design. However, I tend to like the fact that var x: expression and receiver.var x:getter is a bit more like var x = expression respectively var x = receiver.getter than expression.var x and receiver.getter.var x.

restrict the scope to downstream from the declaration

I don't think we should do that: I prefer your approach from the binding type checks/tests, where the new variable is added to the current scope. I think it's going to be considerably more error prone to have two variables named x in the same visual scope (as shown by braces and indentation), and then suddenly we switch over from one to the other because of a binding expression which is hidden deep in an expression.

leafpetersen commented 3 years ago

@eernstg I would like to explore what some actual code looks like under these various proposals. Here's an example piece of code that I migrated, which I think suffers from the lack of field promotion. This is from the first patchset from this CL - I subsequently refactored it to use local variables, the result of which can be seen in the final patchset of that CL. This was a fairly irritating refactor, and I don't particularly like the result. Would you mind taking a crack at showing how this code would look under your proposal?

// The type of `current` is `Node`, and the type of the `.left`  and `.right` fields is `Node?`.
while (true) {
      comp = _compare(current.key, key);
      if (comp > 0) {
        if (current.left == null) break;
        comp = _compare(current.left!.key, key);
        if (comp > 0) {
          // Rotate right.
          Node tmp = current.left!;
          current.left = tmp.right;
          tmp.right = current;
          current = tmp;
          if (current.left == null) break;
        }
        // Link right.
        right.left = current;
        right = current;
        current = current.left!;
      } else if (comp < 0) {
        if (current.right == null) break;
        comp = _compare(current.right!.key, key);
        if (comp < 0) {
          // Rotate left.
          Node tmp = current.right!;
          current.right = tmp.left;
          tmp.left = current;
          current = tmp;
          if (current.right == null) break;
        }
        // Link left.
        left.right = current;
        left = current;
        current = current.right!;
      } else {
        break;
      }
    }
eernstg commented 3 years ago

Sure, here we go:

  // Context, based on CL:
  // Comparator<K> get _compare: Instance getter of enclosing class.
  // K key: parameter to enclosing function.
  // int comp: local variable.
  // Node current: local variable.
  // It seems that `right` and `left` are local variables (the code won't work if it's the fields).
  while (true) {
    comp = _compare(current.key, key);
    if (comp > 0) {
      if (var oldLeft: current.left == null) break; // Snapshot `current.left` as `oldLeft`.
      comp = _compare(oldLeft.key, key);            // `current.left!` -> `oldLeft`.
      if (comp > 0) {
        // Rotate right.
        current.left = oldLeft.right;               // Replace `tmp` by `oldLeft`: it already has that value.
        oldLeft.right = current;
        current = oldLeft;
        if (current.left == null) break;            // NB: Not `oldLeft`, it's based on another `current`.
      }
      // Link right.
      right.left = current;                         // Not sure how `right` was initialized?
      right = current;
      current = current.left!;                      // Snapshot of `current.left` not helpful here.
    } else if (comp < 0) {
      if (var oldRight: current.right == null) break;
      comp = _compare(oldRight.key, key);
      if (comp < 0) {
        // Rotate left.
        current.right = oldRight.left;
        oldRight.left = current;
        current = oldRight;
        if (current.right == null) break;
      }
      // Link left.
      left.right = current;                         // Again, not sure how `left` was initialized.
      left = current;
      current = current.right!;
    } else {
      break;
    }
  }

It could have been slightly more concise if I had used the existing names left and right for the new local variables (that could be done with if (current.var:left == null) break;), but that would clash with the names left and right which are used already in the code.

I can see that SplayTree has fields named left and right, and the post-migration code has local variables left and right in the enclosing method (with declared type Node?, and with different treatment such that they are promoted to Node as needed). I just preserved the behavior as given in this example code, and treated left and right as local variables with type Node, such that the given code does not have an error at right.left = current; and similar statements.

I think it's worth noting that this code is updating the field that we're snapshotting, which is basically the worst case for using a snapshot. I used the names oldLeft and oldRight in order to emphasize that there is a difference between the snapshot and the syntactic expression which was used to get the snapshot (otherwise the names currentLeft/currentRight would have been a natural choice).

So there would presumably be lots of situations where we are actually just using the value of a field (and not updating the same field), and we just want to remember the outcome of null tests and type tests, and there are no name clashes. In that case we could of course use shorter names like left and right.

AKushWarrior commented 3 years ago

Honestly, I think this syntax is far more versatile than the one in #1201 . Since : is a clear definition of binding expression assignment, it's usable virtually everywhere, and is clear in most cases.

To address @lrhn 's concerns about getter binding expressions such as foo.var x:bar, perhaps there could be a lint Prefer enclosing vague binding expressions in parentheses, which corrected code to, e.g., foo.(var x: bar)? Would allowing for the potential parentheses there be a structural issue? It seems to me that that correction eliminates the ambiguity as to what order things are evaluated; I don't think that code conflicts with anything, because parentheses after decimals are currently not supported.

Also, @eernstg, instead of typing out oldLeft in your example, wouldn't it make more sense to use $Old? Isn't the point of the $ syntax to demarcate the similarity in meaning while preserving sanity for later usages?

lrhn commented 3 years ago

I think the parentheses still separates the foo. from the bar. Maybe if it was foo.(var x:)bar or foo.(var x =)bar.

If we allow var x: as a prefix operation in general (so 1 + var x :e) is also valid, and we allow prefix operations in selectors (#1216), so foo.~bar is valid and equivalent to (~foo.bar), then we might get used to reading things like foo.var x:bar, since it's consistent with other operations. (But we could also use foo.var x = bar then, the var ensures that the = isn't ambiguous).

We probably need the var (or final), which means no types: foo.int x:bar/foo.int x = bar. I'd be worried about parsing that, although it's not impossible that it's doable.

AKushWarrior commented 3 years ago

@lrhn

I think the parentheses still separates the foo. from the bar. Maybe if it was foo.(var x:)bar or foo.(var x =)bar.

foo.(var x :)bar.getterOfBar or foo.(var x =)bar.getterOfBar, makes sense to me, though it would be more unclear without those parentheses:

if foo.~bar is valid and equivalent to (~foo.bar), then we might get used to reading things like foo.var x:bar, since it's consistent with other operations.

foo.var x: bar.getterOfBar is still ambiguous because foo.var almost looks like a type. Of course, IDEs would probably light up the var, so it might be okay, but that looks kind of hellacious to read while browsing a GitHub repository. That's the primary motivation behind the parentheses idea; it's also not unheard of to use prepended parentheses to perform an action while maintaining the value of the variable (Java casts is one that comes to mind), so it's kind of semantically familiar.

(But we could also use foo.var x = bar then, the var ensures that the = isn't ambiguous).

I do think that there's value in having a distinct binding operator (e.g. colon) as opposed to equals, because binding assignments are clearly different from a normal assignment and should have a visual distinctifier. On the other hand, it might also be syntactically confusing, because : in other languages is used to denote a type. It might be easier to stick with an equals sign because of the confusion factor, or perhaps use something like := instead.

We probably need the var (or final), which means no types: foo.int x:bar/foo.int x = bar. I'd be worried about parsing that, although it's not impossible that it's doable.

As to the whole parsing types thing, I agree that it's an issue that likely can't be solved without making distinct class and property names mandatory, which is definitely a verbose anti-pattern. It might take some advanced contextual analysis to distinctly allow foo.BarType x = bar.barGetter without also saying BarType is not a valid property of foo.

It might (?) be more feasible if BarType x = was couched in parentheses: because then you could explicitly search for those parentheses before a getter/method, and do static type analysis from there.

AKushWarrior commented 3 years ago

@tatumizer see #1201 for a better idea of what this is meant to solve. It is an enhanced assignment operator, but it has potential to be a lot more.

AKushWarrior commented 3 years ago

Ah, okay I think I misunderstood your original point. So my understanding is that the whole point is to allow adding var in more places; there is disagreement over how to do that. The argument for some syntactical distinction is that this binding kind of assignment is actually different; it also represents an object, where normal assignment usually represents nothing.

    String baseData = "";
    var x = baseData.(var y:)length > 0; //This version
    var a = baseData.var b = length > 0; //Using current assignment syntax

Take this example. If you use a separate operator, you can nest binding assignments virtually anywhere; the first pretty clearly displays what's going on. The second uses the current assignment syntax, which brings up a few issues:

Of course, that's not to say that this syntax doesn't have some issues. I personally like it better just for clarity's sake.

AKushWarrior commented 3 years ago
void main() {
  String baseData = "";
  var length;
  var x = (length=baseData.length).bitLength > 0;
  print (length); // prints 0
}  

Okay... but that's not the point of the issue. The idea is to not have to say (var length = baseData.length). Instead, you could say the cleaner baseData.(var length:)length;. You then don't have to deal with nesting and can chain these together.

The two are strictly different, as well: In someOp((var length = baseData.length));, you perform someOp on the created copy length of baseData.length, not the original object. In someOp(baseData.(var length:)length) the copy is created, and then you perform someOp on the original object.

AKushWarrior commented 3 years ago

Your example can be more consistently treated as an extension of cascade operator - again, we can just allow adding "var" there:

someOp(baseData..var length=length); // note the double dot

Cascade returns the original object.

yes it does, but... why do more work and introduce parsing complexities to make code harder to read?

AKushWarrior commented 3 years ago

why do more work and introduce parsing complexities to make code harder to read?

The Law of Parsimony. Why introduce new syntax where the old one suffices?

True. It's a judgement call and probably personal preference. Ultimately, it'll be up to the Dart devs, not us; I think that we've probably exhausted this particular argument now.

eernstg commented 3 years ago

@tatumizer wrote:

Whatever this new construct aspires to achieve, can be done using existing dart syntax .. What is missing here is just a way to declare bitLength variable inline

Right, that is the topic of this discussion: Can we find a good way to introduce local variables which is more flexible than <localVariableDeclaration>. This proposal allows an expression to introduce a local variable.

we can allow adding "var" in more places

That's what basically all these proposals (#1201, #1191, and this one) do. This proposal uses : rather than =, but otherwise the syntax is quite similar to <localVariableDeclaratio>.

The use of : disambiguates the construct; basically, = is much more ambiguous when it occurs in an expression than : because it is used for a larger number of things already. The use of : to bind a variable to a value is known from named parameters already, and (to me) it seems reasonable to use the form var:x to say that "x is a new local variable, and it's initialized by the meaning of x that we get if we skip that new local variable".

What I propose is to allow an inline declaration instead of a separate "length" declaration:

void main() {
  String baseData = "";
  var x = (var length=baseData.length).bitLength > 0;
  print (length);
}  

This is a slight extension of the existing syntax, which doesn't require much explanation.

Indeed. However, this proposal also aims to allow concise forms where the name of the new variable is taken from the initializing expression or selector. For example, the above could be written as follows:

void main() {
  String baseData = "";
  var x = baseData.var:length.bitLength > 0;
  print(length);
}

So the point is not that it is impossible to allow for <localVariableDeclaration>-ish constructs in some new locations, the point is that this proposal does nearly exactly that, with some twists that allow for a concise form in many cases.

Why introduce new syntax where the old one suffices?

I'm proposing to use : rather than = in order to disambiguate (for parsers and human beings), and in order to support the conciseness of forms like a.var:b and var:x. I think var=x looks less meaningful than var:x.

I would expect the named form to be used for whole expressions (with selectors I'd expect the nameless form to be much more common), like var x: e, and they can of course occur as (var x: e) anywhere a <primary> can occur (which is almost anywhere).

It might look slightly more "normal" if we were to use (var x = e), but that would be somewhat misleading because the binding expression has different properties. In particular, the binding expression allows for the variable to be promoted, as in var:x is T or (var x: e) is T.

eernstg commented 3 years ago

@AKushWarrior wrote:

perhaps .. lint which corrected code to, e.g., foo.(var x: bar)?

I think the grammar could rather easily be adjusted to allow parentheses along these lines, and it might improve the readability in some cases. However, it's not obvious that they would appear to be very "natural" if they apply to the selector syntactically: foo.(var x: bar)(42) would make the method invocation look funny, and if we make it foo.(var x: bar(42)) then we need to consider what it would mean to say foo.(var x: bar<int>(42).baz[0]).qux, and so on.

@lrhn wrote:

Maybe if it was foo.(var x:)bar

That would eliminate the complexities associated with parentheses around a non-singleton sequence of selectors.

But I still suspect that it's equally easy to learn to look further for the selector if the eye hits .var, because the : is right there after the identifier.

if .. 1 + var x :e .. is also valid,

1 + var x:e is in fact valid in the proposal when e can be derived from <primary> <selector>*. So we can have 1 + var x: foo.bar(42).and?.so.on, but it must be 1 + var x: (2 + 3) in order to bind x to the value of 2 + 3.

But we could also use foo.var x = bar then, the var ensures that the = isn't ambiguous

True, but that only works when var is used on a selector, not for usages on expressions. For instance, if var x = (2 + 3) + 1 occurs as an expression statement then we can't see whether it's a <localVariableDeclaration> or a <bindingExpression>.

I think it makes sense to use : everywhere for binding expressions because it removes a lot of ambiguity, both for parsers and for human beings.

lrhn commented 3 years ago

If the inline foo.var x:bar().baz binds x to foo.bar(), then should a leading var x:foo.bar().baz bind x to foo or to foo.bar().baz?

The latter seems to be what you are suggesting, but it feels inconsistent (but then, if we have inline prefix operators, then they should probably all work that way, and then it is consistent.

Also, why let foo.var x:bar().baz(x) bind x to foo.bar() and not just foo.bar? What if foo.bar is a function-typed getter? What if I want foo.var x: bar()!.baz(x) to bind to bar()!? There is no way to control the range of the binding except by special-casing "selectors" (and the fact that .foo(args) is parsed as two selectors instead of one is an accident of grammar design, it's not treated that way by the semantics).

So, I'd still prefer a suffix binding to an inline prefix binding, if we want to avoid parentheses, or just a prefix declaration if we don't care about that:

1 + (var x = foo.bar()).baz(x)  // general declaration-as-an-expression, no selector special-casing
// or
1 + foo.bar().var x.baz(x)  // and then `1 + foo.bar() as<var x>.baz(x)` is very close.
eernstg commented 3 years ago

should a leading var x:foo.bar().baz bind x to foo or to foo.bar().baz?

As specified, x is bound to foo.bar().baz, because it parses a <primary> <selector>* after the :. If you want to bind it to foo then you'd need to have (var x:foo).bar().baz.

What if I want foo.var x: bar()!.baz(x) to bind to bar()!`?

True, there's no specialized support for doing that. Again, I'm prioritizing a likely useful choice and conciseness, so there is no new syntactic device for specifying less common choices. But we could use (var x: foo.bar()!).baz(x).

Of course, there's a need to use a more verbose rewrite if we have null-shorting in the part that we wish to bind to the new variable. As usual, I gave priority to the interpretation that I found most likely to be useful, so the binding selector participates in null-shorting and the type of the new variable is nullable when its selector can be null shorted.

ds84182 commented 3 years ago

One thing I'm worried about here is the use of var, which implies that it can be "rebound" mid expression: [var x:123, x++, x++, x++]

But that does not seem to be the case. Would val (like Kotlin) or let (like Rust) make more sense here? Also related: #136

AKushWarrior commented 3 years ago

But that does not seem to be the case.

Why not? I don't see anything contradicting this.

ds84182 commented 3 years ago

@AKushWarrior

A binding expression always introduces a final local variable. The main reason for this is that it is highly error prone to have a local variable whose name is the same as an instance variable, which serves as a proxy for the instance variable because it has the same value (at least initially), and then assignment to the local variable is interpreted to be an assignment to the instance variable.

AKushWarrior commented 3 years ago
void main() {
  var x=123;
  var y:123;
}

Are these declarations equivalent? If not, what's the difference?

The latter is an antipattern; the latter also represents an object, where as the former represents void.

AKushWarrior commented 3 years ago

@AKushWarrior

A binding expression always introduces a final local variable. The main reason for this is that it is highly error prone to have a local variable whose name is the same as an instance variable, which serves as a proxy for the instance variable because it has the same value (at least initially), and then assignment to the local variable is interpreted to be an assignment to the instance variable.

Ah. I think that maybe final is good instead of var then, because we already have that keyword.

eernstg commented 3 years ago

@AKushWarrior wrote:

Ah. I think that maybe final is good instead of var then

I chose var because it is shorter (and there's no end to the amount of pain inflicted by long keywords ;-). It would be more consistent to use final, because "that's what it means, anyway!". But any misunderstanding with respect to this will be resolved at compile-time because any attempt to assign to such a variable will be an error, so it's not likely to cause bugs.

eernstg commented 3 years ago

@tatumizer wrote:

void main() {
    var x=123;
    var y:123;
}

Are these declarations equivalent? If not, what's the difference?

I agree with @AKushWarrior's remarks on this, but also: y is final; and the binding expression restricts the value to be derived from <primary> <selector>*, so you can't just switch to var y: e in general, and if you use var y:a + b; where a is derived from <primary> <selector>* (e.g., it could be an identifier) then you'll initialize y to the value of a, not a + b.

The binding expression is really intended to be used with "small" initializing terms (expressions or selectors), inside a bigger expression, and if you want to create a variable and bind it to a "big" expression then a <localVariableDeclaration> is the natural choice.

eernstg commented 3 years ago

@lrhn wrote, about suffix forms:

1 + foo.bar() as<var x>.baz(x) is very close

It's definitely an interesting variant of the proposal to use a suffix form.

If we allow 'as' '<' 'var' identifier? '>' as a selector then it could bind x (so foo.bar()() as<var x>.baz would give x the value of foo.bar()()), and it could use the nearest name as the default name (so foo.bar()() as<var>.baz would bind bar to that value).

But we'd need some extra disambiguation in order to bind the suffix form to any other expression. Perhaps it would just require parentheses (in which case it's still a selector, so we don't have any other syntactic forms at all than the selector), like (a + b) as<var x>, in the cases where the expression isn't derivable from <primary> <selector>* or the similar case for cascades.

It should be possible. It's not obvious to me that one is much better than the other:

class C {
  int a, b;

  // Usages; assume different scopes, hence no name clashes.
      // Introduce a new name, apply to expression.
      (a + b) as<var x> + x;
      var x:(a + b) + x;

      // Reuse name of identifier from enclosing scope.
      a as<var> + a;
      var:a + a;

      // Use 'default' name, apply to selector.
      a.bitLength as<var> + bitLength;
      a.var:bitLength + bitLength;

      // Use new name, apply to selector.
      a.bitLength as<var b> + b;
      a.var b:bitLength + b; 
  }
}
AKushWarrior commented 3 years ago

@lrhn wrote, about suffix forms:

1 + foo.bar() as<var x>.baz(x) is very close

It's definitely an interesting variant of the proposal to use a suffix form.

@eernstg Where did he write that? On another issue?

lrhn commented 3 years ago

@tatumizer Fewer parentheses! Linear writing!

When you are writing and have already written 1 + foo.bar() and realize you need to name that (or cast it without naming it), it's much easier to continue writing as <Bar> or as <Bar bar> or as <var bar> instead of needing to go back and add a start parentheses before foo. For a long chain like (~(-foo.bar().baz().qux()).whatnot()).something() it gets increasingly hard to see where the prefix operators apply.

That's the reason we've been considering an inline cast to begin with, and are considering an inline await as well. Prefix operations simply do not work very well with long chains of selectors, and Dart otherwise encourages long chains.

eernstg commented 3 years ago

@AKushWarrior wrote (about 1 + foo.bar() as<var x>.baz(x) is very close):

Where did he write that?

https://github.com/dart-lang/language/issues/1210#issuecomment-694181312.

@lrhn wrote (about why a suffix form would be desirable):

Fewer parentheses! Linear writing! When you are writing and have already written 1 + foo.bar() and realize you need to name that (or cast it without naming it), it's much easier to continue writing

I'd usually give a higher priority to readability than writability, because it's likely that code needs to be understood more frequently than it is modified, and for that it seems useful to announce the variable introduction just before the name of the variable:

  a.bitLength as<var> + bitLength; // Search back to see which `var` that was.
  a.var:bitLength + bitLength; // Aha, we're creating a variable named `bitLength`.

I think the most tricky part is the readability of the verbose cases where it makes a selector much bigger than usual:

  a.bitLength as<var newNameForBitLength> + newNameForBitLength;
  a.var newNameForBitLength:bitLength + newNameForBitLength; 

It's worth considering a rewrite to use a normal <localVariableDeclaration> in such verbose cases.

I think the most important benefit that the suffix form brings is that it easily allows us to include any desired number of non-identifier selectors:

  a.foo(16)<int>(true) as<var x>.bar + x;
  (var x: a.foo(16)<int>(true)).bar + x;
eernstg commented 3 years ago

@tatumizer wrote:

with current syntax rules, it breaks the chain from the compiler's viewpoint

The implication would be that we need new syntax, but the inclusion of < and > is just that. However, foo.as<int>() is of course already correct syntax because as is a built-in identifier, so we can't just put a . rather than whitespace before as.

And why var acquires the meaning of final in this context

My reason for using var was that it is widely desired to have a concise syntax, and it is always preferable to get a final variable. In particular, it is highly error prone to have x = newValue if x is a local variable which is a snapshot of an instance variable x.

eernstg commented 3 years ago

it gives a green light to using var x:123 as a (shorter) synonym to final x=123.

That's true, and it could be considered a danger. But I tend to think that it's a greater danger to allow a lot of these variables to be mutable, just because everyone uses var rather than final, because it's shorter.

AKushWarrior commented 3 years ago

it gives a green light to using var x:123 as a (shorter) synonym to final x=123.

That's true, and it could be considered a danger. But I tend to think that it's a greater danger to allow a lot of these variables to be mutable, just because everyone uses var rather than final, because it's shorter.

@eernstg what if we introduced a new keyword, to get rid of any existing associations? something like bind x: getter or val x: getter. Those are both shorter; val also is semantically the same as final in many languages. Then, that keyword would only be usable in binding expressions, and behavior of the keyword would not change depending on the context in which it is used.

eernstg commented 3 years ago

@tatumizer wrote:

idea of generating the name of a new variable automatically

  • based on the name of the selector as a default

True, the ability to create a new variable with an existing name (a selector for some forms, an identifier for others) was one of the starting points for this proposal, and it is not necessary. But it is also not necessary to support this.x parameters with constructors. The question is whether we as a community would be happier if we can write code that is a bit more concise using these "default" names.

If this "default" name generation is eliminated, then there's no reason to use : - normal = serves well

That construct wouldn't have the same precedence, so if (var:x is T) ... would have to be if ((var x = this.x) is T) .... If conciseness is considered important then it would make a difference.

@AKushWarrior wrote:

new keyword ... val

val would be great, for the reasons you mention, but any new reserved word or even built-in identifier is a breaking change, so that's a much less realistic solution.

AKushWarrior commented 3 years ago

val would be great, for the reasons you mention, but any new reserved word or even built-in identifier is a breaking change, so that's a much less realistic solution.

Ah well. It was a pipe dream, yeah. Although, honestly, code that uses val as an identifier is not very high quality code ;)

eernstg commented 3 years ago

@tatumizer wrote:

x.is(T x1)

That's on interesting idea—is is a reserved word that doesn't otherwise occur after ., so it should be easy to parse. It does allow for an unnamed variant as x.is(T), and a variant that doesn't cast as x.is(var x1) respectively x.is(var) or x.is(final).

There may be an issue with readability: I don't think it is obvious that x.is(T x1) introduces a new variable named x1, and the use of is points in the direction of a type test, not just binding the value of a subexpression to a fresh variable (although x.is(var) allows for an oddly straightforward reading ;-). It's an interesting idea!

use the syntactic pattern of for loop

You might say that this is similar to the comma operator in C: Just include <localVariableDeclaration> ';' <expression> as an expression.

lrhn commented 3 years ago

For ; as something-expression separator, we could go for

<expression> ::= <statement> `;' <expression> | <singleExpression> ; 
<singleExpression> ::= ... current expression ... ;

(and then an expression-statement is <singleExpression>`;' to avoid ambiguity, ditto for for-loop expressions which are already ;-delimited).

It's definitely powerful. It might also be unreadable:

if (while (something) {
  doSomething;
}; test) {
 ...
}

We'll need new ways to think about layouting expressions to make that readable.

(Our previous looks into statement-expressions, expressions containing statements, have generally wrapped the statement in {/} braces to make it easily recognizable. That would block variable declarations, at least unless we say that it doesn't.)

jodinathan commented 3 years ago

I've added two comments regarding type promotion and null checking: https://github.com/dart-lang/language/issues/1201#issuecomment-698290117

I used to develop a lot in JS and PHP and I really want to avoid "magical stuff".
This and isomorphism is what made us choose Dart over TS, Node, C# etc.

So IMHO, we should add an easy to read with obvious motivation syntax for type promotion and null checking.

In my opinion, prefix stuff are not nice to read because it is misleading:

if (var prop: someObj.someProp != null) First thought: Does it set a boolean?

if (var someObj.someProp != null) First thought: Is someObj.someProp really not null now?

For the likes of class methods:

if (longExpression.is(T x1)) { Can I command+click to see the internals of this is method?
Can I overload it?

So this is why I like sufix syntax for this:

if (someObj.someProp != null use prop)

This how I read this from left to right like:

if the property someProp is not null, then use it as prop in this scope

jodinathan commented 3 years ago

reading my last phrase in the above comment I thought that we can have another keyword along with use to make things quicker:

if (someObj.someProp != null useit)

Lets read it:

if the property someProp is not null, then use it in this scope

so we would have someProp that is final and not null in the if block

eernstg commented 3 years ago

@tatumizer wrote:

The problem at hand is very specific: how to implement the conditional promotion

I did not define the goal in that manner, I wanted to go for a mechanism which is a lot more flexible than declaring a variable in the condition of an if. I do recognize that it may be easier to optimize in various ways with a mechanism that is only applicable to such a special case, but it is quite valuable to have mechanisms that aren't so highly specialized, simply because they can be used to solve more problems, and more diverse problems.

@jodinathan wrote:

if (var prop: someObj.someProp != null) First thought: Does it set a boolean?

Right, it is necessary to know the precedence of the mechanism: It applies to a chain of member accesses, e.g., foo.bar().baz[0]?.qux, just like null shorting (where ? in foo?.bar().baz[0]?.qux will short all the following member accesses, but it won't include operators and other constructs). I believe it will be reasonably easy to remember when such things as null shorting and expression bindings have the same "size".

So it doesn't include the operator != and hence it binds someObj.someProp.

if (var someObj.someProp != null) First thought: Is someObj.someProp really not null now?

That would have to be

if (var x: someObj.someProp != null) ...

and yes: x will have a type which is NonNull(T) where T is the static type of someObj.someProp. For instance, if T is int? then x will have type int.

If we use a default name then we have the same properties, someProp is a fresh variable with type NonNull(T):

if (someObj.var:someProp != null) ...

For the likes of class methods:

if (longExpression.is(T x1)) {

Can I command+click to see the internals of this is method? Can I overload it?

That was not my proposal, so I shouldn't start claiming that it does anything in particular. But if it's similar to if (var x1: longExpression is T) {...} then x1 has declared type T in the body of the if statement.

For the .is construct, it wouldn't denote a method (is is a reserved word, so you couldn't have written a method with that name), so there are no internals, and it cannot be overridden.

So this is why I like sufix syntax for this:

if (someObj.someProp != null use prop)

This how I read this from left to right like:

if the property someProp is not null, then use it as prop in this scope

That's a nice sanity test! Let's compare:

if (var prop: someObj.someProp != null) {/*1*/} else {/*2*/}

"If the fresh variable prop whose value is someObj.someProp is not null then do {/*1*/}, else do {/*2*/}."

prop is added to the current scope where the binding expression occurs, which means that it will be available with the type NonNull(T) where T is the type of someObj.someProp in the then-part, but it will also be available in the else-part, with the type T.

It's possible that your sentence is more straightforward than mine, but it is still possible to read it in both cases.

jodinathan commented 3 years ago
if (var prop: someObj.someProp != null) {/*1*/} else {/*2*/}

"If the fresh variable prop whose value is someObj.someProp is not null then do {/*1*/}, else do {/*2*/}."

I don't know others, but my developer brain reads var prop: someObj.someProp != null as a boolean assignment.

bool check;

if (check = someObj.someProp != null) { } // this is dart valid and sets a boolean
if (var prop: someObj.someProp != null) {} // this sets a property that is not null

// how semantic close are the statements above?

it is even harder to developers from other languages where you can:

if (var myBool = someObj.someProp != null) {
  // myBool lives here
}
eernstg commented 3 years ago

@jodinathan wrote:

my developer brain reads var prop: someObj.someProp != null as a boolean assignment

I understand that, it's basically a consequence of transferring knowledge about <localVariableDeclaration> to this new form using var and :.

It would be very easy to change the proposal in this issue to use 'var' <identifier>? ':' <expression>, which would make it work much more like <localVariableDeclaration> and in particular make prop a boolean. This would make a binding a "large" construct, in the sense that it will initialize the new variable with an arbitrary expression.

However, I think it is useful to give binding expressions a different precedence, because they are intended to be "small" constructs, occurring inside expressions.

This allows us to have if (var:x is T) where the "large" form would require if ((var:x) is T). I expect this to be a very common case.

I think the trade-off is (1) when binding expressions are "small", we need to learn that, but the result is typically more convenient; or (2) if binding expressions were "large", their precedence would be more familiar, but they would require parentheses much more often.

I think it's tempting to favor familiarity at first, but in the long run, when we're familiar with it, the slightly unfamiliar design may turn out to work better.

jodinathan commented 3 years ago

@jodinathan wrote:

my developer brain reads var prop: someObj.someProp != null as a boolean assignment

I understand that, it's basically a consequence of transferring knowledge about <localVariableDeclaration> to this new form using var and :.

It would be very easy to change the proposal in this issue to use 'var' <identifier>? ':' <expression>, which would make it work much more like <localVariableDeclaration> and in particular make prop a boolean. This would make a binding a "large" construct, in the sense that it will initialize the new variable with an arbitrary expression.

However, I think it is useful to give binding expressions a different precedence, because they are intended to be "small" constructs, occurring inside expressions.

This allows us to have if (var:x is T) where the "large" form would require if ((var:x) is T). I expect this to be a very common case.

I think the trade-off is (1) when binding expressions are "small", we need to learn that, but the result is typically more convenient; or (2) if binding expressions were "large", their precedence would be more familiar, but they would require parentheses much more often.

I think it's tempting to favor familiarity at first, but in the long run, when we're familiar with it, the slightly unfamiliar design may turn out to work better.

if you guys decide going with a prefix style instead of suffix, the keyword use, IMHO, fits better the purpose.

if (use: obj.someProp != null) {}
AKushWarrior commented 3 years ago
if (use: obj.someProp != null) {}

I talked with @eernstg above about adding a new keyword to represent binding assignments. There's a few that work (val, bind, use) and they are all semantically better than "var". The issue is that the cost of introducing a new keyword (breaking all code that uses that keyword as an identifier) is too high for this syntactic sugar type feature.

jodinathan commented 3 years ago
if (use: obj.someProp != null) {}

I talked with @eernstg above about adding a new keyword to represent binding assignments. There's a few that work (val, bind, use) and they are all semantically better than "var". The issue is that the cost of introducing a new keyword (breaking all code that uses that keyword as an identifier) is too high for this syntactic sugar type feature.

true. That makes me think that suffix style are easier to fit:

if (obj.someProp != null use prop) {
  // prop lives here and is not null
}
if (obj.someProp != null useit) {
  // someProp lives here and is not null
}
if (obj.someProp != null use prop && prop.someInt > 0) {
  // prop lives here and is not null
}
jodinathan commented 3 years ago

BTW, I still don't understand what is wrong with the straightforward

if ((final prop=obj.someProp) != null && prop.someInt > 0) {
  // prop lives here and is not null
}

No new keywords, very small changes to the syntax: allow declaration "var y =" or "final y = " where today we can only use previously declared variable in the assignment subexpression.

type promotion

if ((final prop = obj.someProp) is Foo && prop.someInt > 0) {
  // prop lives here and is Foo
}
jodinathan commented 3 years ago
if ((final prop = obj.someProp) is Foo) {
  // prop lives here and is Foo
} else if (prop is Bar) {
  // prop lives here and is Bar
} else {
  // prop lives here, has the static type of obj.someProp
}

I agree with you, I find old style assignment better than most proposals. I think maybe suffix style can be as good or better.

jodinathan commented 3 years ago

The feature has its own limitations though. E.g., this works:

if ((final x=expr1) !=null && (final y=expr2) !=null) {
  // x, y live here
} 

But this doesn't:

if ((final x=expr1) !=null && (final y=expr2) !=null) {
  // x, y live here
} else {
  print(y); // error: y is not defined
} 

But this works again:

if (((final x=expr1) !=null) & ((final y=expr2) !=null)) { // simgle ampersand
  // x, y live here
} else {
  // x, y live here
} 

The same problem occurs when we try to use inline declaration in the expressions like ?? and ?:.

That is why I think that the variable should be only accessible to the if block it is in. the case below:

if ((final prop = obj.someProp) is Foo) {
  // prop lives here and is Foo
} else if (prop is Bar) {
  // prop lives here and is Bar
} else {
  // prop lives here, has the static type of obj.someProp
}

should be

final prop = obj.someProp;

if (prop is Foo) {
  // prop lives here and is Foo
} else if (prop is Bar) {
  // prop lives here and is Bar
} else {
  // prop lives here, has the static type of obj.someProp
}

we don't need to fix everything, but quick type promotion and nullability while adding a new variable to a specific scope. so, IMO, we should go for either:

A)

if ((var x = someObj.someProp) is Foo) {
  // x is Foo only here
} else {
 // x do not exist here
}

B)

if (someObj.someProp is Foo useit) { // or use someProp
  // someProp is Foo here
} else {
 // someProp doesn't exist here
}

Either solve both type promotion and nullability.

Option A is easier for devs to catch because it exists in other languages (JS maybe, can't remember), but is more verbose and a bit ugly.

Option B is a little different but is less verbose, more elegant and can open to new stuff, ie:

if (someObj.someProp is Map useit.cast<String, dynamic>()) {}
jodinathan commented 3 years ago
if (var x=expr1, y=expr2; x !=null && y != null) {
  // x, y live here
} else {
  // x, y live here, too!
}

that is basically

var x=expr1, y=expr2;

if (x !=null && y != null) {
  // x, y live here
} else {
  // x, y live here, too!
}
jodinathan commented 3 years ago

No, these two programs are not equivalent.

if (var x=expr1, y=expr2; x !=null && y != null) {
  // x, y live here
} else {
  // x, y live here, too!
}
// x, y GO OUT OF SCOPE here

In your variant, they don't go out of scope. The entire thing has to be placed in {...} block for the similar effect:

{
  var x=expr1, y=expr2;

  if (x !=null && y != null) {
    // x, y live here
  } else {
    // x, y live here, too!
  }
}

Quite a difference! :-)

IMO, those kind of magical scopes harms readability a lot

jodinathan commented 3 years ago

I don't necessarily agree with that. These scopes are no more magical than those in the function declaration or "for" loop. In fact, you can find programs in dart core where blocks like the above are introduced in the middle of the code with the sole purpose of localizing some declarations there - which is considered good practice.

I meant the implicit variant:

if (var x=expr1, y=expr2; x !=null && y != null) {
  // x, y live here
} else {
  // x, y live here, too!
}
// x, y GO OUT OF SCOPE here

For me, there is a magical scope there.

jodinathan commented 3 years ago

Turns out, this is exactly what C++ is doing

For the details, refer to https://en.cppreference.com/w/cpp/language/if LOL!

C++ has preprocessors, namespaces and many other stuff we probably don't want in Dart. So, IMO, C++ having this kind of syntax shouldn't make it more viable.

However, even thought I really liked suffix syntax style and I prefer the variable only existing in the if-block, I would live with C++ syntax style. Really not a problem to me.

In my NNBD tests, a lot of times I was inclined to use ! to not have to declare another variable in the parent scope, so a solution to this problem is indubitably needed.

eernstg commented 3 years ago

@jodinathan wrote:

.. the variable should be only accessible to the if block it is in.

@tatumizer wrote:

the scope of the variable should be limited to "if" block

and later noted that C++ scopes variables declared in an if condition to be available in the else part as well.

It is certainly a source of a lot of complexity if we introduce a large number of scopes covering complex regions of source code. If we only consider declarations in the condition of an if statement it doesn't get too bad.

But if we consider E1 && E2 as part of a larger expression, and specify that variables in E2 are only in scope inside E2 (because that part may not be evaluated, so we can't trust the variable to exist elsewhere) then I'm afraid the mechanism won't be very useful: The variables introduced by binding expressions will be available in a too small amount of code locations. Also, if each binding expression introduces its own scope, there will be a large number of scopes, and they won't be associated with recognizable syntactic markers (like {....}).

That's the reason why I proposed adding the variables introduced by binding expressions to the current scope (in our examples that's typically the outermost scope of code that we can see). The binding expression itself doesn't introduce any scopes at all, and a category of confusing name clashes are avoided because that current scope can't use the same name for another purpose.

The other topic that came up several times was reachability and nullability. One possible approach (the one I'm using in this issue) is that a variable introduced by a binding expression bound to a value of type T gets type T? if it is possibly not reached at run time. This means that it can soundly get the value null in the case where the initializing expression isn't evaluated, and normal null checks (static and dynamic) will ensure that this situation is handled.