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

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

Open
jorendorff opened this issue Jan 18, 2018 · 34 comments
Open

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

jorendorff opened this issue Jan 18, 2018 · 34 comments

Comments

@jorendorff
Copy link

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
Copy link
Member

ljharb commented Jan 18, 2018

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

@jorendorff
Copy link
Author

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
Copy link

pitaj commented Jan 18, 2018

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
Copy link
Author

jorendorff commented Jan 18, 2018

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
Copy link

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
Copy link

pitaj commented Jan 20, 2018

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

@jorendorff
Copy link
Author

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
Copy link
Member

allenwb commented Jan 22, 2018

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
Copy link
Author

jorendorff commented Jan 22, 2018

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
Copy link
Member

allenwb commented Jan 22, 2018

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
Copy link
Author

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
Copy link

pitaj commented Jan 23, 2018

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

@allenwb
Copy link
Member

allenwb commented Jan 23, 2018

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
Copy link

pitaj commented Jan 23, 2018

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

1; condition ? do { break; } : do { 4; }

@allenwb
Copy link
Member

allenwb commented Jan 23, 2018

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
Copy link
Member

allenwb commented Jan 23, 2018

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
Copy link
Author

jorendorff commented Jan 23, 2018

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
Copy link
Author

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
Copy link
Member

allenwb commented Jan 23, 2018

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:
[[Type]: break, [[Value]]: undefined
[[Type]: Normal, [[Value]]: undefined

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
Copy link
Author

jorendorff commented Jan 23, 2018

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
Copy link
Member

allenwb commented Feb 1, 2018

So, the if behavior was a ES2016 breaking change tc39/ecma262#1085 (comment)

@jorendorff
Copy link
Author

jorendorff commented Feb 6, 2018

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
Copy link
Author

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

  • It's easy to see statically which statement values matter and which don't:

    • When a nonempty Block or StatementList terminates normally, its value is always the value of the last nonempty statement in it.

    • The values produced by all the other statements, when they terminate normally, never matter.

  • The value of a loop that terminates normally is always the value of the last statement inside the loop, or undefined if the last pass through the loop exited with break or continue.

  • The value of any Statement or LabeledStatement that is terminated normally by break-ing out of it is always undefined.

@jorendorff
Copy link
Author

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
Copy link
Collaborator

bakkot commented Feb 6, 2018

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
Copy link

eloytoro commented Mar 4, 2018

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
Copy link
Member

ljharb commented Mar 4, 2018

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
Copy link
Collaborator

bakkot commented Mar 5, 2018

@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
Copy link
Member

ljharb commented Mar 5, 2018

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

@eloytoro
Copy link

eloytoro commented Mar 5, 2018

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
Copy link
Member

ljharb commented Mar 5, 2018

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

@linonetwo
Copy link

@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
Copy link

eloytoro commented Apr 29, 2018 via email

@bakkot
Copy link
Collaborator

bakkot commented May 21, 2020

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants