tc39 / proposal-do-expressions

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

Implicit return is bad #72

Open 2A5F opened 3 years ago

2A5F commented 3 years ago

implicit return makes the source of the value untraceable
it’s difficult to find where to return
may even not return a b

const a = do {
  if (c1) { 
    ...many lines
    a
  } else {
    ...many lines
    b
  }
}

explicit return has clear semantics
also easy to search in the editor

const a = do {
  if (c1) { 
    ...many lines
    break do a
  } else {
    ...many lines
    break do b
  }
}
const a = label: do {
  while (true) { 
    break label do v
  }
}
theScottyJam commented 3 years ago

This is actually already being discussed in #55.

EdSaleh commented 3 years ago

https://github.com/tc39/proposal-do-expressions/issues/74

theScottyJam commented 2 years ago

@pitaj - I've occasionally seen you express your strong dislike for mandatory explicit completion values - you seem to be one of the most vocal about it, which is why I'm directing this comment towards you. In your comments, you briefly mention how implicit return is the reason you support this proposal, but I don't think I've ever seen you, or anyone else go into much detail as to why this is the case, and I'm honestly wanting to know more about this opposing viewpoint. So, I'm wondering if you would be willing to shed some light as to why you find implicit completion values to be so important.

For example, in your latest comment about it, you said the following:

[it] is a drastic reduction of functionality. It's a trade-off of implementation simplicity for increased verbosity and less flexibility.

(I completely agree with your underlying reasons for posting this comment, but I would also like to discuss some of it in this thread)

What functionality do you feel like we're losing via explicit completion values? What's less flexible about it? Is it really that much more verbose? I've struggled to come up with a good example where the verbosity was really all that overwhelming - I had tried to do so here but failed.

I mostly just hope to understand better where you're coming from with all of this. Thanks.


As for a bit of housekeeping, I had started a discussion about mandatory explicit completion values in this thread about optional implicit completion values. I think it might be better to move the idea of "mandatory" over to this thread instead, and let that other thread just focus on the pros and cons of an optional explicit completion values. Any feedback about how the syntax for the explicit completion values should look will probably be better suited for the other thread, so we don't have two duplicate discussions on that point.

getify commented 2 years ago

I understand the reasoning for splitting these topics into separate threads... however, I have to point out that I think the question of "optional vs mandatory" is not an independent topic to "what should the syntax look like", IMO. I may very well have a very different feeling on the syntax if it's going to be everywhere vs going to be optional or only used in certain cases.

So, I think eventually we may have to merge these conversation threads back together.

theScottyJam commented 2 years ago

I guess I just felt bad for hijacking that thread with a thought that wasn't part of the original idea. But, I probably should have just left the conversation there, as there's a lot of overlap.

Ah well, we'll run with the split.

pitaj commented 2 years ago

@theScottyJam it's taken me a while to get to this.

What functionality do you feel like we're losing via explicit completion values?

Using Rust, I fell in love with statements-as-expressions. While do-expressions aren't exactly the same, they're the closest thing JS will likely ever get. While semantically different, they are aesthetically similar and allow for that "mental mode" of programming.

For instance, in Rust, when I see let y = if x { 12 } else { 54 }; this reads as "y is either 12 or 54, depending on x". Likewise, let y = do { if (x) { 12 } else { 54 } }; reads the same way, same as a ternary.

Once you start introducing keywords, this interrupts how my brain interprets the code. Instead of choosing between different values, let y = do { if (x) { give 12; } else { give 54; } }; reads as "if x is true, return 12, otherwise return 54, and store that in x".

In essence: the literal verbosity introduced by those extra words being on screen maps to cognitive verbosity in my mental model of the code. The impact of this decreases as the complexity within the do-expression increases, but the example I've been using is not contrived. For instance, this would have a huge impact on match, which is supposed to use the semantics for the right-hand side:

const val = match (count) {
   when 0 { "none" };
   when 1 { "one" };
   when 2 { "a couple" };
   when ^(count <= 5) { "a few" };
   else { "many" };
}

Is it really that much more verbose?

Subjectively, yes. A lot of it really comes down to this, and there may be no way to come to a consensus here.


@getify while the conversations may need to be moved back together eventually, I think it's useful to keep the bikeshedding over syntax and keywords partitioned from the conversation here as much as possible.

theScottyJam commented 2 years ago

Thanks @pitaj for your insights.

So, here are some of my thoughts around this.

Your argument about readability of do { if (x) { 12 } else { 54 } } vs do { if (x) { give 12; } else { give 54; } } is certainly a valid one. In the former, the "12" and "54" are simply expressions that you've pieced together. In the latter, "give 12" and "give 54" are explicit instructions, they're statements, and makes the code feel more like a list of instructions rather than a single expression.

So, I can concede on that point and agree with you there.

However, I do feel the value of implicit-give is tied to a very limited scope of use cases, which I'll like to quickly explore. First of all, the "implicit give is nice, because it keeps code expression-oriented" idea quickly breaks down when you put, even a touch of procedural code into the do expression (which, you did acknowledge that your idea breaks down as complexity increases). The moment you place a statement within the if or else block, you've turned what was, conceptually, a single expression tree into a procedural step-by-step list of instructions. For example, In the following scenario, I don't feel the implicit "give" is providing any value (unless you disagree here? If so, feel free to elaborate why.)

// Without "give"
return do {
  if (x) {
    const y = f(12)
    format(y)
  } else {
    format(54)
  }
}

// With "give"
return do {
  if (x) {
    const y = f(12)
    give format(y)
  } else {
    give format(54)
  }
}

(Forgive the contriveness of this example, I realize it's dead simple to just nest the function calls to create a single expression tree)

Both of these code snippets require procedural, step-by-step logic. The explicit "give" doesn't pull us out of "expression-only" land and into "procedural" land anymore, because we're already in procedural land. For the omission of a completion-value marker to provide any value, we would have to ensure our do block contains a single expression tree (A statement at the end of an implicit-give do block is effectively an expression in my book, so when I talk about expressions, you can mentally lump those in as well).

Ok, so we've (arguably) established that implicit-give will only provide value when the do-block contains a single expression tree. It would be stating-the-obvious to also mention that, in this scenario, we must be using at least one statement within the do-block, otherwise, why would we use the do block at all? (the exception is the pattern-matching scenario you brought up, which I'll discuss lower down). The four non-side-effecty statements that functional-minded people may wish to turn into expressions are as follows:

In other words, an implicit-give is really only providing value for two specific scenarios - turning if-else and try-catch into an expression.

Here, again, I'll concede and say that I would really love to have an expression version of if-else - it just reads nicer than nested ternaries. An expression try/catch would be nice as well, but I wouldn't be too bummed out if we don't get one, as I don't use them often enough for it to matter too much to me.

I don't know if you agree with everything I've said thus far or not, but if you do, this means we'll be left with the following decision:

  1. Implement do-blocks with implicit-give
  2. Implement do-blocks with explicit-give, and find some other solution to solve the expression-if (and maybe expression-try-catch). I know a number of solutions have been proposed on this repo, I hope something will come through.
  3. Implement do-blocks with explicit-give, and don't provide a solution for expression-if. (I know you're adamantly against this, and I would certainly be bummed out by this as well).

The downside to option 2 is the fact that we have to add an additional feature to the language, while option 1 lets us roll this idea into do-expressions (at the cost of making do expressions a little more complicated - I won't enumerate the reasons why, as that's been extensively discussed in the other thread). I'm not a fan of the complexities that implicit-give adds to do-blocks, which is why I root for option 2. On the other hand, you seem to be much less worried about those complexities, probably to the point that the complexities of a new language feature (option 2) exceeds, for you, the complexities of implicit-give.

Do you agree with this assessment? Disagree? Partially agree?


Finally, I want to briefly mention this pattern-matching thing, which could cause people to use do-blocks that contain a single expression-tree without any statements, like you showed in your patter-match example. It is certainly true that your pattern-match example is nice and concise, and an explicit "give" in each of those blocks would unnecessarily make it feel a lot more procedural-like. However, it's still an open question whether or not plain expressions would be allowed on the RHS of a match. If you are allowed to provide simple expressions (by omitting the curly brackets), then there wouldn't be a need to have expressions without statements in a do block.

const val = match (count) {
   when (0) "none";
   when (1) "one";
   when (2) "a couple";
   when (^(count <= 5)) "a few";
   else "many";
}

If do-blocks decided to go after an explicit-give, I would half-expect pattern-matching to then make sure their syntax provides a way to have simple expressions on the RHS.

Update: It looks like the pattern-matching have been closing in on the desired syntax and semantics for that proposal. They've now ruled out a number of syntax options from that github issue I linked to earlier. The remaining options all have a way to put a bare expression on the RHS of a pattern-match arm. In other words, pattern matching will not be a reason for someone to put a bare expression-and-no-statements in a do block anymore.

getify commented 2 years ago

@theScottyJam I don't see any consideration for the option of optional give... a compromise such that the expressions where it's helpful (or just more readable) keep the keyword and where the most concise ones don't.

I liken that to how arrow functions can have full blocks or concise expressions, depending on the needs/style preferences of the author. Sometimes the curly braces and return keyword are helpful, many other times, they're skipped.

Optional give would let most usages skip it (if desired), but in certain cases (like returning function expressions) would actually be syntactically advantageous. The subjective areas of usage/not, thus, are handled by linters instead of being baked into the language.

ljharb commented 2 years ago

Forcing linters to deal with multiple ways to do something is a high cost, and arrow functions’ issues with concisely returning an object literal are a great example of a pattern to avoid following. If the arguments for an explicit keyword are convincing, it should be required; if not, it should be prohibited.

theScottyJam commented 2 years ago

@getify - thanks, I did (embarrassingly) forget about that option. We can call that "potential solution #4", and put it on the table of possible options to compare and contrast.

getify commented 2 years ago

@ljharb you say "forcing", I say "allowing".

From where I sit JS has become a nanny language in multiple ways that bother me. It enforces things it doesn't have to, and instead should have allowed more choice and left opt-in adherence up to linters.

Linters are for applying opinions on the "right" way to write some piece of code. The language itself should be more neutral and leave the opinion enforcement up to configurable tooling.

Confoundingly, many devs don't want choice, so they opt for linter/formatting tools that aren't configurable. That's totally fine.. for them. But when the language asserts an opinion on what's "good" or "bad" code, then folks like me are left with fewer options.

An example I despise (and yes I know the reasons why, I still disagree) is not allowing a second let declaration of a variable in a block to just be skipped/ignored.

I bristle when I hear members of TC39 justifying language design choices based on how they think JS devs should write code. Just give us a powerful language with options for how we apply it (readability and maintainability wise).

getify commented 2 years ago

We clearly have cases where:

  1. An explicit give would be both syntactically useful as well as aesthetically more readable, such as returning a function expression.

  2. An optional give might be more readable when the do block has multiple imperative statements, and the final identifier return expression might be visually a bit occluded but give can make it plainly obvious. Consider this situation somewhat similar to arrow bodies with curly braces and return... The explicit keyword in there is quite helpful for readability.

  3. Similar to (2), there may be subjective cases, like returning a simple literal (number, string) where someone might feel the code looks strange or a mistake to just have a literal hanging out on its own line (never before in JS has that been useful, though it's syntactically allowed).

  4. And then there are the cases (like if..else) where quite arguably the give would be nice to skip. TBH, I think specifically a terse if..else like that would be better as a match with concise case bodies -- I'm pretty sure they've already said in that proposal that concise cases will be included and they won't be do expressions, so no give to clutter them up. However, I do think there will be cases where the concise non-give do expressions would be strongly preferable.

So, I think we have reasonable justifications for give and not-give. JS could thus:

  1. Pick one side or the other, choosing to ignore/downplay the counter concerns. (bad IMO)

  2. Get really specific on the places where a give is required and not, like all the nuanced differences in arrow syntax (non-identifier params, etc etc) and just expect us all to learn them. (not good IMO)

  3. Compromise with an optional give and let JS devs be grown up enough to make the choices themselves (and let tools help them adhere to their choices).

I think option (3) is a reasonable outcome, at least one that should be seriously considered and not just dismissed outright immediately.

theScottyJam commented 2 years ago

@getify - Perhaps another way to look at the optional-give issue, is that it's only solving half of the issues with implicit-give. Yes, it'll provide a way to avoid ASI hazards (sort of), and yes, it'll provide a way to leave the do-block early, but it doesn't remove any of the overall complexity of learning how do-blocks work, because implicit-give is still there, and people will use it in all sorts of ways (because we've given them the freedom to do so). When reading/writting do-blocks, you still have to remember:

Perhaps, for some, it's not worth adding an optional-explicit-give feature, because the new give keyword doesn't carry enough weight to be worth existing, unless it also solves the above issues (i.e. you're forced to use the "give" keyword). And, I do agree, in the general case, that it's usually better to provide just one way to accomplish a task, rather than providing two, very similar ways - that's just extra language baggage that doesn't need to exist.

Don't get me wrong, I'd certainly prefer an optional-give over a non-existent give, but optional-give does have this downside that required-give does not.

ljharb commented 2 years ago

That you’re confounded by the fact that most devs don’t want choice doesn’t change that they do, and that the language should serve most devs - by not providing choice, and mandating one way to do things whenever it fits with existing language idioms.

Imo a powerful language is one that helps users solve problems. The more choice is allowed in style, the more bickering to distract from actually solving those problems. Not every delegate likely agrees with me here, of course.

2A5F commented 2 years ago

My opinion:

Just allow the statement to return the expression directly

const a = if (a) 1 else 2

block just simply block don't mix two semantics

The focus of block should be to allow multiple statements, not to detour to resolve statements that cannot return expressions

const r =  do {
    const a = 1
    const b = 2
    give a + b
}

More importantly, the current block syntax has fatal flaws

in rust, the block returns the final expression

let a = { if a { 1 } else { 2 } };
//             ^ return expr
//      ^ return expr x 2

but in this proposal, what the block returns is the side effect of the code running

let a = do { if (a) { 1 } else { 2 } }
//                  ^ not expr
//         ^ side effect
theScottyJam commented 2 years ago

@2A5F - that would be an idea being discussed here, and I'm probably rooting for that one the most (or some variation of it). That would be included in the "potential solution #2, find some other way to do if-else in an expression position".

@getify did also bring up a good point that pattern-matching can easily be used to provide if-else like logic in an expression position (though, it feels like a bit of an abuse of pattern-matching to exclusively use it for an if-else chain). This makes "option #3, force explicit "give" and do nothing else" a little more attractive because we'd still have another solution available to help out with the expression-if problem.

bradzacher commented 2 years ago

Personally the reason I currently dislike this proposal is the implicitness of the syntax - I don't think that it fits with the rest of the language today. This makes it inconsistent and harder to understand, IMO. If this pattern was more prevalent throughout the language for example if all statements could be used as expressions with implicit returns, then I'd be completely in favour of this! But currently it would be the only feature with it.

Looking at this from the perspective of someone who teaches a lot of new people JS - I can see a people getting very easily confused by this. Because it'll be the only place in the language that you have this implicit behaviour - it's not something that someone will "just get" (without prior experience with a language with this feature). This means that you will have to explicitly teach someone "this is a do expression and the last expression in the control-flow branch is implicitly returned".

Allowing return to return from the parent context is another teaching hurdle to overcome as well. I can see a lot of bugs being introduced into people's code because of this feature - "Don't use return here - it doesn't return from your do-expression, it returns from the parent function". I can see a lot of people accidentally writing return due to muscle-memory and breaking their code because of it.

An optional explicit return by way of a new keyword doesn't really help with the last issue - if anything it makes it worse because people will likely just use the wrong keyword and get confused which keyword to use. However it does at least give people the option of being explicit if they prefer - which is similar to other language features (bodyless arrows, block-less if/while/etc statements).

Linters could help with some of the issues here:

But to me it does feel weird proposing a feature into the languge with the expectation that linting will make the feature better - it just feels really "smelly" to me.

As an aside - typecheckers (TS, flow) will make this syntax much less error-prone in many cases because they automatically flag unreachable code, and they will error with type-mismatches caused by incorrect returns. However this is a feature for pure-JS - so we shouldn't be relying on typecheckers to make it usable.

ljharb commented 2 years ago

@bradzacher eval has this feature, fwiw.

bradzacher commented 2 years ago

has this feature

Sorry - which feature are you does this refer to, specifically?

getify commented 2 years ago

@bradzacher you assert that designing a feature with linters in mind is "smelly" design. I disagree, but I understand where that's coming from. I agree with you in that I similarly dislike when features are designed where they're only sensible/reasonable if TS is in play, for example.

Why I disagree, and feel that linters can be a valid consideration in the design process, is: I look at linters not as "making a feature useful or readable", but allowing adaption in competing styles/points of view.

For example, many people really like having a linter rule to disallow any usage of var. That's fine for them. I however vehemently disagree, and prefer the option to use var when I want its behavior. I know many members of TC39 would prefer var to go away -- in fact, maybe even a super majority of them feel this way? -- but if they had just banned var in modules or classes or something like that, I feel it would have been a HUGE detriment to the language.

What they did was add let and const -- in my mind, these augment var rather than replace it -- and then leave it to user-land style guides and linters for people to choose what usages of each of those they want.

I think that's actually superior language design, to give devs powerful tools and let them mold it to their own needs.

As it relates to the ideas being discussed here: if TC39 made give optional (like they made let and const optional), there's plenty of room for teachers, bloggers, and linters to all define their own "best practices" for when give should be used (or not), and let different feelings and use-cases adapt.

I am a teacher of JS (and author) so I sympathize with larger surface areas of a language creating more to teach and more for learners to grok. But there's been plenty of precedent for such teachers/thought-leaders defining their own "good parts" subsets of the language and getting their followers to jump on the wagon. Optional give would be no different, IMO.

bradzacher commented 2 years ago

Sorry to clarify: If the choice is to make the keyword optional - so be it, as long as it's optional and not implicit-only! I'm more than happy to enforce via a linter that the keyword is there. Giving users the option isn't necessarily a bad thing (see semis, if/while/etc curlies, arrow function bodies and arrow function param parentheses).

My remark in regards to designing a feature with linters in mind is more to do with the suggestion to add a new keyword, give. That is the footgun which will require linter support to properly teach people and to validate code.

"Use give here! If you use return it returns from the parent context, not the do-expression, and will break your code."

That's the thing that's hard to teach. Teaching someone the syntax and make sure they don't break their code by using the wrong keyword accidentally. I can foresee a lot of cases where give vs return will subtly break people's code in ways that will be really hard to find without a linter. It's where I don't necessarily see the benefits of allowing break/continue/return within a do-expression, which is a related, but separate discussion (the readme doesn't explain why this is allowed, just that it is...)

ljharb commented 2 years ago

@bradzacher eval has the same implicit return semantics that do expressions are proposed to have - iow, it's not a new capability, it's just an underutilized one.

bradzacher commented 2 years ago

I have been looking through issues in this proposal and saw you mention it a few times.

I don't think eval is worth mentioning it here. People don't know or use eval for good reason (eg the security issues it can invite), and in the vast majority of codebases it will be completely banned from being used. I highly doubt that many people know the ins and outs of eval and what it supports.

For example, I just spoke to some folks who have read the language spec - they knew about the statement completion value, but did not know it was used in eval.

ljharb commented 2 years ago

Perhaps not - but I'm confident they've encountered it in the developer console in browsers, which also uses the same semantic.

theScottyJam commented 2 years ago

But, it's one thing for a REPL to inform you what the completion value of statement/expression is, and it's a very different thing to have to read code, that depends on your understanding of how completion values work. In the first case, if you don't really understand how it evaluates the completion value, there's really no harm in that. A REPL in any language will output the value of an expression, but I wonder how many people actually notice that JavaScript REPLs will also output a value when you place control structures in there - those values tend to be extra noise that we quickly learn to ignore.

So yes, there's a small degree of precedence for completion values, but the amount is so low, that it's sounding like people are even willing to make breaking changes to how completion values work, if they think different semantics would be more intuitive for do blocks. What we're doing here is turning completion values from a minor concept you might stumble upon in a couple of dusty corners of JavaScript, into something that's blessed with syntax, and that everyone must understand if they want to learn the main features of JavaScript.

I do believe that completion values are not that difficult to learn, but like @bradzacher said, there's really not much precedence for this in the language (at least, not compared to a language like Rust), and turning them into something you have to learn to understand the basic syntax of JavaScript doesn't seem worth it to me.

getify commented 2 years ago

Just wanted to mention that JS developers have widely embraced => arrow functions, which are the prime example of "implicit return". Yes, it's expressions rather than statement-completion-values, but... I think it partially demonstrates that JS developers really like "implicit return" semantics -- whether that's a good or bad thing. I don't think extending that to statements will be nearly as big a jump as we made in moving from function to concise => form.

bradzacher commented 2 years ago

I disagree that bodyless arrow functions are similar to the implicit return of the do expressions

An arrow function supports exactly one expression and thus one return. You cannot add if/while/etc statements, only an expression.

A do expression can theoretically have 0 or more returns and can have variable declarations, statements and non-implicit, unrelated returns.

They are very different things and conceptually they are very different things to learn and understand.

getify commented 2 years ago

@bradzacher not sure what you mean by "0 or more returns"...?

I don't think that's true at all. AFAICT, there's precisely one return from a do block, and IIUC it's the last expression or statement-completion-value in the block. That's like an extension of the concise => in that they only allow exactly one expression (which is returned), whereas do blocks are going to allow any number of preceding statements/expressions before the final return statement/expression.

bradzacher commented 2 years ago

By "0 or more returns" - I am specifically referring to return locations.

An bodyless arrow function houses a single expression. You are limited by what you can do in an expression location - but ultimately it is equivalent to exactly what can fit into the expression of a single return statement.

A do expression, OTOH, can have 0 (let x = do {}) to n return positions. Here's an example with 4 (valid according to the current babel plugin):

  let x = do {
    let y = 1;
    if (condition) {
      y = 2;
      y; // 1
    } else if (condition2) {
      while (y < 50) {
         y += 1; // 2 
      }
    } else {
      switch (condition3) {
        case 'a':
          3; // 3
          break;

        default:
          4; // 4
      }
    }
  }

This is where the complexity I'm talking about is. In order to parse and understand this code you have to really, really understand how the "implicit return" semantics work. For example - for most people I'm sure it wouldn't be clear that while (y < 50) { y += 1; } would return the value of y at the end of the loop, or that the break; after (3) does not void the fact that (3) is the statement completion value.

Having an explicit return keyword removes all confusion from such examples. No longer does anyone need to learn the bespoke logic around the statement completion value. They just need to understands plain old javascript.

bradzacher commented 2 years ago

Perhaps not - but I'm confident they've encountered it in the developer console in browsers, which also uses the same semantic.

@ljharb - you've used the same example on other issue threads. I was going to include it in my original comment, but I thought I'd keep it focused. Again, I don't think that you're entirely correct here for two reasons.

First - from what I've seen in teaching and working alongside colleagues (like @theScottyJam said) - people don't write super complex logic within the REPL. When I have seen people writing complex logic, they write plain old javascript, as if they were coding outside of a REPL. Put another way - they don't rely on implicit returns and instead will console.log or assign to a variable for printing later.

i.e. you don't see if (condition) { 1 } else { 2 } and instead see if (conditon) { console.log(1) } else { console.log(2) } or if (condition) { x = 1 } else { x = 2 } followed by a manual x

Second - again in the same vein as my earlier comment - I don't think you'll find many people that actively know that the browser console is based on eval and as such supports implicit returns. Definitely not to the depth that they write REPL code with it in mind, as per the first point.

From my experience - a large majority of the people actively working with JavaScript do not have such an in-depth understanding of the dark corners of the spec. I was curious if I was an outlier, and quizzed a number of 10+ year veterans of the language, and zero of them knew that eval allowed inline returns. One knew the term "statement completion value" from reading the JS spec, but did not know it was used in eval.

For what I will say is almost all users of JS by volume - this is going to be an entirely new concept and feature of the language.

EdSaleh commented 2 years ago

In my opinion, I think allowing Arrow Functions to be immediately invoked easily is better than creating any new keywords or syntax. => is already a function expression in JavaScript. => without preceding () or with preceding {} as mapped parameter. This way also allows chaining =>{}=>{}..... We can support Await using this way (we can make it translate to Await and Async arrow function by default), Yeild isn't supported and I don't think it's a good pattern to use inside 'do' block.

Example: let a = => if(true) return 1; else 0; We should allow functions to accept one statement in this syntax without {} block statement. Or let a = => { if(true) return 1; else 0; }

This approach is best in my opinion. Current proposal is also good as well and will add new good features to JavaScript.

ljharb commented 2 years ago

@bradzacher i fully concede that completion semantics in eval, and the REPL, are both going to be relatively unknown. However, I don't think that a new concept in the language is a bad thing, especially when it's one that's going to be so intuitive. The code you provided (with multiple possible completion points) isn't good code - so, people won't write it that way. It's very trivial, for example, to use a let, store the result, and have the last line of the do expression be the variable name.

ljharb commented 2 years ago

@EdSaleh using functions is a nonstarter, because many on the committee insist there be a way for one or more of await, yield, and return to operate on the containing function. Additionally, adding another stack frame is unacceptable. and, if it looks like a function, it must act like one, thus, it can't look like a function or act like one.

EdSaleh commented 2 years ago

I understand your view. But my idea here can allow multiple awaits and returns inside the function body. By translating => to await (async ()={/*multiple return and await here, but no yeild*/})(). The current proposal is also good and adds great features, but it's a big change to JavaScript, which is ok if got accepted by the committee.

theScottyJam commented 2 years ago

@EdSaleh - there's discussion around this idea in some other open threads that you can check out and contribute to, but they've all gotten stuck on the issues that @ljharb described. Here are a couple of relevant threads: #5, #46.

Here's one specific comment that addresses one minor flaw with your async-iife idea.

theScottyJam commented 2 years ago

@ljharb

The code you provided (with multiple possible completion points) isn't good code

I think this is one of the main reasons why some people want implicit completion values (at least, that's what I understood from @pitaj 's comment) - it's so that people can write an if-else chain, or a try-catch, and rely on completion values to make these flow-control constructs feel like expressions. You seem to be suggesting that people should not code like that, and such code should be considered "bad"?

e.g.

// bad
const result = do {
  if (condition) {
    1
  } else if (anotherCondition) {
    2
  } else {
    3
  }
}

// good
const result = do {
  let temp
  if (condition) {
    temp = 1
  } else if (anotherCondition) {
    temp = 2
  } else {
    temp = 3
  }
  temp
}

I can understand @pitaj's desire for wanting to use constructs like if-else as an expression, but if you think that's bad to do, then in your opinion, what's the value in having an implicit completion value?

EdSaleh commented 2 years ago

Hi Scot, thanks for reply. If making everything async is not plausible, we can only make it async if await => is used and non async if just => is used or maybe let compiler decide. Also, my expression is {}? => {}? ({}? means optional bracket block) which is different than ()=>{}? for arrow function. Thanks for the discussion, these are just my ideas, but I think the current proposal is good as well.

bradzacher commented 2 years ago

especially when it's one that's going to be so intuitive

@ljharb We can agree to disagree on this point. I personally don't think it's intuitive at all.


The code you provided (with multiple possible completion points) isn't good code - so, people won't write it that way

Most of the examples I've seen in issues have been explicitly written to have multiple completion points. In fact the second example on the root readme uses multiple completion points: https://github.com/tc39/proposal-do-expressions/blob/3ef6d463b8163cbf222846b40d17178cca36e9e1/README.md#L25-L33

Saying that "using multiple completion points is bad code" seems to go against the motivating points of this entire feature.

As an aside - if it is valid code - people will write it that way. This is one thing I've learned working at Meta. Having access to, and maintaining one of the largest JS codebases around - I've seen terrible things 😅 Sometimes it's people who are inexperienced with the language who are trying to just make things work. Sometimes it's experts trying to meet a deadline. Regardless the outcome is the same - they will write bad code that is valid code. An example - () => (doSomething(), returnVal) is bad, but valid code. It would be much clearer written with a body. But I have seen this code shipped to production.

getify commented 2 years ago

@bradzacher

By "0 or more returns" - I am specifically referring to return locations.

I can construct a "bad looking" arrow function with multiple "locations" for a "return point":

arr.map(v => (
   v % 2 == 0 ?
      v > 100 ? 24 :
         v < 24 ? v * 2 : v
      : v + 1
));

The fact that I have to mentally traverse/execute a complex nested ternary to figure out the result of that arrow, because there are 4 different possible locations, does NOT mean that arrow functions themselves are bad. Devs can always write complex/hard-to-mentally-figure-out code.

I agree with @ljharb that the do expressions will offer choices for improving readability, which responsible developers (who care about readability and maintainability) will tend toward.

if it is valid code - people will write it that way

Of course they will. They'll write code with with and eval, since those are still in the language. But there's no reason why the language should hold itself back because of its worst usages. We should keep pushing and encouraging and enabling better code.

FWIW, I personally don't really think do expressions with if..else if..else conditional logic are a "good" usage, regardless of what syntax we end up with. I think pattern matching is the better construct for that.

ljharb commented 2 years ago

I think it’s fine to have multiple completion points - I’m talking about the contrived examples where it’s difficult to at-a-glance reason about where those are. This is the same as it already works for returns.

bradzacher commented 2 years ago

@getify there is a difference between my example and yours. Your example is definitely difficult to understand - nested ternaries suck! But parsing it is a straight forward algorithm because you know each ternary has exactly three positions.

I think there is a huge difference between "difficult to parse code with multiple completion points" and "difficult to parse code with multiple returns".

An explicit return keyword adds a clear marker that says "this is the value that the programmer wanted to return". Implicit completion points do not have any marker - you have to parse the entire block to understand which expressions might be a completion point.

My example was contrived for sure, but subsets of it are constructs people will put within do-expressions.

When I was writing the above "bad" code I had no idea what each branch would actually do until I saw the transpiled output that babel produced. This is my issue with this concept and why I say it's not intuitive.

getify commented 2 years ago

I think there is a huge difference between...

I don't see there being a "huge" difference. An arrow function returning a nested ternary has multiple branches, and there's no return keyword anywhere to grab your eye and say, "hey, this thing could be returned here". A do-expression with a bunch of if..else if..else logic branches similarly has no return keyword drawing your eye to each potential result expression. I don't see how a ternary and an if..else if..else structure are "huge"ly different with respect to what we're discussing.

Whatever "algorithm" (mental or otherwise) you imagine that can traverse a ternary, it can also traverse an if..else if..else construct with, IMO, similar difficulty. In fact, it may even be easier to glance-scan because tokens like if and else stand out visually more than ? and : do!

An explicit return keyword

My point: concise arrows don't have that -- and adoption/love of them has been quite substantial community wide -- so why would a do block need it, to achieve similar reception?

bradzacher commented 2 years ago

The difference is that you know that a chain of ternaries awlays returns a value from one of its branches.

But a chain of ifs in a do-expression might not actually return a value. It might - whether or not it does is entirely dependent on whether or not there are additional expressions after the chain.

Bodyless arrow functions are easy to reason about because you only have to parse an expression, which means if you can read any other JS, you can read a bodyless arrow function.

You can apply that same awful ternary from the body of an arrow function and put it a for loop variable initialiser and it holds the same. Adoption of bodyless arrow functions worked so well because it was taking an existing concept in JS and put it in a new location.

But let's not get hung up on the ugly ternaries you could write (which you could also similarly put in the statement completion location of a do expression). Expand the scope to loops, switches, and declarations too - all of which are not valid in a bodyless arrow function.

My point: concise arrows don't have that

As I've stated, bodyless arrow functions don't need it because there is one and exactly one return position - the expression that it encapsulates.

Do expressions have 0..n "statement completion locations".

There is a big difference.

getify commented 2 years ago

But a chain of ifs in a do-expression might not actually return a value. It might - whether or not it does is entirely dependent on whether or not there are additional expressions after the chain.

But that's the thing: by nature of how do is proposed to work, all the branches would be return points, the same as in a ternary. Whether the dev intends that or not is a separate question from the fact that, if you're reading/parsing the code, every single branch has an expression (even the empty branch's {} block essentially contains an implicit undefined) that is a return point.

you only have to parse an expression

Again, the point of do, as I see them, is to turn statements into expressions (by grabbing and returning their completion values). As such, a do with if..else if..else definitionally becomes equivalent to a ternary.

bradzacher commented 2 years ago

all the branches would be return points, the same as in a ternary

Not quite. There is a dichotomy here. A branch can both be and not be a statement conpletement point. You don't know if it is unless you parse the entire do expression

For example: do { if (X) { 1 } else { 2 } }. This follows your argument - each branch is a return point. However in do { if (X) { 1 } else { 2 } 3 } no branches are a return point because there is an expression after the if statement.

This is the complexity I am talking about. The branches of the if are statement completion points, until they're not. And similarly - loops and switches have the same complexity.

A bodyless arrow function doesn't have this same complexity unless you're really writing purposely contrived code by chaining ternaries with other expressions using a sequence expression. Which is possible with the language, but unlikely (like eval, few people purposely use sequence expressions)

With an explicit keyword for the return, this ambiguity is gone. You know exactly which branch terminates and which branch doesn't. It's clear and easy for anyone to understand.


At this point we are talking in circles. It's clear we are in different camps on this issue, which is perfectly reasonable.

In my opinion implicit returns in a do-expression isn't a good addition to the language. Statements-as-expressions with implicit returns is one thing I can definitely get behind, but having it in the do-expression is confusing and complicates the syntax for little gain, IMO.

EdSaleh commented 2 years ago

I agree with @bradzacher that implicit returns will be confusing and will be an unsuitable addition to JavaScript for do statement. If the decision was to go with do as block rather than function, then a new keyword like retrieve or give can be used to return from do statement. I like retrieve because it's similar in return specially in first 3 letters ret. Hope this proposal is accepted soon.

getify commented 2 years ago

unless you're really writing purposely contrived code by chaining ternaries with other expressions using a sequence expression

You think a sequence expression is "contrived" but the do-expression with the trailing expression after the if isn't "contrived". I don't see such a distinction, especially since I don't think people will do that very often.

At this point we are talking in circles

Maybe so, but I am at least glad I now understand (more) what you find contrived vs not-contrived, even if I disagree. I think that's progress.


FTR, I'm neither for the "required give keyword" camp nor for the "no give, all implicit" camp. I'm for a third camp, which still doesn't seem to get much attention here: "optional give". In particular, in the case you're referring to, if I genuinely had a reason to write code like that, I'd use give to disambiguate. And moreover, I'd set up a linter rule to make sure I remembered to use give in the places where I had deemed it "important" for readability. And then in all the other cases where I didn't think give was necessary, I'd omit it.

The way I see it, "optional give" is the compromise that bridges between these two opposite camps. For me, this set of comments has elicited yet another example that supports my position.

EdSaleh commented 2 years ago

@getify that's a good idea and we can use eslint to force or disallow or make it optional.

spazmodius commented 2 years ago

I'm trying to understand how give could possibly not be optional?

What value does do { 1 } evaluate to?

getify commented 2 years ago

I think that's pretty unambiguous. IUIC, the current spirit of do { .. } expressions is:

  1. pull the completion value out of any statement and treat the statement as if it had been an expression resulting in that value
  2. whatever the last evaluated expression (or statement completion value) is, that becomes the result of the do { .. } expression

So here, the 1 is the last evaluated expression, which means its result (1) is the result of the do { .. } expression.

This thread is debating (2), which is to say that it's debating if do { 1 } should be allowed, or if it should have to be do { give 1 }. What's emerged during the debate, I think mostly at my behest, is a middle-ground between "there is no give, it's all implicit returns" and "there are no implicit returns, give is always required": specifically, "unless otherwise syntactically required for disambiguation, give can be used in front of an expression or omitted; either way, the outcome is the same." Thus, both do { 1 } and do { give 1 } would be legal syntax and would have the same outcome.