Open kkshinkai opened 5 years ago
second syntax definitely causes ambiguity:
let y = 1;
let flag = {
x: y // k: v
}
let flag = {
label: y; // statement with label
}
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"
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
.
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.
@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.
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.
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;
In other words, my overall response is “the explicit do
both avoids confusion and keeps the rules simple”
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).
Thus, not the most consistent way.
Right, I was speaking within the confines of "without using do".
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
}
//
Assignment already returns something, and is an expression (although most style guides discourage using assignment as an expression).
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?
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
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.
I very much doubt it.
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):
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;
@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)
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.
and { a }
when const a = false
?
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.
As per @pitaj 's suggestion, { a }
would be an expression block. So it would evaluate to whatever a
is.
Then it is a breaking change. Currently it evaluates to an object with an a
property.
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.
However, I think
if/else
can still be an expression without being inside ado
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
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);
@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
@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.
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);
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);
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?
That's because the second semicolon in your first example is unnecessary. In your first example you have 3 statements:
if
statementYou 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.
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?
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.
Shouldn't expression control structures without curly brackets only allow expressions as well?
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.
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:
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. else if
. You can encode it but it is messy and generally involves thinking carefully about precedence rules.try/catch
expressions. There isn't a ternary equivalent for try/catch
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.
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;
}
How would that interact with ASI and more statements in the do expression?
@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.
@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;
}
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
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).
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.
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:
...
...
f()
g()
} else {
h()
...
Later on, you realize this giant if-else was the last statement of a do block, and g()'s result was actually a completion value, it was not just a side-effect producing function.
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
.
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.
Statements in many Languages can return value directly, for example, Rust:
So why we need the keyword
do
?Even
{...}
is better thando {...}
These syntaxes will not cause ambiguity. Am I right?