tc39 / proposal-do-expressions

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

Isn't this just an immediately invoked function? #57

Closed dustbort closed 3 years ago

dustbort commented 3 years ago

Doesn't this just provide another way to write an immediately invoked function?

(() => {
  foo();
  bar();
  return baz();
})()

Isn't that the exact semantics? In that case, we don't need to try to copy Rust and exclude the final semicolon, or worry about early returns. Simply say:

do { <body> } := (() => { <body> })()

Done. Alternative name: call. Or, can you make a block callable?

{
  foo();
  bar();
  return baz();
}()

Syntactically:

{ <block> }() := (() => { <block > })()

Can the runtime can optimize this, but use the same semantics?

I wouldn't go the route of excluding the final semicolon as implying a return. The comma operator is already somewhat similar to Rust's ability to have the last expression of the block be its value. The main limitation I have noticed is that variable bindings are not permitted inside a comma-operator expression, although assignments are allowed. I guess this is because of the ambiguity between a comma separated list of variables in a let/const binding, versus a binding in a comma operator expression. For example in

// invalid syntax
(let x = 0, y = 1, x = 2)

it is ambiguous where the let binding ends and the comma operator begins.

Fine, let's replace the comma with a semicolon to resolve the ambiguity. But, now we create another ambiguity, e.g. parsing for

// invalid syntax
for (x = 0; let y = 0, z = 0; x < foo; ++x; y += bar)
//        ^                               ^

Now we say, let's add this Ruby-like do expression that omits the final semicolon like Rust, to make something like comma operator. Phew!

bakkot commented 3 years ago

Doesn't this just provide another way to write an immediately invoked function?

No. Among other differences, you can use await within the do, which you can't do in an immediately-invoked function. That is, this works:

async function f() {
  const x = do {
    const tmp = await foo();
    tmp * tmp
  };
  return x;
}

but this doesn't:

async function f() {
  const x = (() => {
    const tmp = await foo();
    return tmp * tmp
  })();
  return x;
}

(You could use an async arrow instead, and await it, but that involves an extra microtask tick and even more syntax.)

The main limitation I have noticed is that variable bindings are not permitted inside a comma-operator expression

The comma operator only allows expressions; it does not allow you to write a try-catch or if or any other sort of statement. The point of this proposal is to allow you to put statements in expression position. They're not very closely related, that I can see.

dustbort commented 3 years ago

@bakkot Thanks for the info about async. But, in terms of semantics, why introduce the concept of statements as expressions and omitting the final semicolon? I love that about Rust, but is it necessary to address in this proposal? I saw some other issues related to early returns, and it seems easier to understand in terms of IIFE's syntax for the body, instead of new syntax.

pitaj commented 3 years ago

Statement as expression is one of the primary motivations behind this proposal. Without that, it's just a normal block.

bakkot commented 3 years ago

why introduce the concept of statements as expressions

Well, that's basically the whole proposal. Many people find expression-oriented code clearer and cleaner: it allows you to group your code in a simpler way, so that a temporary variable or a try-catch (for example) which are only used during the initialization of a single variable can be scoped to that initialization, as in

const x = do {
  const tmp = Math.random();
  tmp * tmp
};

or

const x = do {
  try {
    JSON.parse(blob)
  } catch {
    null
  }
};

instead of having to write your code in a way which obscures the structure, as in

const tmp = Math.random();
const x = tmp * tmp;
// tmp is still live here; a reader needs to read the rest of the block to know it's not actually used for anything else

or

let x;
try {
  x = JSON.parse(blob);
} catch {
  x = null;
}
// note that `x =` is now duplicated, and `x` cannot be const.

and omitting the final semicolon

I'm not sure where you're getting this from. I've been omitting the final semicolon as a matter of style, but it's strictly stylistic; this proposal doesn't say anything about semicolons.

dustbort commented 3 years ago

OK, got it. So I can wrap my entire program in do to get Rust semantics. Eventually we can make do the default. j/k

dustbort commented 3 years ago

@bakkot Rust treats a final semicolon as returning unit () instead of the statement value. It's helpful to pinpoint one way or the other.

bakkot commented 3 years ago

I'm familiar with Rust's semantics. This proposal does include anything along those lines.

dustbort commented 3 years ago

If you have to put do to make the block an expression, then wherever your "last" statement is, you can put return in front of it. Then use IIFE semantics.

Can you clarify if this is allowed?

do {
  const name = if (customer) {
    customer.name
  } else {
    "unknown"
  };
}
bakkot commented 3 years ago

If you have to put do to make the block an expression, then wherever your "last" statement is, you can put return in front of it. Then use IIFE semantics.

That is a thing we could do, yes.

bakkot commented 3 years ago

Can you clarify if this is allowed?

No.

dustbort commented 3 years ago

Why is that not allowed?

bakkot commented 3 years ago

do allows you to take a block, such as you could write anywhere else, and use it as an expression.

{
  const name = if (customer) {
    customer.name
  } else {
    "unknown"
  };
}

isn't legal as a statement, and so nor is it legal as the body of a do.

dustbort commented 3 years ago

I thought the point of this proposal was to achieve statement as expression. Otherwise, it's just IIFE body semantics, with a request to omit return from function bodies, essentially. The same thought has to be put to it as if it were that.

bakkot commented 3 years ago

do allows you to put a block which you could already write in expression position. It does not, and in my view should not, introduce a new context in which you can write things which are not currently legal.

dustbort commented 3 years ago

Thank you for clarifying that the proposal is about block as expression, which is covered by IIFE semantics, not statement as expression, as in functional programming. I'm not dismissing the astute observation that async in do is more efficient than in IIFE. I'm not trying to be pedantic either. Seeing the issues raised, just clarifying this seems to make the request simpler. It's asking for IIFE semantics within the block, which everybody understands already. :heavy_check_mark:

Additionally, it asks for omitting return, but only in do blocks, not in all function body blocks. And we still don't really get if or try as an expression, only their return value when wrapped with do. Why do we need that without statement as expression? We need to wrap every statement with do to be functional?

pitaj commented 3 years ago

Block as expression is not covered by IIFE semantics. You cannot yield out from an IIFE. You cannot await out from an IIFE. You cannot return out from an IIFE.

bakkot commented 3 years ago

It's asking for IIFE semantics within the block

It is not IIFE semantics, as mentioned above.

Why do we need that without statement as expression?

I discussed the motivation for the proposal above. As to why it's necessary to have do at all, rather than just allowing every statement to be used in expression position, please see discussion in https://github.com/tc39/proposal-do-expressions/issues/39.

dustbort commented 3 years ago

My point is that if return is kept, the block's semantics are familiar. You're bringing up issues about yield, etc that are going up past the do block to the calling scope, and I'm already trying to acknowledge you for recognizing the advantages of do for such things. await, yield :+1:

You can return the return of an IIFE; And you don't need to omit return to do that. I didn't see that return (or omitted return) in the proposal takes a label and can return up multiple levels or something. So, please clarify.

Omitting return introduces other issues, limited to do blocks, but possibly generalized to function blocks.

pitaj commented 3 years ago

My point is that if return is kept, the block's semantics are familiar.

Can you give an example of what you mean?

You can return the return of an IIFE; And you don't need to omit return to do that. I didn't see that return (or omitted return) in the proposal takes a label and can return up multiple levels or something. So, please clarify.

Short-circuiting

const foo = do {
  const response = something();
  if (response.err) {
    return { err };
  }
  response.foo
};

You can't do that with an IIFE.

Omitting return introduces other issues, limited to do blocks

Omitting return is half of the proposal.

possibly generalized to function blocks.

This proposal does not say anything about function blocks.

dustbort commented 3 years ago

Statement as expression is one of the primary motivations behind this proposal. Without that, it's just a normal block.

We get block as expression with this proposal, not statement as expression. Unless you mean to wrap each statement with do.

Can you give an example of what you mean?

Yes:

const foo = do {
  const response = something();
  if (response.err) {
    return { err };
  }
  return response.foo;
};

is akin to

const foo = (()=> {
  const response = something();
  if (response.err) {
    return { err };
  }
  return response.foo;
})();

We didn't need to omit return to get statement block as expression. and short-circuiting.

You can't do that with an IIFE.

I just showed that we can, aside from omitting return.

Omitting return is half of the proposal.

Would you like me to compile a list of the issues caused by this?

This proposal does not say anything about function blocks.

Sure it does. If you're asking for a special block context in which return can be omitted, then we will ask, can we generalize this to function blocks to make the language consistent? That's implicit. Otherwise, let's propose Rust blocks. We will add the keyword rust and then everything inside the block will be Rust language. No need to ask for language consistency, because it's Rust inside the block after all.

nicolo-ribaudo commented 3 years ago

With this proposal,

const foo = do {
  const response = something();
  if (response.err) {
    return { err };
  }
  response.foo;
};

is equivalent to

const iifeResult = (()=> {
  const response = something();
  if (response.err) {
    return { type: "RETURN", value: { err } };
  }
  return { type: "NORMAL", value: response.foo };
})();
if (iifeResult.type === "RETURN") {
  return iifeResult.value;
}
const foo = iifeResult.value;

As you can see, the iife version is much more verbose. And if you bring in labeled break and continue, the iife version gets even worse.

Also note that if you want "functions where you can omit return", this proposal gives you this:

const addAndSquare = (a, b) => do {
  const sum = a + b;
  sum * sum;
};
dallonf commented 3 years ago

With this proposal,

const foo = do {
  const response = something();
  if (response.err) {
    return { err };
  }
  return response.foo;
};

is equivalent to

const iifeResult = (()=> {
  const response = something();
  if (response.err) {
    return { type: "RETURN", value: { err } };
  }
  return { type: "NORMAL", value: response.foo };
})();
if (iifeResult.type === "RETURN") {
  return iifeResult.value;
}
const foo = iifeResult.value;

As you can see, the iife version is much more verbose. And if you bring in labeled break and continue, the iife version gets even worse.

Also note that if you want "functions where you can omit return", this proposal gives you this:

const addAndSquare = (a, b) => do {
  const sum = a + b;
  sum * sum;
};

Quick correction - I think your first example should end with response.foo; instead of return response.foo;

nicolo-ribaudo commented 3 years ago

Oh thanks, fixed. I copy-pasted the input from the wrong place.

dustbort commented 3 years ago

Ah, I see now. The return is not returning from the do block, it is returning from the higher calling scope. So omitted return is the value of the do expression, but return not only exits from the do block but goes one level higher on the call stack. Right?

I wasn't grasping just how Rusty this is.

nicolo-ribaudo commented 3 years ago

Exactly, return always returns from the enclosing function and not from the block.