tc39 / proposal-do-expressions

Proposal for `do` expressions
MIT License
1.11k stars 14 forks source link

Why not just let statements return value? #39

Open kkshinkai opened 5 years ago

kkshinkai commented 5 years ago

Statements in many Languages can return value directly, for example, Rust:

let flag = if (cond) {
  "True"
} else {
  "False"
}

So why we need the keyword do?

Even {...} is better than do {...}

let flag = {
  if (cond)
    "True!"
  else
    "False"
}

These syntaxes will not cause ambiguity. Am I right?

phiresky commented 5 years ago

second syntax definitely causes ambiguity:

let y = 1;
let flag = {
  x: y // k: v
}
let flag = {
  label: y; // statement with label
}
pitaj commented 5 years ago

The ambiguity issue can be solved by saying "labels are not allowed in blocks-as-expressions, any such ambiguity will be resolved as an object literal"

ljharb commented 5 years ago

This suggestion would break this valid code:

function f() {
  {
    // this makes a scope
    let a;
    if (true) {
      a = 2;
    } else {
      a = 3;
    }
  }
}
f() === undefined

With your change, f() === 2.

pitaj commented 5 years ago

I might be missing something, but the suggestion doesn't include anything about implicit return values.

For the behavior you're describing, there would have to be a return before the opening brace of the internal block AFAIK.

ljharb commented 5 years ago

@pitaj the suggestion was for curly braced blocks to be an expression; that’s what would break. Anything that can be a RHS or a return value also has to be an expression.

ljharb commented 5 years ago

ah, i see tho, you’re saying that the function wouldn’t have a return value without return, that’s true. In that case, consider eval of that function body, instead.

pitaj commented 5 years ago

I suppose your point is that such behavior would require many exceptions to be backwards-compatible. Another example might be an arrow function:

const f = () => {
  3;
};

f() === undefined;
// under this proposal
f() === 3;
ljharb commented 5 years ago

In other words, my overall response is “the explicit do both avoids confusion and keeps the rules simple”

pitaj commented 5 years ago

The most consistent way to make this backwards-compatible would be to say "any syntax involving a statement in the place of an expression that currently results in an error is now treated as a valid expression".

That's not very satisfying though, and would result in some weird inconsistencies (like in the arrow function case).

ljharb commented 5 years ago

Thus, not the most consistent way.

pitaj commented 5 years ago

Right, I was speaking within the confines of "without using do".

pvider commented 5 years ago

I want to talk about ambiguites and 'legacy code'. Here by 'legacy code' I mean any code before this proposal. Of course we shoudn't break any existing code. However we should think about cases when meaningful code coudn't be broken. There are should be rules when implicit return for last statement should be disabled. I think these rules are: 1) assignment (=) . Assignment should not return anything, many languages with implicit return do this. 2) any function call (f(x)). In legacy code last function call in a block used for side effects. 3) any method call (f.g(x)). Same as function call. Or summary: explicit return is allowed only when last expression has no possible side effects.

So every last expression in a block should not break above rules: no assignments, not function calls, no method calls. Then we have meaningful defaults:

function f() {
  {
    // this makes a scope
    let a;
    if (true) {
      a = 2;
    } else {
      a = 3;
    }
  }
}
f() === undefined```
-- returns undefined cause of assignment.

```js
const f = () => {
  3;
};

f() === undefined;
// under this proposal
f() === 3;

-- using constant as last expression is useless in legacy code, what will be broken do you think? Only some strange snippets which show that legacy code has no implicit return. If you have any proof of usefullness in production please show it.

With above rules the following will work because it has no possible side effects:

f = {
a + b < c * 10
}

g = {
  x = Math.floor(11 / 3.3)
  x
}
// 
ljharb commented 5 years ago

Assignment already returns something, and is an expression (although most style guides discourage using assignment as an expression).

ceigey commented 5 years ago

I believe I am missing some background information - is there anything that makes the very first example undesirable or ambiguous?

My understanding is that, currently, you cannnot assign a keyword-based control flow statement to a variable, or use them where an expression is required.

So, I'm assuming (that's a dangerous word) that there is no ambiguity there, and thus allowing:


let a = if (true) { 'yes' } else { 'no' }
console.log (if (false) { 'whoops' } ......)

..."should" be fine.

The potential footguns are things like


let a = if (true) 'true'
  else 'false'

Which I would expect parsed as two separate statements.

Are potential footguns like that the reason for having the generalised do expression syntax?

ljharb commented 5 years ago

If it can be used in an assignment, it can be used in any position an expression can be used, which includes on the next line after a statement that omits a semicolon

ceigey commented 5 years ago

That was largely the intent, treating if/else as an expression, but I presume from the way you worded that, that that kind of ambiguity isn't going to fly in the case of EcmaScript.

ljharb commented 5 years ago

I very much doubt it.

cdow commented 5 years ago

Can't any ambiguity, as well as backwards compatibility concerns, be resolved by preserving the following 2 rules (in addition to the one mention by @pitaj about labels in blocks):

  1. Top level curly brackets in function bodies will be treated as part of the function definition and not part of a body expression.
  2. Multi-statement functions require an explicit return

As far as I can tell these rules already exist. That is why you can't declare object literals in arrow functions like this:

() => {
  key: 'value'
}

Instead you have to do this:

() => {
  return {
    key: 'value'
  }
}

By preserving the above rules, then this doesn't break because the block result isn't returned:

function f() {
  {
    // this makes a scope
    let a;
    if (true) {
      a = 2;
    } else {
      a = 3;
    }
  }
}
f() === undefined

This wouldn't break because the body of the function now consists of the literal 3 and it isn't being returned:

const f = () => {
  3;
};

f() === undefined;
ljharb commented 5 years ago

@cdow so will if ({ a: false }) be truthy or not? Now, only an object can go there - but if it can be a scope, with a labeled false, it’d suddenly be falsy.

Anything that can be an expression has to be able to be used anywhere any expression can be, or else you end up with the unfortunate ambiguity in concise arrow function bodies (that i hope nobody wants to repeat or worsen)

cdow commented 5 years ago

That case would be covered by @pitaj's suggestion:

labels are not allowed in blocks-as-expressions, any such ambiguity will be resolved as an object literal

You might be able to formulate a rule is less strict about labels but that one seems pretty straightforward.

Going by that rule, { a: false } would be an object literal. So, it would always be truthy.

ljharb commented 5 years ago

and { a } when const a = false?

cdow commented 5 years ago

Thinking about this further, there doesn't appear to be any reason to limit expression if/else to being inside do blocks. The only reason the do keyword is necessary in the original proposal is to disambiguate expression blocks from object literals.

cdow commented 5 years ago

As per @pitaj 's suggestion, { a } would be an expression block. So it would evaluate to whatever a is.

nicolo-ribaudo commented 5 years ago

Then it is a breaking change. Currently it evaluates to an object with an a property.

cdow commented 5 years ago

I hadn't considered that case. That is a good point. I think the { a } case kills the possibility for using plain {} for expression blocks. However, I think if/else can still be an expression without being inside a do block.

phaux commented 5 years ago

However, I think if/else can still be an expression without being inside a do block.

That would mean the following is allowed:

let x = if (foo) if (bar) 1; else 2; else 3

I vote for allowing if/else as expression, but requiring curly braces.

Edit: where does the arrow end?

const sign = x => if (x > 0) 1; else if (x < 0) -1; else 0; console.log(sign(42));
// vs
const sign = x => if (x > 0) { 1 } else if (x < 0) { -1 } else { 0 }; console.log(sign(42));

Edit2: ...or simply allow do if (…) {…} else {…} as expression to make it even more unambiguous

cdow commented 5 years ago

I'm not sure I understand why this is a problem:

let x = if (foo) if (bar) 1; else 2; else 3

What is the concern beyond the currently allowed:

if (foo) if (bar) console.log(1); else console.log(2); else console.log(3)

I would think that the arrow would end right before the console. I'm not sure I understand what ambiguity this adds over the current if/else syntax:

if (x > 0) 1; else if (x < 0) -1; else 0; console.log(42);
phaux commented 5 years ago

@cdow

I'm not sure I understand why this is a problem

Because it adds semicolons which aren't actually ending the statement. Having semicolons inside expressions is weird

claudepache commented 5 years ago

@cdow. In the following program:

if (x > 0) 1; else if (x < 0) -1; else 0; console.log(42);

the semicolon after 0 ends the statement inside the else clause. The if statement itself does not end with a semicolon. This can be seen when you write it as:

if (x > 0) { 1; } else if (x < 0) { -1; } else { 0; } console.log(42);

That means that you would have to write:

const sign = x => if (x > 0) 1; else if (x < 0) -1; else 0;; console.log(sign(42));

with two semicolons before console.log, the first one ending the 0; statement inside the else clause, and the second one ending the const declaration.

cdow commented 5 years ago

Because it adds semicolons which aren't actually ending the statement.

if statement itself does not end with a semicolon.

I'm not sure these statements are actually true. Otherwise why is this valid JS (at least according to Chrome and Firefox consoles):

if (x > 0) 1; else if (x < 0) -1; else 0; console.log(42);

But this isn't:

if (x > 0) 1; else if (x < 0) -1; else 0 console.log(42);

If what you are saying was correct, shouldn't we have to already write:

if (x > 0) 1; else if (x < 0) -1; else 0;; console.log(42);
nicolo-ribaudo commented 5 years ago

Consider this expression:

const x = do { if (a) 0; }; console.log(1);

There are not unnecessary semicolons (ie "empty statements".

If we remove the parentheses, it keeps both the semicolon which ends 0; and the one which ends the const declaration:

const x = do if (a) 0;; console.log(1);

It might be clearer with parentheses:

const x = (do if (a) 0;); console.log(1);

If I remove a semicolon, it becomes the equivalent of

const x = (do if (a) 0;) console.log(1);

or

const x = do { if (a) 0; } console.log(1);
cdow commented 5 years ago

I understand the reasoning for why one would expect to need two semicolons. My point is JS appears to already have support for using a single semicolon to end multiple things at once. Can't we leverage that same logic for if/else expressions?

For example, @nicolo-ribaudo, you can do your same reduction, in current JS, with just an if statement:

if (a) { 0; }; console.log(1);

If you remove the curly brackets you get:

if (a) 0;; console.log(1);

However, you can also remove one of the semicolons and it is still valid:

if (a) 0; console.log(1);

Does the above semicolon end the 0 statement or does it end the if? As far as I can tell it ends both. Why can't this still be done if if is an expression?

nicolo-ribaudo commented 5 years ago

That's because the second semicolon in your first example is unnecessary. In your first example you have 3 statements:

You can safely remove the empty statement, since they have absolutely no meaning:

if (a) { 0; } console.log(1);

The following statements don't have a closing semicolon:

While these do:

In my first example (const x = do { if (a) 0; }; console.log(1);), the first semicolon closes the expression statement (0;), while the second one closes the const declaration. In your first example (if (a) { 0; }; console.log(1);), the first cemicolon closes the expression statement, while the second one doesn't close anything and is a separate statement.

cdow commented 5 years ago

Thanks, @nicolo-ribaudo, that cleared up my confusion.

I guess that also explains why arrow functions without curly brackets don't allow semicolons. Couldn't you apply the same rules about semicolons to expression control structures? Wouldn't that clear up any issues with multiple semicolons?

nicolo-ribaudo commented 5 years ago

I guess that also explains why arrow functions without curly brackets don't allow semicolons.

It's different: arrow functions without curly brackets don't allow statements, but only expressions.

cdow commented 5 years ago

Shouldn't expression control structures without curly brackets only allow expressions as well?

nicolo-ribaudo commented 5 years ago

Yes, that would solve the semicolon problem. But by limiting them as such, I don't think that they would bring additional value to the language. I can't think of other usecases for control flow structures with only an expression as the body other then if and if/else, but we already have them:

const color = if (dark) "black" else "white";
// ->
const color = dark ? "black" : "white";

const name = if (person) person.name;
// ->
const name = person && person.name;

Even is all the example in this thread use trivial example, the usecase for this proposal is to allow more complex program flows to be represented as expressions, and disallowing statements goes in the opposite direction.

cdow commented 5 years ago

I don't think we should limit if/else to just single expressions. People should still be able to include multiple statement/expression bodies by wrapping the body with curly brackets. I suspect that is what many people (myself included) care about the most. However, I still think we should try to solve the single expression version for 3 reasons:

  1. People are already used to being able to write if/else without curly brackets. So, if people modify some existing logic and want to convert a one liner to a multi-statement version (or vice versa) they won't have the cognitive overhead of having to convert between 2 completely different expression syntaxes.
  2. Ternary operators doesn't neatly support else if. You can encode it but it is messy and generally involves thinking carefully about precedence rules.
  3. These rules can also be generalized to apply to try/catch expressions. There isn't a ternary equivalent for try/catch
bakkot commented 3 years ago

For me, one of the major use cases for do expressions is to introduce a scope which is local to an expression; that is, to introduce a block of statements in expression position, rather than a single statement like if. There's discussion above about why we can't simply make { be a block in expression position, and why potential patches like "distinguish based on :" fail to solve the problem.

(Another fun example:

let x = {
  foo()
  {
    bar()
  }
}

is currently legal code: it makes an object with a foo method.)

As such, I think do expressions, as proposed, stand on their own merits. I don't think it makes sense to simultaneously try to turn if and try into expressions.

kornelski commented 3 years ago

How about using unary = to make a block expression have a value?

{ = x }
let x = if (true) { = 1 } else { = 2 };

It would be required to be the last expression in a block:

let result = {
   let tmp = fn();
   = tmp * tmp;
}
ljharb commented 3 years ago

How would that interact with ASI and more statements in the do expression?

bakkot commented 3 years ago

@kornelski The problem with that is that you really want to know at the beginning of the block whether it's going to be an object or a block:

let result = {
  if (foo) {
    // arbitrarily complicated stuff here
  }
}

is already legal (it creates an object with a method named if), and if readers (and engines) can't distinguish it from

let result = {
  if (foo) {
    // arbitrarily complicated stuff here
  }
  = 1
}

(presumably an expression-block containing an if statement) until they get to the = 1, that's not going to fly.

cdow commented 3 years ago

@bakkot, I think you are right that turning statements in to expressions (if/else, try/catch, etc) and do blocks should probably be 2 different proposals. It seems like those might actually play together pretty well together. If multiple statement expressions are solved here then a proposal for statement expressions might only need to solve for the case where the statements have only a single expression body. For example, if if/else expressions only support a single expression, you could do this:

const result = if(cond) 1 else 2;

But you couldn't do:

const result = if(cond) {
  1;
} else {
  2;
}

However if you could create do blocks like this:

const result = do {
  doThing1();
  doThing2();
}

Then you could do something like this:

const result = if(cond) do {
  1;
} else do {
  2;
}
theScottyJam commented 3 years ago

I think @cdow is onto something. If our goal is to make Javascript more expression oriented, the most straightforward path would be to just provide expression versions of if-else and try-catch. I made a post here talking about that, and they rightfully linked me here, because this is a very similar idea.

But, instead of having a do block, we could just have do if and do try, which takes expressions instead of blocks. If we want a block in the expression position, my suggestion was to provide nicer syntax for IIFEs and make them more normal to use.

Here's some examples:

const val =
  do if (count === 0) "none"
  else if (count === 1) "one"
  else if (count === 2) "a couple";
  else if (count <= 5) "a few"
  else "many"

const result =
  do try (
    readFile('./myFile.txt')
  ) catch (err) (
    err instanceof FileNotFoundException
      ? null
      : throw err // I'm relying on the "make throw an expression" proposal here
  )

To have multiple statements, I'll use the made-up IIFE syntax iife { ... } (I have no idea how this syntax should actually look, I just using something for illustrative purposes)

const x =
  do if (condition) iife {
    const a = 2
    return a + 3
  } else 3
theScottyJam commented 3 years ago

I mentioned in my other post how I like the idea of using do blocks to make "mini" scopes, in which I can initialize a value without introducing intermediate variables into the parent scope, to which @ljharb responded:

You can already use curly braces to create and use temporary variables; what do expressions give you (among other things ofc) is the ability to do that in expression position without an IIFE.

In the interest of moving the conversation here, I'll respond to that here.

Curly brackets do work to make a scope (I use them), but they don't work well. For example:

// Compare:
const x = iife {
  const a = whatever()
  const b = whatever()
  return a + b
}

// with this:
let x
{
  const a = whatever()
  const b = whatever()
  x = a + b
}

You're forced to use a let binding, and it's less obvious looking at the do block that the purpose is to initialize the value of x.

The other advantage to IIFE syntax is that we can use statements in expression positions, the same side-advantage do blocks have, but implemented in a much less complicated way. (The only thing we wouldn't get is the ability to use await, continue, break, etc inside the IIFE - which if that's also a motivating reason for the existence of do blocks, maybe that can be added in the README's "motivation" section - right now, the "do if" and "do while" will completely solve the motivation stated there in a simpler way).

bakkot commented 3 years ago

IIFEs, even with lighter syntax, don't address the need for a nested scope because they come with other baggage: most grievously, they do not allow you to await, which I and I think most people would consider an unacceptable limitation. (Yes, you can use an async IIFE and await its result, but that is not quite the same semantics and is also conceptually quite painful.)

So we really do need some syntax which just introduces a nested scope, and allows you to get a value out of it, without being an actual IIFE. And the simplest way of getting a value out of the nested scope is to just let you use the completion values. (Using return isn't coherent unless it's an actual IIFE, with the new-function-boundary baggage that implies.) At which point you have the current proposal.

I agree the README needs to be updated to talk more explicitly about why IIFEs are not adequate.

theScottyJam commented 3 years ago

Thanks @bakkot

I do think we're still sort of mashing two different ideas into one proposal though. Here's are two separate goals:

That first scenario is best solved by simply providing an expression version of "if" and "try", similar to how most languages solve that issue. When coding, there shouldn't be a need to make a do block just to change an if to an expression-if - that's a lot of work to do one of the basic founding ideas of this proposal.

The second scenario is now free to be solved without these issues that the current proposal currently has:

All of these issues can be easily solved by requiring an explicit completion value marker (e.g. using a keyword give to indicate what value is the result of the do-expression). do if/do try (or simply changing if/try to be able to work as expressions) would provide everything that the completion markers were providing before, without any of the above downsides.

Aside: I don't want to drag the conversation of explicit completion value markers too much into this thread - it's already being discussed at length over here, so any thoughts on that side of things are welcome there. I merely want to point out that there are alternative formulations to the current proposal that are much simpler if we do have do if/do while.

theScottyJam commented 3 years ago

I ended up taking my do if/do try idea, with some added modifications, and presenting it as a stand-alone proposal on the TC39 forms here. While it does try to solve the same problems as do expressions, it does so in a very different way, and I'm thinking that it could be best to keep the ideas separate.

Feel free to take a peek at it and leave any feedback.