Skip to content
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

Switch expressions with some cases that need a body #3065

Open
TimWhiting opened this issue May 11, 2023 · 55 comments
Open

Switch expressions with some cases that need a body #3065

TimWhiting opened this issue May 11, 2023 · 55 comments
Labels
request Requests to resolve a particular developer problem

Comments

@TimWhiting
Copy link

Switch expressions are really useful, however, In the past few days of working with them frequently I've run into the problem where most of my bodies of the cases are expressions, but I need a proper body for the minority of them. In such cases I have to revert everything to a switch statement (it's good that there is an assist for it), or I have to create an immediately invoked function. This is poor user experience.

return switch (this) {
      Ref() => k(this),
      // ... etc
      Lambda(:final formals, :final body) => (){
         //... Some code
         return x;
      }(),
    };

My proposal would be to allow a body instead of an expression in switch expressions. The body has the caveat that it cannot use a break / continue, and has to have a return statement. So the previous example would change to this: The semicolon of the return statement then separates the cases.

return switch (this) {
      Ref() => k(this),
      // ... etc
      Lambda(:final formals, :final body):
         //... Some code
         return x;
    };
@TimWhiting TimWhiting added the request Requests to resolve a particular developer problem label May 11, 2023
@TimWhiting TimWhiting changed the title Switch expressions with a single case that needs a body Switch expressions with some cases that need a body May 11, 2023
@rrousselGit
Copy link

The fact that were using => makes me really wish that we could write:

final a = switch (obj) {
  pattern {
    return 42;
  } 
}

(with or without a ":" after the pattern)

I often switch back and forth between => and {return} depending on what's most readable for the given situation.

@TimWhiting
Copy link
Author

That would be most consistent with function body / function expression bodies, which is actually probably more consistent for the feature than using switch statement body syntax.

I don't know which would fit into the grammar better.

@eernstg
Copy link
Member

eernstg commented May 11, 2023

We have had some proposals about a construct which is often called 'block expressions'. Cf. #2848 (comment).

They could do the job, and they amount to the same syntax except for the parentheses:

  return switch (this) {
    Ref() => k(this),
    // ... etc
    Lambda(:final formals, :final body) => {
        //... Some code
        return x;
      },
  };

The block expression is quite similar to the function literal application () { /*my code here*/ } (), but there is no function object at run time, which means that there is no run-time cost as there would be with the function literal.

@TimWhiting
Copy link
Author

Yes, I do think that this featured could be more general as in your linked issue.

However, since that feature will not be available for awhile most likely, I think it would be good to support for a refactoring assist to convert an expression to an immediately invoked function expression.

@munificent
Copy link
Member

I agree this is an annoying wart of the syntax.

If I had a time machine, Dart would have always been an expression-based language. But I don't, and it isn't, so I tried to fit patterns and switch expressions in as gracefully as I could given that Dart already does make a distinction between expressions and statements.

Something like block-expressions could help, though it makes me wonder if we should consider just trying to actually do the whole thing and make the language expression-oriented.

@TimWhiting
Copy link
Author

TimWhiting commented May 12, 2023

If you look at the changes recently, they are heavily expression oriented:

Collection elements
Records
Patterns

The first two work in a statement oriented language, because collection elements are only useful in expression position, records are part of patterns to a large degree, but as just a datastructure they are mostly a anonymous constructor expression. Patterns as you mentioned is really hard to put into a statement oriented language. I think it is great that it is finally here. I wouldn't go back to pre dart 3.0, but the experience has yet to feel polished in my opinion. This can and I believe will come over time, however I wonder if there will need to be some larger breaking changes as far as syntax goes to make it actually feel polished.

Something like block-expressions could help, though it makes me wonder if we should consider just trying to actually do the whole thing and make the language expression-oriented.

I'm curious what you envision for this? Is a larger breaking change with an automated syntax migration needed in your opinion to make dart an expression first language?

@rrousselGit
Copy link

They could do the job, and they amount to the same syntax except for the parentheses:

  return switch (this) {
    Ref() => k(this),
    // ... etc
    Lambda(:final formals, :final body) => {
        //... Some code
        return x;
      },
  };

My two cents on this is that it looks a bit weird.

=> {} is reminiscent of JS. It's not something we see in Dart.

It'd be quite confusing imo

@braj065
Copy link

braj065 commented May 14, 2023

Like any other Dart function syntax is expressed this can folllow the same. the user should be able to write either of below
Lambda(:final formals, :final body) {
//... Some code
return x;
},
OR
Lambda(:final formals, :final body) => x,

@munificent
Copy link
Member

Lambda(:final formals, :final body) {
//... Some code
return x;
},
OR
Lambda(:final formals, :final body) => x,

Yes, I think that would work, though we have to be careful that it doesn't run into an ambiguity around map patterns or get in the way of other pattern syntax we might want to add.

The harder question is what the semantics of that block body are. I don't think return in the middle of a block should mean "yield this value from the block". Users expect return to return from functions and I think that's a good property to maintain.

I would be tempted to do something Rust-like and say that a block yields the value of the last statement in it (which would be void if the last statement wasn't an expression statement or some other statement that yields a value like a nested block).

But making the language expression oriented like this would be a big change, and I have no idea if there is a coherent design that would hold together and, if so, if we could actually ship it and migrate the world onto it without too much chaos.

@dgreensp
Copy link

dgreensp commented Mar 5, 2024

I humbly propose the do-give expression:

var x = do {
  // ...
  give y;
};

The give keyword would immediately break out of the innermost do expression. give would only be considered a keyword when syntactically within a do-expression (for backwards compatibility).

In the switch example:

  return switch (this) {
    Ref() => k(this),
    // ... etc
    Lambda(:final formals, :final body) => do {
      //... Some code
      give x;
    },
  };

@mateusfccp
Copy link
Contributor

I humbly propose the do-give expression:

var x = do {
  // ...
  give y;
};

The give keyword would immediately break out of the innermost do expression. give would only be considered a keyword when syntactically within a do-expression (for backwards compatibility).

In the switch example:

  return switch (this) {
    Ref() => k(this),
    // ... etc
    Lambda(:final formals, :final body) => do {
      //... Some code
      give x;
    },
  };

To reuse syntax, we could use yield instead.

@dgreensp
Copy link

dgreensp commented Mar 6, 2024

To reuse syntax, we could use yield instead.

I guess the first question is, can you return or yield from a do-expression? It certainly could be allowed; the semantics are easy to imagine. Some languages (like Kotlin) even make return itself an expression, making it normal to return from the middle of an expression.

If return and yield are not allowed (with their usual semantics) inside a do-expression, the question becomes whether it is confusing to see one of those keywords used for exiting a do-expression. It was already commented that using return is not desirable, and the same objection could apply to yield.

@mateusfccp
Copy link
Contributor

mateusfccp commented Mar 6, 2024

I would say we shouldn't allow yield inside of a do block. IMO do blocks should be only to "build" a complex expression, and shouldn't have side-effects. Although we can't control, for instance, if a function called inside a do block causes side-effects (I'm actually fond of languages that are explicit about side-effects, but this would be a major change in Dart's paradigm), we could have some controls regarding it, for instance:

  • disallow yield and return (disregarding the question about whether it
    would be confusing to repurpose the keywords)
  • disallow calling void functions
  • disallow discarding values (i.e. if one calls a function, the returned value must be used)

I can see how these restrictions may cause other problems. For instance, if one want to build a list inside the do block, they are forced to use a collection-for instead of a for+list.add.

Between return and yield, I think the later is semantically more appropriate, and less confusing, but one can, indeed, argue that this may be confusing inside sync* functions.

@rrousselGit
Copy link

pattern => do { ... } feels horrifying to me.

@lrhn
Copy link
Member

lrhn commented Mar 7, 2024

If we add a way to have statements inside expressions, we can indeed choose to either allow or disallow local control flow statements likereturn or break.

I'd personally allow them. It can make some things harder to reason about, but writing opaque code isn't hard today, you're just expected not to do it.
But sometimes a control flow in the middle of an expression is precisely what you need to express the logic concisely. I can definitely see something like .. foo (expression ?? return null) ... being useful, with the alternative being

var value = expression;
if (value == null) return null;
... foo(value) ...

If we disallow control flow operators in statement expressions, we can consider also disallowing them in finally blocks. That's another place where doing explicit control flow can get hard to reason about.

We should not allow statements only in switch expressions. Then people will write switch (0) {_ => stmts} to get statements inside any expression. Might as well make it general (maybe with slightly shorter syntax for switch expression branches).

If we can agree on behavior, I think it will be possible to find a syntax.

We can easily allow return/break/continue/rethrow as expressions, even if we don't allow more statements.

More expressions could be allowed using a block, do {…} being the most common proposal, probably because do is short (and other languages using it).
Then it can either required you to exit at the end, with a trailing expression (do {stmt* expr}, or have an explicit exit-operator.
I'm partial to => expr;, because then a switch expression states looking like a statement with exit operators as case bodies.

But I also want to be able to nest such statement expressions, and not necessarily exit the innermost one.
We already have a syntax for naming a piece of code and exiting it early: labels and break.
I've suggested break with values, to exit an expression, or labeled expression.

… (label: foo(do{stmt;if (b) break label: 42;…})

Again, of we can agree to have the functionality, we can start to look for a good syntax.

@tatumizer
Copy link

tatumizer commented Mar 7, 2024

… (label: foo(do{stmt;if (b) break label: 42;…})

This syntax is so verbose it defeats the very purpose of the exercise IMO, which is: to allow writing short fragments of code that otherwise don't fit into a single expression (under current definition of expression). "Short" is a key here: for longer programs, the current syntax with function literals (){...}() is OK.
Here's one of the ideas: support composite expressions of the form
(statement; statement; ... statement) (note the parentheses, as opposed to braces)`.
The expression returns the last computed value. The syntax can be very restrictive: only declarations (var, final), ifs and expressions. Fot "if", allow only the expressions in the body (no blocks). No loops, breaks, returns or anything except (composite) expressions.

The above example … (label: foo(do{stmt;if (b) break label: 42;…}) can now be written as
… (stmt;if (b) 42 else 43)

@lrhn
Copy link
Member

lrhn commented Mar 8, 2024

I think that allowing small and restricted statements, but not the full power of statements, is always going to be just short of powerful enough for what people want. Might as well make it full power.

If you just need to do one computation before another, you can use;

T seq<T>(void _, T value) => value;

and write seq(updateBananas(), banans.average). No new syntax needed for that.
Allowing if "statements" that can only have a single expression in each branch, that's what the ?/: conditional expression already does. (Not that I don't want to use if instead of that, #3374).

If it's to introduce a local variable, like var firstLast = (var x = computeList(); (x.first, x.last));, then I'd prefer a proper let (#1052) or even better, a declaration as an expression (#1420).

If it's to allow local control flow using break or return, we can allow those as expressions independently of this feature. (I think we should.)

So, if we want to allow statements inside expressions, we should allow all statements.

All we need to do is agree on semantics, and then syntax. :)
For semantics, any construction that allows general statements can allow return or break. We can disallow those specifically, or we can accept that an expression can now complete with a value, by throwing, or by breaking, continuing or returning.
And an expression should always either evaluate to a value, or complete in one of the another ways. The one thing it cannot do, which a statement can, is to "complete normally" with no value.

For syntax, the simplest is the do { statements; expression } approach. Allowing an early escape can be nice, so do {statements;} where <emit> expression; is now a statement (or expression!) which exits the nearest enclosing do-expression with that value, can work. It introduces a new way to complete a statement: "complete with value".
It's then a compile-time error if statements; can complete normally, without a value, according to flow analysis.
The <emit> syntax can be any of ^ expression (Smalltalk style "return"), => expression (Dart style?), do = expression (Pascal-style return-value setting, but doesn't break?), break = expression or break: expression, or whatever we can come up with.

Being short is nice.
Being able to refer to a further-out labeled do-expression is probably needed.
Maybe ^label: expression, ,break label: expression, break label = expression.
Which means being able to label the blocks. label: do { ... } is tricky in element context, it might have to be do label:{ .... }. Labels will be rare, so not a big issue, as long as they are there when you actually need them.
(My suggestion of (label: expression) for labeled expressions also doesn't work any more, records used that syntax.)

We could allow (statement ... statement expression) as an expression. It would effectively be a block statement. It saves the do, and allows a final expression to be emitted directly. But how do we nest composite statements.
Will it look odd to do: var norm = (var r = 0; for (var i in xs) { r += i * i; } sqrt(r));, probably formatted like:

 var norm = (
   var r = 0; 
   for (var i in xs) { 
     r += i * i; 
   } 
   sqrt(r));`

Using ( on the outside, but { inside is a little weird. The prefix do works by allowing a {-} body after it.

Lots of options. If we can agree on the semantics, we should be able to find a useful syntax to match it.
If we don't agree on semantics, discussing syntax is moot.
(And I don't agree that something like this should be limited to switches, so I'll defer to a more general "statements in expressions" feature.)

@mateusfccp
Copy link
Contributor

mateusfccp commented Mar 8, 2024

Lots of options. If we can agree on the semantics, we should be able to find a useful syntax to match it.
If we don't agree on semantics, discussing syntax is moot.

In this case, I think I disagree on the semantics proposed in this issue in favor of something like #1052.

If I understood correctly, with this we could do (adapting from original example):

return switch (this) {
  Ref() => k(this),
  // ... etc
  Lambda(:final formals, :final body) =>
    let a = _buildAFromFormals(formals) in
    let b = _doSomethingWithAToGetB(a) in
    b.processBody(body);
};

@tatumizer
Copy link

tatumizer commented Mar 8, 2024

A radical solution would be: just saying that the "block statement" (as defined in 18.1 of the spec) implicitly returns a value (thus effectively becoming a "block expression") and this "returned value" is determined exactly as in other languages supporting block expressions (there's a lot of prior art). (Essentially, it always returns the "last computed value").

var five= {
    fn_call();
    5;
}

What is the downside? I can't think of any. Right now, the construct

{
    fn_call();
    5;
}

is already legal in dart anyway, it's just very rarely used by anyone except a couple of people from dart team.
What harm will be caused by saying that this construct implicitly returns 5?

If this is OK, then the example from the original post can be written as

return switch (this) {
      Ref() => k(this),
      // ... etc
      Lambda(:final formals, :final body) => {
         //... Some code
         x; // assuming x is defined by "some code"
      },
    };

Similarly, there's no harm in saying that "if-else if ... else" implicitly returns the last computed value (some restrictions apply - e.g. the case with no "else" won't implicitly return anything):

var x= if (a<b) 3; else 4; // works
var y= if (a <b) 3; // error
var z= if (a<b) { // works, rules for block expression apply here
  stmt; 
  5;
} else {
  stmt;
  3;
}

There are only two constructs that support "implicitly returned value": block expression and if-expression (plus already defined switch-expression). There's no "while"-expression or "for"-expression or anything else.

There's a small catch though: "early return" from block expression is problematic. "break value" is no good (conflicts with break label or leads to ugly syntax), and "return" means something totally different. Not a big deal IMO.
(Maybe "emit" can help, but it also might become the source of confusion).

@lrhn
Copy link
Member

lrhn commented Mar 9, 2024

If we say that a {statements;} has a type and a value, then we must be able to infer the type. Which means passing in context types. And probably assigning a type to a statement with no actual value (void, value null).

The type of a statement with type context C is:

  • expression-statement: type of expression with type context C.
  • labeled statement: type of inner statement with type context C.
  • if-statement:
    • If has else, UP of types of each branch with context C.
    • If has no else, S? where S is type of then branch with context C.
  • switch-statement: UP of types of every case block.
  • block statement: {stmt1 ... stmtN}.
    • If N is zero (empty block), then type void (and value null)
    • If not labeled (not a break target): type of stmtN with context C.
    • If labeled and used as break target; void. (Let's not try to allow 42; break block; to "return" a value.)
  • do-loop: type of body with context C.
  • any other (for,while,try): void. (Which means not allowed in most expression contexts.)

That can work. I am worried about parsing it, for all the same reasons we don't allow an expressions-statement to start with a { character. With collection literals, it's pretty certain that this would be ambiguous. Take:

var x = {if (test) {} else {}};

Is that a statement block or a set-of-maps literal?

(The do is there for a reason, to explicitly opt in to being a set of statements.)

@rrousselGit
Copy link

rrousselGit commented Mar 9, 2024

Parsing can always be solved by adding an extra symbol on the opening bracket. This would help with readability too I think.

var x = #{
  return 42;
};

And if we opt for this, I'd heavily suggest making the => on switch expressions optional in this case.

Rather than:

final a = switch (v) {
  _ => #{ return 42; }
}

We'd have one of:

final a = switch (v) {
  // Desugared to `=> #{...}`?
  _ #{ return 42; }
}

final a = switch (v) {
  // Reminiscent of switch statement, for syntax parity.
  // Then I'd suggest supporting `=>` in switch statements to be complete.
  case _: #{ return 42; }
}

@tatumizer
Copy link

tatumizer commented Mar 9, 2024

var x = {if (test) {} else {}};

I think it's a contrived example. If someone wanted just an if-expression, the code would be different:
var x = if (test) {} else {}.
But sure, it's a matter of principle. For 1-tuple, the user has to write an extra comma: x=(1,);. A similar rule can be introduced for the 1-element set literals. Set literals are rare, single element set literals are still rarer, so the new requirement won't affect too many users.

As for "do" - for some reason, I don't like it, it just feels wrong. Especially considering that we already have "do - while" in the language, which doesn't return a value and never will. Maybe #{...} is indeed a good compromise?

@rrousselGit : there should be no "return" in your examples. Block "returns a value" not via "return", but implicitly.
Unfortunately, this can create confusion.

final a = switch (v) {
  case _: #{ stmt; return 42; } // returns from the containing function!
}

would mean a totally different thing than

final a = switch (v) {
  case _: #{ stmt; 42; }
}

That's why I suggested disallowing "return" in block expressions.

@lrhn: this is problematic

  • If has no else, S? where S is the type of then branch with context C.

This clashes with the existing treatment of if-else in collection literals. Not sure what can be done about it.

@lrhn
Copy link
Member

lrhn commented Mar 9, 2024

Definitely a contrived example. I expect almost all possible reasonable programs to be uniquely parsable, and the test will be contrived because it's obvious to a human reader what the author means.
But we still need a computer to parse it, and do something, preferably the right thing, with every acceptable input.
Parsing isn't impossible (probably), but it might get harder, which usually also means that all later changes get harder too.

Then there is the issue of readability. If the meaning of a long piece of code changes significantly whether there is s semicolon or not, then it breaks with the design principle of "make similar operations have similar syntax, and different operations look different". The first part is to make learning the language easier. The second is to make code readable.
If {if(test) {}} can mean two vastly different things, a set literal or a statement block, then you need context to know what one it is. So far we've taught uses that if it starts a statement, it's a statement.
Making it also, possibly, be a statement on the middle of an expression throws out that learning.

And statements in expression position will definitely clash with elements. We have three different kinds of code:

  • expressions, always have a value
  • elements, zero or more values
  • statements, no values.

(If we had conditional arguments, we'd cover the zero-or-over case too.)

Those are significantly different, and we can't easily use one instead of the other. At least we always need to know which one it is.

@rrousselGit
Copy link

@rrousselGit : there should be no "return" in your examples. Block "returns a value" not via "return", but implicitly.
Unfortunately, this can create confusion.

Not necessarily. That's up to us to decide what the "return" in a custom block does. Especially when using a special syntax for defining the block.

#{...} could very well be sugar for () {...}(), in which case return definitely does not quit the enclosing function.

@tatumizer
Copy link

tatumizer commented Mar 9, 2024

Indeed, #{...} could very well be sugar for () {...}(), thus requiring "return", but if we intend to address if-expressions in a similar manner, then we have a problem. Is this a valid code?

var x = if (cond) {
   stmt;
   42;
} else {
  stmt;
  43;
}

Writing "return 42;" is not an option (clearly, it would be a return from the enclosing function). So, we have to introduce "implicit return" for if-expression. But as soon as we introduce implicit returns for if-expression, why do it ONLY for if-expression and not for block-expression? Another example:

var x = #{
  stmt;
 
  if (test) {  
    42; 
  } else {
    43;
  }
}

This won't work: you need to write "return if (test)..." or "return 42"/"return 43". I find it inconsistent and also inconvenient. We may, of course, live without if-expression - I just hoped we can kill two birds with one stone. But without the second bird, the case for #{} becomes very weak: just use {}{...}() instead.

@lrhn: does syntax #{...} address your concerns? There's no conflict with the set literal.
(Personally, I would prefer to go without '#' marker - it just looks cleaner. Also: why does block-expression require a marker, and if-expression doesn't? )

@rrousselGit
Copy link

rrousselGit commented Mar 9, 2024

Writing "return 42;" is not an option (clearly, it would be a return from the enclosing function).

The point is that we'd use #{ in all block expressions. So:

var x = if (cond) #{
   stmt;
   return 42;
} else #{
  stmt;
  return 43;
}

There's no issue here

Or:

var x = #{
  stmt;
 
  if (test) {  
    // We quit the enclosing #{ block here
    return 42; 
  } else {
    return 43;
  }
}

And of course, we could use this in collections:

final x = [
  if (foo) #{
    statement;
    return 42;
 }
]

@tatumizer
Copy link

If we want to call a thing "an expression", then the term itself imposes some restrictions. In particular, you can't use the word "return" in the context of the expression in the same meaning as you do inside a function. Expressions like this are supported in many languages, but there, if you say "return x" inside a block, it will return from the enclosing function (not from the block). See, for example, this thread
In rust, they use "break" (not "return") for the "early return", but they have a special syntax for labels, so "break 42" is not ambiguous. Again, this is not only rust - it looks like an established convention,

@lrhn
Copy link
Member

lrhn commented Mar 9, 2024

Block/statement expressions "need" a delimiter mainly because statements can already contain expressions. If expressions can just contain statements, then we risk cycles in the grammar. A unique delimiter can make it clear and unambiguous which way we are parsing the following syntax, both to the compiler and to the human reader.

The alternative, which we have used so far, is that if something starts a statement, then it's the statement form, otherwise it's the expression form. That has worked fairly well. Then we added elements, which can also contain expressions, and again the rule is that if it can start an element, it does. Expressions are the lowest "precedence" of interpreting ambiguities. If an expression can now start with a statement, things may get weird, because now an element can start with a statement, which is new and grammatically confusing.

Delimiters makes the grammar unambiguous and gives the reader a hint about what's to come.

"If expressions", which I guess means conditional expressions like e1 ? e2 : e3, have a distinctive syntax. It's not delimiters, and that has caused us a lot of trouble over the years, as we try to use ? and : for other things too. If a conditional expression had to be parenthesized, (e1 ? e2 : e3), then it would be much, much easier to avoid ambiguity.
(And you generally should parenthesize your conditional expression if it's not an expression by itself. Don't do this: isDiskOk ? discController() : discRepairController()..format().)

(You can totally use the word return in an expression with the same meaning as in a statement: Returning from the surrounding function. It's giving it a different meaning, like "returning from the surrounding expression" that gets weird.)

@tatumizer
Copy link

By "If expressions" I meant your proposal #2306. (I don't understand the part where you said this expression cannot start the statement).
Are you sure you want to allow "return" in this expression?

var x = if (cond) 42 else return 15;

Right now, you can't use "return" in expressions. E.g. var x= cond ? 42 : return 15; is an error.

@lrhn
Copy link
Member

lrhn commented Mar 9, 2024

There are lots of ideas floating around here.
The simplest is allowing return, break, continue and rethrow as expressions. That allows some of the uses people have for statements in expressions - the bailout if there is no good value.

With that, I would allow (whether if or ?/: syntax)

int foo(bool cond) {
  var x = cond ? 21 : return 15; // aka: `int x; if (cond) x = 21; else return 15;`
  return x + x;
}

That would be the point of allowing those as expressions.

@tatumizer
Copy link

tatumizer commented Mar 11, 2024

If "return" is allowed in expressions, then what stops dart from simply reclassifying a block, "if" and "try" from statements to expressions, with no syntax changes to the language?

We can say that these constructs always produce a result; whether the result is used (e.g. assigned to a var or passed as a parameter, etc.) is irrelevant.
The result is defined as the value of the last executed statement or expression in the body. If the result has type "void", the returned value is set to null (for historical reasons: that's how things are defined today for a function).

An "early return" from an expression has the form break label:value (or maybe break label value?). The label is mandatory.
I don't think this can create too much confusion: after all, the syntax remains essentially the same, and the "last expression" thing is common for many languages

(there's a small backward compatibility issue with the "break" in a legacy "block statement", but I think it's solvable)

@mcmah309
Copy link

Whatever syntax is decided, I think it is critical to allow returning to the containing function from any nested expression.

@tatumizer
Copy link

One of the problems with block expressions is that we will have a third way to write function definition:

(x)=>expr;
(x){...body...}
(x)=>{...block-expr...} 

To add to the confusion, the way how function returns the result would be different between variant 2 and variant 3.
(Or we will have 2 equivalent ways of returning a value - one with "return v", and another - implicit return or break)

@lrhn
Copy link
Member

lrhn commented Apr 12, 2024

I'm not sure having a suboptimal way to write something is a problem, if there is a better way too, and we can point people to the better way.

You can do list.isEmpty, list.length == 0 or list.every((_)=>false), they all do the same thing.
We point the second choice to the first as a recommended lint, and don't even recognize the third (but good luck getting it through a code review).

It's not a problem.

As for doing

int foo() => do { 
  stmt;
  if (something) return 0;
  42
};

I think we can recommend converting that to a block body too, as a matter of style.

@tatumizer
Copy link

tatumizer commented Apr 12, 2024

Is there a difference between

var x= do {
  ...
  if (cond) 42; // semicolon
}

and

var x= do {
  ... 
  if (cond) {
    42
  }
}

In other words, does the semicolon suppress the logic of implicit return?

@lrhn
Copy link
Member

lrhn commented Apr 13, 2024

There is no specification for expression blocks yet. Nobody knows what the syntax means, we're just throwing around examples.
Whether a semicolon will be singificant depends on what we can end up agreeing on (if anything). Everything is still open.
(What do you want it to mean? 😉)

@tatumizer
Copy link

I don't quite understand the role of "do" in do {...} . Suppose we want to introduce if expression like

var x= if (cond) {
   Stmt;
   42
} else {
  Stmt;
  43
}

There are two block-expressions here: one is then-expression, another is else-expression. Any justification you have for a "do" also applies here. Do we have to add a "do" in each branch? Like this?

var x= if (cond) do {
   Stmt;
   42
} else do {
  Stmt;
  43
}

@tatumizer
Copy link

Maybe people dislike IIFE because it's ugly? Then it's possible to introduce a shortcut e.g.
.{code} as a synonym for
(){code}().
This is almost the same as block expression, but much simpler to formalize and implement.

@lrhn
Copy link
Member

lrhn commented Apr 17, 2024

I dislike IIFEs because they're the wrong tool for the job.

They try to look like code that's in the current function, but they are not. It's just a helper function with no name.

One could instead make it a named function and call that. If that's not good enough, that's because a function isn't really what the problem calls for. It usually isn't, otherwise that completely idiomatic approach would probably have been used.

So it's syntax that tries to be something other than what it is. That makes it hard to read.

And it doesn't work well with async code.

@tatumizer
Copy link

tatumizer commented Apr 18, 2024

The problem is that in dart, it's difficult to introduce block-expressions without causing great confusion about functions.
In expression-oriented languages, a function is subject to the same rules of implicit return of "last expression". Also, there's some dance around semicolons. E.g. in rust

fn plus_one(x: i32) -> i32 {
    x + 1
}

means something different than

fn plus_one(x: i32) -> i32 {
    x + 1;
}

The Documentation explains that this semicolon turns the expression into a statement , which I find a bit strange, and don't even understand what exactly is turned into a statement.
See details here.

Because dart is unlikely to drop "return" in functions, the logical thing to do in blocks is to require explicit "emit". Agree?

@mcmah309
Copy link

mcmah309 commented Apr 18, 2024

What part is the confusion? The example you gave is a top level expression of a function so

x + 1

Or

return x + 1;

Yield the same result. But, if you nested these operations inside another expression, the former would yield to that expression and the latter the function.

What is being turned into a satement by adding a semicolon seems self explanatory to me.

@tatumizer
Copy link

tatumizer commented Apr 18, 2024

The confusion comes from the fact that today in dart, functions don't support implicit return (not counting an implicit return of null in the function that failed to return anything by the moment it reaches the end of the body).
Introducing implicit return in blocks without doing the same in functions would be confusing IMO.
Agree?

@mcmah309
Copy link

I agree, it makes sense for functions to also support implicit returns. But there may be some language constraint I am not aware of that prevents this from being implemented. If so, I don't think it saying implicit returns are only allowed in a do block is confusing.

@mcmah309
Copy link

mcmah309 commented Apr 18, 2024

@lrhn if do {} is the agreed upon syntax, I would expect its functionality to extend to do {} while and if so, It would also make sense to extend to while {} in general. Thus:

String func(){
    int x = 0;
    String y = while(true) {
      x++;
      if(x < 5) continue;
     "string"
    }
    return y;
  }

is this what you had in mind?

@lrhn
Copy link
Member

lrhn commented Apr 18, 2024

We can allow do {... expr} while (...) as an expression since the body is evaluated at least once. Have to disallow breaking the loop, or introduce breaking with a value.

Probably not while (..) {...}. A while loop can run zero times, and an expression must provide one value.

@tatumizer
Copy link

A while loop can run zero times, and an expression must provide one value.

Counterexample:

var x = (){
  if (false) {
    return 42;
  }
}();

This is an expression without the value, it returns null. :-)

@mateusfccp
Copy link
Contributor

mateusfccp commented Apr 18, 2024

This is an expression without the value, it returns null. :-)

Actually, this is an expression with value null. The anonymous function definition is not an expression, so it can "return nothing", but the call to the function is an expression, and has a definite value.

@tatumizer
Copy link

The anonymous function definition is not an expression

See section 17.11 of language spec v. 3

@dgreensp
Copy link

dgreensp commented Apr 18, 2024

Personally, I dislike having the last statement/expression in a block sometimes be used for its value, without any keyword like return or yield (or my proposal give) to indicate that. Having a keyword also lets you "return" early from the block, and basically parallels the return keyword for functions. I can see people disliking the unfamiliarity of do { } and/or using the word give as a keyword (we programmers tend to like using keywords that have at least been used as a keyword before in some other popular language, so that the "keyword" bit has been set on that word in people's minds, and in the name of backwards compatibility and not having too many reserved words, it is also common to just reuse the same keyword like static in a bunch of different ways).

In Rust and Scala, blocks are expressions, and in the block expression { stmt1; stmt2; expr }, expr is understood to be what the block evaluates to. Obviously Dart can't use curly braces this way, because of set/map literals, which is fine with me. But at least it's universal within the language. (Or maybe, to make it work in Dart, something similar to could be done to how tuples and parentheses are disambiguated, like a block expression has to have at least one semicolon, the same way a tuple has to have at least one comma.)

The JavaScript do-expression proposal uses the syntax do { stmt1; stmt2; ... }, and the way it gets a value out of a block (which is a statement, not an expression) is kind of interesting. JavaScript has always had a concept of a statement having a "completion value," which is why eval("1;2;") (or eval("1;2"), since semicolons are optional in JavaScript) evaluates to 2, and why you can type 1; 2 in the browser console or the Node REPL and get 2. You can even—and I didn't realize this before—evaluate if (true) { 1; } else { 2; } (semicolons optional) in the console and get 1, and this is not because JavaScript has an if expression—like Dart, it doesn't, it has the ternary operator for a conditional expression, and if as a statement; you can never write var x = if...—and it's not because a block can be an expression. This is an if statement containing block statements containing expression statements. You would never know it has a "completion value" unless you run it in a REPL or with "eval." Well, the proposed do-expression uses the completion value of its block, which is the completion value of its last statement. If the last statement of the block is an if statement, you can actually get a value out of that.

Again, though, I feel like it potentially leaves the reader guessing whether what looks like a statement is actually being used for its "result" value, especially if the block is long. A counterargument would be, the do keyword is a strong cue that this is happening, and you probably shouldn't make your do blocks very long.

Do expressions are unrelated to do-while loops, and the syntactic ambiguity is easily addressed in the TC39 proposal:

do expressions are prohibited in contexts in which statements are legal. In such contexts you can just use a normal block or enclose the do expression in parentheses.

@tatumizer
Copy link

tatumizer commented Apr 20, 2024

Suppose I have the following program:

var x=(){
  ...
  if (cond) return "Blue";
  return "Moon";
}();

Having heard of a do-expression with the "implicit return", I refactor it into a do-expression by simply dropping both "returns"

var x= do {
  ...
  if (cond) "Blue";
  "Moon";
};

After a bit of testing, I discover that the program now returns "Moon" regardless of the condition, and I don't understand why. In desperation, I put "returns" back

var x= do {
  ...
  if (cond) return "Blue";
  return "Moon";
};

Hilarity ensues... :-)

The idea that if (cond) "Blue" returns "Blue" from the block on cond=true is an optical illusion. For a human reader, the line reads like a kind of return, otherwise it's meaningless. The compiler doesn't see it this way.

I like the word "emit". With explicit emit, the only syntactic difference between IIFE and do-block is the use of "emit" instead of "return". No hair-splitting about semicolons, no optical illusions... "explicit is better than implicit".

var x= do {
  ...  
  if (cond) emit "Blue";  
  emit "Moon";
};

(Using more terse "^" is also an option (there's a precedent in smalltalk, where it meant "return"), but the word "emit" is more meaningful IMO).

Explicit "emit" would rule out making "if" an expression, otherwise we have a puzzler here:

var x = do {
  var y = if (cond) {    
    emit 42; // emit to where??? y or x?   
  } else {
    emit 0;  
  } 
  ...
}

An argument can be made that while, do-while, for should not be redefined as expressions either, to avoid a similar type of confusion about the destination of "emit".

@tatumizer
Copy link

Among the disadvantages of IIFE mentioned earlier, there's this: IIFE doesn't work well with async code.
Citing from #2848

IIFEs work badly with async, because if you have an async body, you need to add extra await and async to it.
The plain () { .... }() becomes await () async { ... }().

What will happen if we replace IIFE with a do-block? Then the plain () { .... }() will become (I'm guessing) await do {....}, right?
Then consider the program

var x = await do {
  if (cond) {
   emit Future.value(1);
  } else {
   emit 1;
  }
};

What is the return type of this expression? It (I'm guessing) must be the same as the return type of the expression
cond ? Future.value(1) : 1, which is Object. Not a Future, not a FutureOr, but an Object.
To make it return a Future, we need to add an instruction to the compiler to treat our do-expression in a way similar to async functions. This leads to the syntax await do async {....}, which is arguably not much better than the original. :-)

@mcmah309
Copy link

mcmah309 commented Apr 22, 2024

do-blocks aren't meant to replace IIFE. They each have their own use cases with some overlap. I don't think we need async do-blocks. You can just use a do-block inside an async function. But if they are supported, I don't see a problem here. The latter branch can just be coerced into a future, like a regular async function.

@dgreensp
Copy link

I agree with using a new keyword that breaks out of the new do expressions, but not any other kind of expression.

I think the point of bringing up async was just to say to definitely not implement or specify do in terms of IIFEs.

To me, the word "emit" doesn't convey that execution of the do block terminates with a result. The word "give" can be used in the sense of: "the do expression is evaluated and gives 3 as a result." In other words, the intended sense is not that the values is being "given" as in emitted or yielded by the statement of that name, but given by the do expression as a whole. It's natural to say that evaluating an expression gives X as a result, less natural to say it emits X as a result. Emitting sounds like something that potentially be done any number of times while executing a block, without affecting control flow.

If not give, I would suggest something along the lines of (but not) break or return (or yield), or with at least some sense of completion ("telic" semantics). produce? Or what about a symbol like ->? Or a variant of return, maybe with some punctuation?

Whatever is chosen should look good in small blocks like do { f(); give g(); } or do { f(); -> g(); }, and also in large blocks with multiple "return" points inside if-statements. (If you only look at the small block case, you could convince yourself that no keyword or symbol is needed, but the large block case, IMO, shows the need for one.)

@mcmah309
Copy link

Sounds like everyone has a different option on the syntax. I personally like omitting any unnecessary keywords and just being implicit. Coming from a background familiar with Rust, I have never found personally or heard anyone else voice distaste over being implicit in these situations. I think after you code that way for awhile, it actually becomes tedious to specify unnecessary keywords that are just syntactic sugar (Switching back and forth to Dart and Rust, I have even learned to dislike having to use braces in expressions like for (..) when they are in reality unnecessary).

That said I think the language team should just pick a route since I do not believe the community will come to a consensus here on the syntax.

@tatumizer
Copy link

...Or just simply "out". An example using .(...) for block compound expression

return switch (this) {
    // ... 
    Lambda(:final formals, :final body)=>.(
        //... Some code
        out x;
    ),
   // ...
}
// in collection literals:
var list = [
   first,
   second,
   if (cond) .(
     ...
     out x;
   )
];

There can be a better symbol than dot, but parentheses (instead of {...}) seem a better choice: if we call it "an expression", the syntax should look more like an expression than a block.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
request Requests to resolve a particular developer problem
Projects
None yet
Development

No branches or pull requests

10 participants