tc39 / proposal-do-expressions

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

Clarify interaction between do-expressions and break #24

Open jorendorff opened 6 years ago

jorendorff commented 6 years ago

What does break; do inside a do-expression inside a loop? @ljharb and I had opposite intuitions, both of which seem reasonable (to me). (See issue #22.)

The proposal says,

Tennant's Correspondence Principle

[...]

  • (do { <stmt> };) equivalent to { <stmt> }

I take this to mean that (do { break; }); is equivalent to break; and therefore do-expressions are invisible to break. (To me, the use of the phrase “Tennant's Correspondence Principle” indicates that this is intentional; maybe @dherman can confirm or deny?)

loganfsmyth commented 6 years ago

This is definitely a tough one. I'll just say my expectation would be that break breaks out of the while, not the do. Assuming we'd want consistent semantics to allow continue, I feel like I could easily see myself wanting to do

while (getThing()) {
  items.push(do {
    const value = getItem();
    if (!value) continue;
    value;
  });
}

or something along those lines to bail out part-way through some calculation the same way I'd assume a return; there would also bail out.

ljharb commented 6 years ago

break to me breaks out of the curly braces. I would expect both break and continue to exit the do, especially since return doesn't.

loganfsmyth commented 6 years ago

Interesting, I don't think I've ever associated it with braces, but rather with loops and switch specifically.

You can just as easily do

while (true) {
  while (true) break;
}

which breaks the inner loop even though it doesn't have braces

or

while (true) {
  if (true) { break; }
}

which breaks the loop, not the if even through it has braces.

The only place where unlabelled break isn't tied to a loop is switch which I generally see as a special case.

I guess at the end of the day you could always do

while (true) {
  var foo = do { label: {
      4;
      break label;
  };};
}

if you wanted to break out of the do expression 😬 depending on wherever the spec settles...

ljharb commented 6 years ago

sure, but do can't omit the braces; i guess i'd say, it should break/continue out of the closest context/construct?

loganfsmyth commented 6 years ago

The braces don't mean anything to me essentially, so the definition of "closest construct" depends on what things you think are important constructs. It seems like the only viable rule is "the one we think people will use the most". switches are essentially grandfathered in since break is critical for their functionality, but loops are common, and being able to break out of them with minimal syntax is important. So the question is, is being able to break out of a loop with minimal syntax (since most people don't know what labels are, or that they exist in JS) more important than being able to easily break out of the current do expression.

I personally feel like I struggle to think of cases where I'd want to break out of a do expression on its own, so the conversation may also be easier if there were examples.

pitaj commented 6 years ago

If you try to use break; outside loops or switch statements, it results in

SyntaxError: unlabeled break must be inside loop or switch

Here's an example:

{
  console.log(1);
  break;
  console.log(2);
}
claudepache commented 6 years ago

@ljharb

break to me breaks out of the curly braces.

If you really think so, you’ll have trouble to understand that break is illegal in the following case:

if (true) {
     break;
}

and even that it breaks the infinite loop in the following case:

while (true) {
    break;
}

because break must act on the while-statement, not just on the block it controls.

In general, unlabelled break acts on so-called breakable statements (for/while/do-while/switch), which are mostly loop statements, and labelled break acts on labelled statements.

The question is: Will the do-expression be specced as a breakable expression or not? Since it is not a loop expression, the answer is a priori no, unless we find a good reason for it.

ljharb commented 6 years ago

@claudepache that confusion suggests that break/continue should perhaps be illegal directly inside a do expression, since conceptually they're like inline IIFEs.

loganfsmyth commented 6 years ago

conceptually they're like inline IIFEs.

I feel like that's an assertion based on your mental model, but not reflected anywhere in this spec. It seems like a very limiting way to think of them and I'm not clear on how we'd benefit from promoting that model.

I conceptually think of them like they are in Rust. Rather than an IIFE, I envision them as just a sequence of statements like they would be anywhere else, which seems more in line with how this proposal is currently presented, meaning that break and continue and return should work just like they would in any normal statement context, just like throw they create an abrupt completion record that propagates upward skipping execution of later statements until they hit a block that has special behavior defined for abrupt completions, like loops and switches.

domenic commented 6 years ago

Indeed, if this whole feature is just some sort of IIFE, then it's pointless. Instead it's a generic way to turn statements into expressions, which is much more interesting and powerful.

allenwb commented 6 years ago

Rather than an IIFE, I envision them as just a sequence of statements like they would be anywhere else, which seems more in line with how this proposal is currently presented, meaning that break and continue and return should work just like they would in any normal statement context, just like throw they create an abrupt completion record that propagates upward skipping execution of later statements until they hit a block that has special behavior defined for abrupt completions, like loops and switches.

At one time I had all the mechanisms in the ES6 spec. to handle do expression exactly this way. Took them out when it was clear that do expression weren't going to get in.

The only real question was situations like this:

for (;; do {if(cond) break}) body;

and

for (;; do {if(cond) contine)) body;

(note no labels) Seems reasonable that break would break out of the for loop. But what about continue? I speculatively made continue in for headers act like a break but that is certainly debatable.

loganfsmyth commented 6 years ago

That's a fun one. I'd probably have assumed that continue there would just cause an infinite loop, then leave it to linters and/or common sense to make people not put them there.

So to put it in spec text terms, my expectation for break is

DoExpression: do { StatementList }

  1. Let stmtCompletion be the result of evaluating StatementList.
  2. Return Completion(UpdateEmpty(stmtCompletion, undefined)).

rather than special-casing break to result in a normal completion:

DoExpression: do { StatementList }

  1. Let stmtCompletion be the result of evaluating StatementList.
  2. If stmtCompletion.[[Type]] is break, return NormalCompletion(undefined).
  3. If stmtCompletion.[[Type]] is continue, return (an error probably?).
  4. Return stmtCompletion.
allenwb commented 6 years ago

My expectation is that:

//statements
{ /* StatementList */ }
//statements

evaluates identically to:

//statements
do { /* StatementList */ } ;
//statements

So the grammar production for DoExpression would be:

DoExpression : do Block

(ignoring grammar parameters for now)

and the evaluation semantics for it would be:

  1. Return the result of evaluating Block.

The responsibility of dealing with abrupt completions (particularly continue completions) falls on the semantics of the iteration statements that have some sort of continue semantics, not on the statement/expression that generates the continue.

In the current language, there is no way for a continue abrupt completion to occurs in the head of a for statement so the semantics doesn't need to deal with that possibility. The addition of do statements (with full Tennant's correspondence with Block) means that iteration statements have to deal with unlabeled break and continue in all evaluative positions. The handling could be as simple as throwing an exception. It can't just be ignored (with the current factoring of the specification) because there is currently no mechanism to convert unhandled break/continue abrupt completions into exceptions.

loganfsmyth commented 6 years ago

@allenwb That's a fair point. I probably don't actually have the UpdateEmpty call in the optimal place in my example. If we go the route of

Return the result of evaluating Block.

I'd imagine the spec would need to expand usage of UpdateEmpty to include each expression's runtime semantics too, is that your expectation as well? It seems like we would have to in order to keep things semantically similar to the behavior of statements. For example, with no changes to expression runtime semantics

expressionStatement;
expressionStatement;
do { break ; }

behaves differently from

expressionStatement;
(expressionStatement, do { break ; });

which, to me at least, seem than idea, so it would require the addition of UpdateEmpty calls in the runtime semantics of the comma operator.

Similarly

var result = do {
  while(true) {
    "prefix";
    if (foo) 4;
    else break;
  }
};
result === 4 or undefined

but

var result = do {
  while(true) {
    "prefix";
    foo ? 4 : do { break };
  }
};
result === 4 or "prefix"

Note undefined vs prefix. So we'd need additional UpdateEmpty usage in the ConditionalExpression runtime semantics.

And really this would end up applying to all expression types, since it's equally an issue for

var result = do {
  while(true) {
    "prefix";
    4 + do {break};
  }
};

I'm assuming we'd want a completion value of undefined here instead of "prefix" because logically "prefix" is no longer the most recently executed item.

Hopefully I'm not misunderstanding somewhere.

allenwb commented 6 years ago

see https://github.com/tc39/proposal-do-expressions/issues/21#issuecomment-359878642

allenwb commented 6 years ago

I'm sure that we need to have Tennant's correspondence between:

expressionStatement;
expressionStatement;
do { break ;};

and

expressionStatement;
expressionStatement;
{ break ; }

But it it's at all obvious that we need or want it for:

expressionStatement;
(expressionStatement, do { break ; });

That latter isn't a direct Tennant's correspondence situation as a additional operator (,) has been added. As I mentioned in the other thread, we need to think carefully about whether we want completion value propagation to generally apply between subexpressions. My initial think is that it isn't needed/desirable.

jorendorff commented 6 years ago

I agree. On the whole, it would be nice to make (do { S1 }, do { S2 }) equivalent to (do { S1 S2 }), but it is definitely not "Tennant's correspondence". In any case that is the topic of issue #22.

In any case, the question in this issue has been answered. (do { break; }); equals break;.

To close the bug we just need a PR to clarify the proposal.

hax commented 5 years ago

I read the whole thread and agree (do { break; }); should equals break;. . But I also suspect maybe we should make do { break; } syntax error just like current eval('break;') if there is no strong use cases.

The only near-real use case in this thread is

while (getThing()) {
  items.push(do {
    const value = getItem();
    if (!value) continue;
    value;
  });
}

But I feel this example just show the dark side of allowing direct continue/break in the do block. It's not easy to recognize there is a continue statement inside and actually dismiss the push operation. As a code reviewer, I would prefer the plain code like

while (getThing()) {
  const value = getItem();
  if (value) items.push(value);
}

Much simpler, shorter and clearer.

Hope someone can give some good use cases.