tc39 / proposal-do-expressions

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

This requires JS programmers to learn a bunch of trivia #21

Open jorendorff opened 6 years ago

jorendorff commented 6 years ago

As it stands, you can be a very good JS programmer and not even know that statements produce values.

If do expressions are added, programmers will have to understand the rules in some detail:

let name = do {
  for (const book of books) {
    if (isRecommended(book)) {
      book.title;
      break;
    }
  }
};

In the spec, the way these statements produce a value is all faux-functional, but to programmers, that book.title is implicitly stored in a nameless variable, and implicitly read out of it later. That's spooky—and while it's true eval already has all these rules baked into it today, in practice nobody has to know.

ljharb commented 6 years ago

The developer console has the same semantics, and I’d argue that most programmers do know/learn that in practice.

jorendorff commented 6 years ago

Surely you're not arguing that programmers already know that book.title; break; is a useful thing that you can write in a loop, because of their experience with the developer console.

pitaj commented 6 years ago

The completion values of loops have been discussed elsewhere. The completion value of behavior of every other statement with the exception of function or class declarations are, in my opinion, very intuitive.

jorendorff commented 6 years ago

Well, maybe we just disagree, but I was born without any real intuition for whether eval("f(); var x = g();") should return the value of f() or the value of g(). (It's f().) Or whether break should propagate the "last value" or set the loop's value to undefined (for break in particular, it's the former, but note that for exceptions it's the latter: a catch block effectively resets the "last value" to undefined before it runs). Or how ExpressionStatements in finally blocks should affect the value of a try/finally (usually they don't, but if the finally block exits with a break or continue, then they do).

All that stuff seems pretty arbitrary, to me. Maybe in practice it just won't come up. I agree it's loops and declarations that will actually surprise people the most often.

claudepache commented 6 years ago

It would be clearer if do-expressions had an explicit syntax to mark the produced value, à la return and yield:

let name = do {
  for (const book of books) {
    if (isRecommended(book)) {
      use book.title;
    }
  }
};
pitaj commented 6 years ago

@claudepache that misses the point of do expressions almost entirely.

jorendorff commented 6 years ago

Here's a fun case. Empty Blocks usually don't affect the value:

eval("3; {} {} {}");  // returns 3

But if you "comment one out" using if (false), then it does:

eval("3; {} {} if (false) {}");  // returns undefined

I love it: an empty statement has no side effects, thank goodness—unless you make it not execute...

allenwb commented 6 years ago

eval("3; {} {} if (false) {}"); // returns undefined I love it: an empty statement has no side effects, thank goodness—unless you make it not execute...

It isn't the not executed block that has a side-effect (the completion value) it is the if statement that has a side-effect (completion value)

This is part of ES6 "completion reform" championed originally by @dherman. Essentially completion reform was intended to make completion values easier to understand/analyze by ensuring that all statement list elements either always produce a normal completion value or never produce a normal completion value I(they just propagate the previous completion value).

Previously 0; if (cond) 1; would sometime produce a new completion value (1) and sometimes propagate the previous completion value (0). There was no way to statically determine which it would be.

jorendorff commented 6 years ago

Thanks for the background, Allen.

As long as [[Type]]: break, [[Value]]: empty completion values are combined with the results of previous statements, the determination isn't exactly static, though. It's static in some places and dynamic in others. :-\

Edit: I don't mean to argue with anyone or blame anyone by saying that. Just a bit stuck for how to proceed from here...

allenwb commented 6 years ago

Well break produces [[Type]]: break, [[Value]]: empty but StatementList propagates non-empty completion values into any break completion records emitted by the StatementList and that completion record would just pass through a containing Block. Any surrounding StatementList would then propagate its current non-empty completion value into that break completion record in the same way.

jorendorff commented 6 years ago

You're right, it's more static than I thought.

Is the idea that in {1; 2 && do { break; }} the second ExpressionStatement would complete with [[Value]]: undefined rather than [[Value]]: empty? (That is the kind of situation I had in mind.)

pitaj commented 6 years ago

That's the behavior I'd expect from that particular code.

allenwb commented 6 years ago

For {1; 2 && do { break; }} the second ExpressionStatement's completion value would be [[Value]]: empty because expressions do not propagate completion values among sub-expressions. The completion value for the block would be [[Type]]: break, [[Value]]: 1 because the completion value of the first statement has propagated into the empty-valued break completion of the second statement.

So: {1; 2 && do { break; }} is equivalent to (1; {break}} which is equivalent to {1; break}

pitaj commented 6 years ago

So value && empty == empty? Does that apply to every operator? Like what if I had

1; condition ? do { break; } : do { 4; }
allenwb commented 6 years ago

So: {1; 2 && do { break; }} is equivalent to (1; {break}} which is equivalent to {1; break}

Perhaps a better equivalence:

 {1; 2 && do { break; }}

is equivalent to:

{1;
if (2) {break}
}

is equivalent to:

{1;
if (2) break
}
allenwb commented 6 years ago

So value && empty == empty? Does that apply to every operator?

This is where some new design is required. Currently (I believe) there is no way for a subexpression to evaluate to [[Type]]: normal, [[Value]]: empty so this isn't a situation the spec. currently deals with. But with do expressions we have the possibility of things like (1 + do {}).

The do expression do {} needs to evaluate to [[Type]]: normal, [[Value]]: empty so it has Tennant's correspondence with { } when used in a statement context.

So, here is how we might achieve this.

Let's assume for now that subexpressions that evaluate to [[Type]]: normal, [[Value]]: empty should be consider to have the value undefined by expression operators (this could be separately debated). Well, expressions that need ECMAScript values always call the abstract operation GetValue on the result of evaluating subexpressions. So, we could update GetValue so that it returns undefined if passed a completion [[Type]]: normal, [[Value]]: empty.

But, we would also need to look carefully at all the uses of GetValue (and there are a lot) to find any cases where we wouldn't want to do that conversion. There is one that immediately comes to mind: ExpressionStatement evaluation:

  1. Let exprRef be the result of evaluating Expression.
  2. Return ? GetValue(exprRef).

To maintain Tennant's correspondence with Block it would need to change to:

  1. Let exprRef be the result of evaluating Expression.
  2. Let exprComp be Completion(exprRef).
  3. If exprComp.[[Type]] is normal and exprComp.[[Value]] is empty, return exprComp,
  4. Return ? GetValue(exprRef).
jorendorff commented 6 years ago

Just to clarify: it seems like in this comment you're changing your mind, saying that the result should be undefined, not 1. Right?

If so, I think I agree this is the only design consistent with completion value reform. Without this, we're right back in the situation described here:

Previously 0; if (cond) 1; would sometime produce a new completion value (1) and sometimes propagate the previous completion value (0). There was no way to statically determine which it would be.

because there would be cases like 0; (cond ? 1 : do{});.

jorendorff commented 6 years ago

Heh! The very first instance of GetValue() in the spec is in array initializers. I assume we want [0, 1, do{}, 3] to have an element 2 with the value undefined, not a hole...

Design decisions around every corner!

allenwb commented 6 years ago

Interesting, referring back to the spec. I see that the behavior of if seems to have changed between ES2015 and the current ES2018 draft:

In 2015:

{ 1;
if (true) break;
}

produces the completion value for the block: [[Type]: break, [[Value]]: 1

and

{ 1;
if (true) ;
}

produces the completion value for the block: [[Type]: Normal, [[Value]]: undefined.

In the ES2018 draft, they respectively produce:

The latter is consistent with what Jason is saying. However, the algorithm conventions for manipulating completion results changed between those versions of the spec. It isn't obvious to me that the semantic change was intentional or a bug introduced when rewriting using the new conventions. It probably should be researched to see if the 2015 behavior had been identified as a bug and the change was an intentional fix.

The bigger question is what (if anything) was the intent of "completion reform" for cases like this. It's pretty clear that a goal of completion reform was that during linear progress, each statement either always or never produces a new completion value. But abrupt completions are really a separate dimensions of completion value propagation, so it isn't obvious that the always/never rule should apply to it.

In seems strange to me that:

{ 1;
break;
}

would have a completion value of [[Type]: break, [[Value]]: 1 But,

{ 1;
if (true) break;
}

has a completion value of [[Type]: break, [[Value]]: undefined

jorendorff commented 6 years ago

I agree!

But consider:

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

In the current draft, the value of this block is undefined either way.

What did it do in ES2015? I think it would be 1 if cond is true, and undefined if cond is false. This seems strange.

So both semantics have some strange cases. I think if I could pin down what we mean by "static" here, I would understand better where the strangeness is coming from.

allenwb commented 6 years ago

So, the if behavior was a ES2016 breaking change https://github.com/tc39/ecma262/issues/1085#issuecomment-362347581

jorendorff commented 6 years ago

I think pretty much all of the trivia goes away if we change abrupt break and continue completion so that they always have [[Value]]: undefined.

jorendorff commented 6 years ago

That would of course be a breaking change. It would make the rules a snap to reason about, though:

jorendorff commented 6 years ago

Oh, I guess actually getting the above rules would involve changing var and let to produce undefined as well. Further breakage. Likely too much... I guess we could try it.

bakkot commented 6 years ago

Strictly speaking, we're not required to use the same completion value semantics that eval uses. The argument in favor is simplicity of mental model, but to be honest I'm not too concerned with the simplicity of the mental model of people who are using eval to get completion values. (I'm also not totally sure that any such people exist.)

eloytoro commented 6 years ago

Another fun example of how evaluating to the last expression will generate a lot of confussion

const result = do {
  switch (val) {
    case 'foo': 1;
    case 'bar': 2;
  }
}

What does result eval to?

ljharb commented 6 years ago

Intuitively, the only options are either “always undefined”, or “1, 2, or undefined, based on val” imo - and you could verify it by sticking the body of the do expression in a repl or in eval.

What confusion am i missing?

bakkot commented 6 years ago

@ljharb, I assume the point is that there's no break, so it can never be 1. I agree this seems like something which would be a common error.

ljharb commented 6 years ago

Aha, fair - although that’s a hazard of switch statements themselves, and not something new for do expressions.

eloytoro commented 6 years ago

Intuitively, the only options are either “always undefined”, or “1, 2, or undefined, based on val” imo - and you could verify it by sticking the body of the do expression in a repl or in eval.

What do you mean by intuitive? The idea of having to insert code into a repl in order to know what it does seems like a pretty bad experience as a developer.

Aha, fair - although that’s a hazard of switch statements themselves, and not something new for do expressions.

This is not a hazard for switch statements, it just shows a limitation in the proposal, being unable to resolve to a given expression without any ambiguity leads to a number of possible mistakes, take for instance the extremely failed with javascript keyword, deprecated because of its immense ambiguity.

ljharb commented 6 years ago

There isn’t any ambiguity with switch; it’s just not intuitive, because switch itself in JS is an unintuitive construct.

linonetwo commented 6 years ago

@eloytoro We have eslint, and this kind of code will be warned.

For me, do expression helps me to build template engine that non-coder can understand and use. Non-coder may be difficult to understand let and const, but they can easily understand "oh value inside { } is just what I will get"

eloytoro commented 6 years ago

Eslint Is not part of the spec, it can't be used as an argument for negligence

On Fri, Apr 27, 2018, 8:34 AM lin onetwo notifications@github.com wrote:

@eloytoro https://github.com/eloytoro We have eslint, and this kind of code will be warned.

For me, do expression helps me to build template engine that non-coder can understand and use. Non-coder may be difficult to understand let and const, but they can easily understand "oh value inside { } is just what I will get"

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/tc39/proposal-do-expressions/issues/21#issuecomment-384971505, or mute the thread https://github.com/notifications/unsubscribe-auth/ADwSBX5yJ8OA9hR5YGC_fAnMFHrNB_YSks5tsx5-gaJpZM4RjL_Y .

bakkot commented 4 years ago

One possible solution for this is to syntactically require that the last statement in the do block be an expression.

Or, at least, not a loop or declaration.