Skip to content

Introduce let construct for local destructuring #2197

New issue

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

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

Already on GitHub? Sign in to your account

Open
lrhn opened this issue Apr 11, 2022 · 18 comments
Open

Introduce let construct for local destructuring #2197

lrhn opened this issue Apr 11, 2022 · 18 comments
Labels
feature Proposed language feature that solves one or more problems patterns Issues related to pattern matching.

Comments

@lrhn
Copy link
Member

lrhn commented Apr 11, 2022

With pattern destructuring, we treat declarations specially.

You can write

var (x, y) = pair; // grammar: `var` bindingPattern `=` expression
print(x + y);

but that doesn't easily let you destructure inside an expression, because declarations are not inside expressions.

So, let's introduce:

`let' bindingPattern `=' expression `in' expression

(possibly allow a comma-separated list of "pattern = expression" instead of requiring you to nest lets).

The use cases would be => functions:

class C {
  (double, double) pair;
  int get first => let (first, _) = pair in first;
}

or, more importantly, inside collection literals:

 {for (var c in cs) let (first, second) = c.pair in first: second}

The grammar has no end-token, which means that it'll likely need to have a low precedence, and will need to be parenthesized if used inside more complex expressions, which is very likely what's best for readability too.

var x = let (first:, last:) = list in "$first, ..., $last";
// but
var z = x + y + (let w = calculate(x, y) in w * w) + q;

The scope of the variable will only be the in-expression (or following bindings if we allow more than one:

let x = 1, y = x + 1 in y + 2

Should the variables be final? Or do we want a mutable variable. The latter seems occasionally useful, like:

Map<int, X> toMap<X>(Iterable<X> iterable) => {let x = 0 in for (var y in iterable) x++: y};

You can always not assign to a variable, so being mutable seems like the best default.
People wanting extra safety against accidental mutation can write let final x = ..., in ....

Probably means let needs to be at least a built-in identifier, and probably a reserved word.

@lrhn lrhn added feature Proposed language feature that solves one or more problems patterns Issues related to pattern matching. labels Apr 11, 2022
@lrhn lrhn changed the title Introduce let constructor for local destructuring Introduce let construct for local destructuring Apr 11, 2022
@Levi-Lesches
Copy link

So long as you're already choosing a variable name, why not separate the declaration into its own line (which would also encourage more meaningful variable names)? IMO it would help readers be able to quickly scan the scope and usage of the variable.

var z = x + y + (let w = calculate(x, y) in w * w) + q;
// vs
final magnitude = calculate(x, y);
var z = x + y + (magnitude*magnitude) + q;

Map<int, X> toMap<X>(Iterable<X> iterable) => {let x = 0 in for (var y in iterable) x++: y};
// vs
Map<int, X> toMap<X>(Iterable<X> iterable) {
  var index = 0;
  return {
    for (var y in iterable) index++: y
  };
}

@lrhn
Copy link
Member Author

lrhn commented Apr 12, 2022

People, for better or worse, like their one-line => function bodies.
Writing such a function, with pattern matching, was what triggered this request.

The let can be used in other places where an expression is required (initializer list entry or super call, redirecting generative constructor, inside an assert).

Also, not all local variables in a collection literal can necessarily be hoisted:

 { for (var i in something) let x = expensiveComputation(i) in x.name: x }

@munificent
Copy link
Member

munificent commented Apr 22, 2022

I can definitely see the appeal, and if Dart were an expression-based language, this would be a no-brainer. But in a fairly statement-oriented language that derives heavily from the C/Java/etc. syntactic tradition, I gotta say it looks and feels pretty weird to me.

A big part of the problem for me is that it only has a single expression for the body and that doesn't really give you a lot of mileage for the variable you just bound. I have to assume that if we give people let x in expr, then the very next day, they'll ask for let x in expr1, expr2, expr3 because they actually want to evaluate a couple of expressions using that variable. And then they'll realize that they only want to evaluate expr2 some of the time, so they use c ? expr2 : expr2else. But then they realize that actually they have two expressions to evaluate when c is true so they do let x in expr1, c ? (let _ in expr2a, expr2b), expr3, and so on.

I strongly suspect that if we provide expression-level variables, they'll (reasonably!) ask for expression-level rest-of-the-whole-language. I don't want to be in the position that we're in with const expressions where each tiny piece we add just increases the desire to add the rest. I think we should either not do expression-level variables, or we should anticipate making most statements usable inside expressions.

If it's the latter, how about instead of a special expression form for variable declarations, we consider the ECMAScript do expression proposal? That provides a nicely delineated way to embed an entire series of arbitrary statements in an expression context, with a single trailing expression to return the result.

Your example becomes:

class C {
  (double, double) pair;
  int get first => do { var (first, _) = pair; first };
}

It's a little more verbose, but it extends to multiple variables and any other statement you could want. Also, it answers all of the questions around whether the variable(s) are final, typed, late, etc. You get all the existing local variable statement declaration semantics. Done.

Personally, I'm not super enthused about this. I think code is easier to read if you don't have huge nested expressions containing imperative code in them. But if expression-local variables is important to many users, I think something like do { ... } expressions is a more extensible approach than a one-off variable-declaration syntax.

@mateusfccp
Copy link
Contributor

mateusfccp commented Apr 22, 2022

Your example becomes:

class C {
  (double, double) pair;
  int get first => do { var (first, _) = pair; first };
}

I don't think this is any better than:

class C {
  (double, double) pair;
  int get first {
    var (first, _) = pair;
    return first;
  }
}

In a code review I would suggest replacing the former with the later.

The only case that I think may benefit from this is when using if inside collections, which is quite common in Flutter build method:

Widget build(BuildContext context) {
  return Column(
    children: [
      a, 
      b,
      if (c)
       do {
         // some more complex logic to get a widget d
         d;
       },
    ],
  );
}

While today we have to either do the complex logic before the list or use a auto-calling function:

Widget build(BuildContext context) {
  // some more complex logic to get a widget d
  return Column(
    children: [
      a, 
      b,
      if (c)
       d,
    ],
  );
}
Widget build(BuildContext context) {
  return Column(
    children: [
      a, 
      b,
      if (c)
       () {
         // some more complex logic to get a widget d
         return d;
       }(),
    ],
  );
}

@munificent
Copy link
Member

Your example becomes:

class C {
  (double, double) pair;
  int get first => do { var (first, _) = pair; first };
}

I don't think this is any better than:

class C {
  (double, double) pair;
  int get first {
    var (first, _) = pair;
    return first;
  }
}

In a code review I would suggest replacing the former with the later.

Indeed, as would I. Part of the reason I'm not super enthused about expression-level variable declarations is that in many cases, you really are better off just hoisting things out and writing some statements. We're an imperative language. Statements are fine.

The only case that I think may benefit from this is when using if inside collections, which is quite common in Flutter build method:

Widget build(BuildContext context) {
  return Column(
    children: [
      a, 
      b,
      if (c)
       do {
         // some more complex logic to get a widget d
         d;
       },
    ],
  );
}

+1.

When I was working on UI-as-code and trying to make these build expressions more, uh, expressive, I tried a number of different approaches to allow arbitrary statements and control flow in there and never got to anything I liked. The nice thing about collection-if and for is that the body is a single element which makes the common case where you just have a single value to emit a lot more terse.

Extending that with do expressions might be a nice way to opt in to statements when you need something more powerful while still allowing the common case to remain terse as it is now.

@Wdestroier
Copy link

Could another operator be defined instead?
Basically an attempt to simplify expression.map((value) => ...) and ['hello world'].map((value) => value.replaceFirst(' ', '_')).map(print)

Example syntax: methodName() => expr1 # expr2 # expr3;

  • The next expression has access to the return value of the previous expression by the new it keyword, if a previous expression is present
  • The returned expression is always the last one
var x = let (first:, last:) = list in "$first, ..., $last";
var z = x + y + (let w = calculate(x, y) in w * w) + q;

Would become:

var x = (first:, last:) = list # "$first, ..., $last"; // The last expression is always returned
var z = x + y + (calculate(x, y) # it * it) + q; // `it` is the return value of calculate(...)

And

Map<int, X> toMap<X>(Iterable<X> iterable) => {let x = 0 in for (var y in iterable) x++: y};

Would become:

Map<int, X> toMap<X>(Iterable<X> iterable) => {var x = 0 # for (var y in iterable) x++: y};

And

var message = 'hello world';
[message].map((value) => value.replaceFirst('', '_')).map(print);

Would become:

var message = 'hello world';
message # it.replaceFirst('', '_') # print(it);

If the it keyword is introduced for single parameter lambda expressions too, then it would be necessary to define "what is really it", but that's a little too much to think about rn 😂

@leafpetersen
Copy link
Member

A big part of the problem for me is that it only has a single expression for the body and that doesn't really give you a lot of mileage for the variable you just bound. I have to assume that if we give people let x in expr, then the very next day, they'll ask for let x in expr1, expr2, expr3 because they actually want to evaluate a couple of expressions using that variable. And then they'll realize that they only want to evaluate expr2 some of the time, so they use c ? expr2 : expr2else. But then they realize that actually they have two expressions to evaluate when c is true so they do let x in expr1, c ? (let _ in expr2a, expr2b), expr3, and so on.

I don't understand where you're going here. You feel that adding let is going to drive people to ask for a sequence operator that they would otherwise not ask for? I am... highly, highly skeptical. Moreover, let gives you a sequence operator: let _ = expr1, _ = expr2 in expr3. So... where is this coming from? And if there is actually demand for a sequence operator... why would we not want to support that?

I can definitely see the appeal, and if Dart were an expression-based language, this would be a no-brainer. But in a fairly statement-oriented language that derives heavily from the C/Java/etc. syntactic tradition, I gotta say it looks and feels pretty weird to me.

This is not a very convincing argument to me. The overwhelming majority of Dart code is Flutter code, and the Flutter code is primarily based around... expressions. I'm not sure that Dart is so heavily statement based as all of that. Many of the language changes we have made over the past N years have been explicitly around enriching the expression language.

@leafpetersen
Copy link
Member

Your example becomes:

class C {
  (double, double) pair;
  int get first => do { var (first, _) = pair; first };
}

I don't think this is any better than:

class C {
  (double, double) pair;
  int get first {
    var (first, _) = pair;
    return first;
  }
}

In a code review I would suggest replacing the former with the later.

Indeed, as would I. Part of the reason I'm not super enthused about expression-level variable declarations is that in many cases, you really are better off just hoisting things out and writing some statements. We're an imperative language. Statements are fine.

The more I think about this exchange, the more puzzled I get. I also don't think the first suggestion is better than the second... but that's a critique of the counter-proposal from @munificent , not this issue. The original example was:

class C {
  (double, double) pair;
  int get first => let (first, _) = pair in first;
}

which is, in my opinion, better than either of the above. Do you disagree? Do you think the statement form is actively superior to the expression form? Would you suggest replacing the let code with either of the other alternatives presented above? If so, what rationale or justification would you give? Would you apply the same justification ("We're an imperative language. Statements are fine.") towards replacing int get x => 3 with int get x { return 3; }? Perhaps you would, but... I don't think I would accept that suggestion in a code review. The former is, IMO, much more readable.

@munificent
Copy link
Member

You feel that adding let is going to drive people to ask for a sequence operator that they would otherwise not ask for? I am... highly, highly skeptical.

I do. With Flutter UI code, I fairly see users run into code like:

return Column(
  children: [
    someExpensiveComputation().someProperty,
    someExpensiveComputation().anotherThing
  ],
);

And they don't want to perform that computation twice, but UI-as-code doesn't help. Though I suppose that you could reorganize code like this to not need an actual sequence operator, like:

return Column(
  children: [
    ...let c = someExpensiveComputation() in [c.someProperty, c.anotherThing]
  ],
);

So maybe I'm wrong an a sequence operator wouldn't come up that often.

Moreover, let gives you a sequence operator: let _ = expr1, _ = expr2 in expr3.

Is that... something we'd like users to write? Coming from an object-oriented C-ish background, that's pretty alien looking.

And if there is actually demand for a sequence operator... why would we not want to support that?

My point was just that if we're going to go there, let's pick an expression syntax that expands gracefully to handle it, which I think do { ... } expressions do better than an ML-like let ... in ... expression does.

This is not a very convincing argument to me. The overwhelming majority of Dart code is Flutter code, and the Flutter code is primarily based around... expressions.

Yes, but I think users still associate certain kinds of behavior with statements and don't like seeing it embedded inside expressions. I get pushback on the fact that the formatter allows single-line ifs because users don't like seeing return not appear literally textually at the beginning of a line. I think there's a certain discomfort with things like control flow happening inside expressions. Obviously, control flow elements go directly against that, but we were pretty careful to do UX testing and make sure users had a strong intuition of how it behaved before we added that.

With variable declarations, users have to infer not just the execution behavior but the scope of the variables, and with something like let ... in ..., I'm not confident they will find it intuitive (especially because it has no closing delimiter).

I'm not sure that Dart is so heavily statement based as all of that. Many of the language changes we have made over the past N years have been explicitly around enriching the expression language.

Well, yeah. We probably should have made it completely expression-oriented from the get-go and we're trying to slowly deal with that limitation without making the language too weird in the process. It's a hard balancing act. I don't want to end up in the uncanny valley where the language looks half expression-based and half statement-based in a way that alienates both sets of user preferences, like we did with the old type system.

Maybe this issue thread just needs better motivating examples, but I don't see any code in here where I think, "Yes, this code using let looks better than what I'd write today."

@leafpetersen
Copy link
Member

You feel that adding let is going to drive people to ask for a sequence operator that they would otherwise not ask for? I am... highly, highly skeptical.

I do. With Flutter UI code, I fairly see users run into code like:

return Column(
  children: [
    someExpensiveComputation().someProperty,
    someExpensiveComputation().anotherThing
  ],
);

And they don't want to perform that computation twice, but UI-as-code doesn't help. Though I suppose that you could reorganize code like this to not need an actual sequence operator, like:

return Column(
  children: [
    ...let c = someExpensiveComputation() in [c.someProperty, c.anotherThing]
  ],
);

So maybe I'm wrong an a sequence operator wouldn't come up that often.

Yeah, you're kind of making my point here. Though the better way to write that code is:

 return Column(
   children: let c = someExpensiveComputation() in [c.someProperty, c.anotherThing],
 );

which sure LGTM.

Moreover, let gives you a sequence operator: let _ = expr1, _ = expr2 in expr3.

Is that... something we'd like users to write? Coming from an object-oriented C-ish background, that's pretty alien looking.

It looks ok to me, coming from an ML background. :)

But really, for Dart, I would suggest allowing most statements as elements in a let body. Then you get:

let 
  expr1;
  expr2;
in expr3

which is entirely reasonable.

Yes, but I think users still associate certain kinds of behavior with statements and don't like seeing it embedded inside expressions. I get pushback on the fact that the formatter allows single-line ifs because users don't like seeing return not appear literally textually at the beginning of a line. I think there's a certain discomfort with things like control flow happening inside expressions. Obviously, control flow elements go directly against that, but we were pretty careful to do UX testing and make sure users had a strong intuition of how it behaved before we added that.

I don't understand why you bring up control flow here. Are you anticipating my suggestion (above) to add statements? Otherwise this adds absolutely no control flow whatsoever not already present in Dart.

With variable declarations, users have to infer not just the execution behavior but the scope of the variables, and with something like let ... in ..., I'm not confident they will find it intuitive (especially because it has no closing delimiter).

I highly question that. I think people are pretty familiar with let expressions by now. And the lack of closing delimiter is a syntactic choice that we can vary as desired (e.g. let ... in ... end is the ML approach).

Maybe this issue thread just needs better motivating examples, but I don't see any code in here where I think, "Yes, this code using let looks better than what I'd write today."

I mean, you started your comment with an example of exactly that, no?

@lrhn
Copy link
Member Author

lrhn commented May 12, 2022

There is actually no new functionality here. You can get the same effect, with worse scoping, using existing language functionality and a pre-declared uninitialized variable

Then ... (let int x = expr1 in expr2) ... would be

T seq<T>(void _, T value) => value;
int x;  // Uninitialized
...seq(x = expr1, expr2)...

and expr2 can refer to x.

Doesn't work with control flow elements, you need for (var x in [expr1]) expr2 there, which has better scoping but more verbosity.

In fact, you can do [for (var x in [expr1]) expr2].first to get a let expression today. Yey!
That's why I'd make let a collection element too, so you could write:

return Column(
  children: [
    let c = someExpensiveComputation() in ...[c.someProperty, c.anotherThing]
  ],
);

(notice the ... moved inside, which reads slightly better, because ...[elements] is the canonical way to conditionally introduce multiple elements.)


About allowing arbitrary statements in the let, the let stmts in expr format is effectively a do block. The special case of stmts containing only declarations (or only one declaration) is almost the let expression, except that you need to write let var x = expr1 in expr2 instead of being able to omit the var.

I like it, but might as well just do do { stmts } in expr then, and not introduce a new keyword.

The big difference from existing control flow blocks is that the declarations inside {stmts} would be visible in expr too, even though it's outside the {...} block, but I really really want to do that for do { stmts } while (expr} already, at least when possible (absent continues).

So, yes, I'd be fine with do stmt in expr or even requiring the braces and do do { stmts } in expr, if the declarations inside stmt/stmts reach expr.
We can disallow local control flow inside a do expression's statement block (no return, break or continue, and maybe not even rethrow).

@munificent
Copy link
Member

which is entirely reasonable.

Yes, but I think users still associate certain kinds of behavior with statements and don't like seeing it embedded inside expressions. I get pushback on the fact that the formatter allows single-line ifs because users don't like seeing return not appear literally textually at the beginning of a line. I think there's a certain discomfort with things like control flow happening inside expressions. Obviously, control flow elements go directly against that, but we were pretty careful to do UX testing and make sure users had a strong intuition of how it behaved before we added that.

I don't understand why you bring up control flow here. Are you anticipating my suggestion (above) to add statements? Otherwise this adds absolutely no control flow whatsoever not already present in Dart.

My point is that in a C-tradition language, there are certain things users generally associate more with statements than with expressions. I think that's mostly control flow (though ?:, &&, and || are exceptions) and very much so with variable bindings.

With variables in particular, I think there is a fairly strong association with statements because blocks (which are a statement-only concept) are the delimiters for local scopes.

(I understand that for elements go against all of this, but, then, we were pretty careful to ensure that users did find them fairly intuitive before we shipped them.)

Again, I'm not saying this means we can't do let, just that it feels against the grain of the language to me.

I highly question that. I think people are pretty familiar with let expressions by now.

Really? Are there other languages in Dart's general area that have something like let ... in? I don't think Kotlin, Scala, Swift, JS, Java, or C# do. As far as I know, local variable declarations in those are always scoped to the end of the surrounding block. The closest you have is lambdas, but maybe I just missed them?

And the lack of closing delimiter is a syntactic choice that we can vary as desired (e.g. let ... in ... end is the ML approach).

Maybe this issue thread just needs better motivating examples, but I don't see any code in here where I think, "Yes, this code using let looks better than what I'd write today."

I mean, you started your comment with an example of exactly that, no?

I don't actually particularly like the example I wrote. I would rather have users write:

var c = someExpensiveComputation();
return Column(
  children: [
    c.someProperty,
    c.anotherThing
  ],
);

I could maybe be on board with a do expression:

return Column(
  children: do {
    var c = someExpensiveComputation();
    [
      c.someProperty,
      c.anotherThing
    ]
  },
);

That keeps the familiar syntax for declaring a local variable and the intuition that its scope is to the end of the surrounding braces.

@leafpetersen
Copy link
Member

There is actually no new functionality here. You can get the same effect, with worse scoping, using existing language functionality and a pre-declared uninitialized variable

Ignoring async, let var x = e1 in e2 can be just written as ((x) => e2)(e1)

@lrhn
Copy link
Member Author

lrhn commented May 12, 2022

True. Ignoring async is the hard part. That, and worse promotion across function boundaries.
And trusting that compilers will be efficient, and not allocate a new function object and then call it.
(Not that I believe [for (var x in [expr1]) expr2][0] is going to be efficient.)

@leafpetersen
Copy link
Member

Really? Are there other languages in Dart's general area that have something like let ... in? I don't think Kotlin, Scala, Swift, JS, Java, or C# do. As far as I know, local variable declarations in those are always scoped to the end of the surrounding block. The closest you have is lambdas, but maybe I just missed them?

Kotlin uses an extension for let: https://kotlinlang.org/docs/scope-functions.html . Technically a lambda, but giving a let like syntax. But otherwise yes, those other languages don't as far as I know, support this, so fair enough. On the other hand, I don't think most of them have collection elements either? In general, this argument feels to me exactly like the arguments I was having with the OO folks 10-15 years ago about function expressions - that they were unfamiliar, weird and un-OO (just use an Object!). And now it's completely uncontroversial to have closures. Progress marches on, slowly. :).

I do wonder whether patterns and tuples are going to put more pressure on this though. Tuples give you multiple returns, and without this, the only way to handle the elements of a multiple return function other than as an aggregate will be to bind them at the top level, which is a bit annoying, or use a single element switch expression, which is a bit annoying.

Speaking of which, we will actually have let with the patterns proposal, just with an annoying syntax (and I guess worse inference):

let var x = e1 in e2 is exactly switch (e1) {case var x => e2}

@munificent
Copy link
Member

I do wonder whether patterns and tuples are going to put more pressure on this though. Tuples give you multiple returns, and without this, the only way to handle the elements of a multiple return function other than as an aggregate will be to bind them at the top level, which is a bit annoying, or use a single element switch expression, which is a bit annoying.

Speaking of which, we will actually have let with the patterns proposal, just with an annoying syntax (and I guess worse inference):

let var x = e1 in e2 is exactly switch (e1) {case var x => e2}

Well, as @lrhn noted, we already have ((x) => e2)(e1) and [for (var x in [e1]) e2][0]. :)

To be clear, much of my concern about let is that the syntax looks weird crammed into a language whose syntax (and variable scoping) is based on C. I'm still not convinced that a general-purpose expression-level variable binding is particularly valuable when it comes to writing maintainable code. But if it was, I would be more in favor of a syntax like do { var x = e1; e2 } which I think makes the scoping much clearer (and allows arbitrary statements in there).

@lukehutch
Copy link

I'm still not convinced that a general-purpose expression-level variable binding is particularly valuable when it comes to writing maintainable code.

It would be valuable in Flutter for sure:

dart-lang/sdk#52603

@munificent munificent added patterns Issues related to pattern matching. and removed patterns-later labels Aug 28, 2023
@nex3
Copy link
Member

nex3 commented Sep 8, 2023

Today I seriously considered writing if (expression case var variable) as a workaround for not having this in a map literal 😅. I decided it was better to just bite the bullet and construct the map procedurally, but it would have been a lot clearer if I'd had access to let.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems patterns Issues related to pattern matching.
Projects
None yet
Development

No branches or pull requests

8 participants