tc39 / proposal-do-expressions

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

Do-Expressions vs IIFEs: A Comparison #34

Open JHawkley opened 5 years ago

JHawkley commented 5 years ago

It has occurred to me that a comparison between the do expression and the currently idiomatic immediately-invoked function expression (IIFE) has not been done yet. Doing this would be important for determining how much is actually being gained from introducing the do expression to the language and what challenges need to be overcome in its implementation.

Below, a friendly syntax will be defined for each and then tested against ECMAScript's various flow-control structures to see how each performs.

Defining the Do-Block

The do-block is the name I will be assigning to the current do expression proposal.

It has the syntax do { <expr> } where <expr> is a set of statements that terminate with an expression.

For the purposes of this exercise, it has the following characteristics:

  1. No function scope is created; flow-control statements (like return, yield, etc.) can affect program-flow external to the do-block.
  2. The completion-value of the block is used as the result of its evaluation.
  3. When used in expression position, all code-paths must end in either an expression, a throw, or a return from the enclosing function (if applicable).

Defining the Do-Arrow

The do-arrow is the name I will be assigning to a syntax that produces traditional immediately-invoked function expressions. It will be based on one of the usages of CoffeeScript's do keyword for evaluating an IIFE.

It has the syntax do => <expr> or do => { <function body> }, similar to the arrow-function syntax. Unlike arrow-functions, it supports no parameter list.

For the purposes of this exercise, it has the following characteristics:

  1. It has all of the characteristics of an arrow-function, excluding a parameter list. Its inputs are provided exclusively through variable capture from the outer scope.
  2. Having the characteristics of an arrow-function, flow-control statements (like return, yield, etc.) cannot be used to influence program flow external to the do-arrow. The only way external program flow can be influenced directly is by throwing an exception.
  3. The body of the do-arrow is executed immediately when it is being evaluated and is compulsively inline-optimized; there is no actual function-call overhead associated with the use of do-arrow.
  4. When used in expression position, all code-paths must terminate with an explicit return <expr> statement (the <expr> is mandatory) or a throw statement. This keeps an unintended undefined from being returned as the result of the expression.

The following code:

const foo = do => {
  const val = Math.random();
  if (val > 0.33) return 0;
  if (val > 0.66) return 1;
  return 2;
}

...is effectively equivalent to:

const foo = (() => {
  const val = Math.random();
  if (val > 0.33) return 0;
  if (val > 0.66) return 1;
  return 2;
})();

Key Differences

There are really only two differences between these two constructs.

Result Selection Behavior

The do-block implicitly selects the final expression evaluated within it as the result of the block, whereas the do-arrow must always have its result explicitly specified with return.

Ability to Affect External Program Flow

Because do-block is treated as a normal block within its enclosing scope, it can use common flow-control statements like return, yield, break, and continue to influence constructs external to the do-block. This can give it higher versatility compared to do-arrow.

The do-arrow is isolated from its enclosing scope by its function scope. It can only directly influence the flow of the external scope by throwing an exception. This gives the do-arrow higher purity compared to do-block. As its only purpose is to evaluate to a result value, reasoning about the do-arrow in code is a little easier.

Both constructs can still use and mutate variables that are accessible in the external scope and generate side-effects.

Examples

Now that we know how everything looks and works, it's time to see how they perform against each other. We'll run through a small exercise with each of the common flow-control structures in ECMAScript, except try-catch, which is too trivial to gain much insight from.

The syntax for each construct will be kept to its current definition, though the break statement will have some special considerations when it becomes relevant later.

If-Else Chain

For this example, a count-of-things will be mapped to a fuzzy-word that describes the amount, such as "one" or "many".

Block Version

The if-else chain is the poster child of the do-block, in that it is entirely clear and concise.

const count = 2;
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";
}
console.log(val);

Arrow Version

const count = 2;
const val = do => {
  if (count === 0) return "none";
  if (count === 1) return "one";
  if (count === 2) return "a couple"; 
  if (count <= 5) return "a few";
  return "many";
}
console.log(val);

Observations

Both versions hold up against the other quite well. The do-arrow gets some bad-marks for being slightly longer, but not by much since it was able to exchange else for return. That did introduce a repetitive use of return, though.

However, you can also say the do-block has a repetitive use of else...

Switch

In these examples, we will use a switch to map some game controller input to an action.

Block Version

We get a big blemish when it comes to using break in a do-block.

const controllerInput = 2;
const action = do {
  switch (controllerInput) {
    case 1:
      "walk";
      break;
    case 2:
      "run";
      break;
    case 3:
      "jump";
      break;
    default:
      "idle";
  }
}
console.log(action);

The expression we would want to be the result is not the last statement in the block; the break statement is. Reading and understanding this code takes more mental effort than should be necessary. It is also much longer, though you could also write the break on the same line, as in: case 1: "walk"; break;

Because each case had multiple statements, I decided not to do that for clarity. But this isn't the end for switch.

There is a proposal to change the syntax of break [<label>] into break [<label>] [with <expr>], where the with <expr> is used to explicitly specify the completion-value of a case, loop body, or labeled block when exiting it.

That would look like this:

const controllerInput = 2;
const action = do {
  switch (controllerInput) {
    case 1: break with "walk";
    case 2: break with "run";
    case 3: break with "jump";
    default: "idle";
  }
}
console.log(action);

This is better! But it is literally just a re-implementation of return that is only useful in a do-block.

Arrow Version

So, why not just use the return we have already?

const controllerInput = 2;
const action = do => {
  switch (controllerInput) {
    case 1: return "walk";
    case 2: return "run";
    case 3: return "jump";
    default: return "idle";
  }
}
console.log(action);

Observations

In order to have clear code with the do-block, a syntax change is necessary. The proposed break [<label>] [with <expr>] syntax really only has one use: resolve a do-block to a specified expression.

This is almost identical to return, which resolves a function to a specified expression.

The do-arrow demonstrates that a syntax change is not necessary in this context, as a flow-control statement that pretty much does the same thing is already present in the language.

For Loop

In the for loop examples, we'll be using our do constructs to search an array for the first even number. For purposes of demonstrating a critical weakness in do-block, the result will be packaged in an array; if the array does not contain an even number, the do expression will indicate it with an empty array.

In other words, we're treating Array as an Option type.

Block Version

We'll start with the intuitive version, using the new break syntax to make it look nice:

const numbers = [1,3,5,2,4,6];
const firstEven = do {
  for (const num of numbers) {
    if (num % 2 === 0)
      break with [num];
    else [];
  }
}
firstEven.forEach(console.log);

It looks good, but you might be noticing a problem. During the evaluation of this do-block, three empty arrays are allocated and discarded before the block provides the result of [2]. Not every expression is side-effect free, and a programmer will need to recognize and accommodate for it in order to create optimal code within a loop.

This leads us to the second version, which extracts the empty array allocation out of the loop:

const numbers = [1,3,5,2,4,6];
const firstEven = do {
  const nothing = [];
  for (const num of numbers) {
    if (num % 2 === 0)
      break with [num];
    else nothing;
  }
}
firstEven.forEach(console.log);

Better, but we still have one potentially unused array allocation. That's not really terrible and you can likely leave it at this without worrying about your application's performance, but can we get rid of that unnecessary allocation entirely?

const numbers = [1,3,5,2,4,6];
const firstEven = do {
  const result = do {
    for (const num of numbers) {
      if (num % 2 === 0)
        break with [num];
      else null;
    }
  }
  result !== null ? result : [];
}
firstEven.forEach(console.log);

Yes we can! ...with a nested do-block and a check for null. Because it would be an error to have a code-path that does not end in an expression, we need to explicitly provide null from the for loop's block; we can't just allow the block to terminate without a value.

There are yet other ways to write this, including doing the sensible thing and using Array.find instead, but the point is to show off a worst-case scenario of a for loop in a do-block, and these are about as good as you can do while sticking to the constraints of this exercise.

Arrow Version

The do-arrow version of this is idyllic by comparison.

const numbers = [1,3,5,2,4,6];
const firstEven = do => {
  for (const num of numbers)
    if (num % 2 === 0) return [num];
  return [];
}
firstEven.forEach(console.log);

The most obvious solution was the best solution.

Observations

This example was obviously cherry-picked to demonstrate how a do-block can push a programmer to write unnecessarily convoluted code. Because ECMAScript's loops were not built with expression-oriented programming in mind, they can often work unintuitively with the do-block.

Correcting this would require changes to the language that affect these core flow-control constructs or introduce new loop constructs that work better in a do-block, both of which are kind of a big deal to do.

As an example, introducing a for-else construct, which might work like a similar construct in Python, would do a lot to clear this up:

const numbers = [1,3,5,2,4,6];
const firstEven = do {
  for (const num of numbers) {
    if (num % 2 === 0) break with [num];
  } else [];
}
firstEven.forEach(console.log);

However, ECMAScript's loops are already built to be used in functional programming and the do-arrow exploits that to have clear, concise code without requiring new syntax.

More thoughts on this issue are considered in the "Comparisons to Other Languages" section.

While Loop

In the while examples, we're going to run a little simulation, determining how many time-steps it will take for two objects traveling on a one-dimensional line to intercept each other. In each time-step, the object's vel is applied to the object's pos and then interception is checked for. An intercept occurs when the objects swap relative positions on the line.

For simplicity's sake, we'll assume both objects are traveling in the positive direction. If the trailing object does not have enough velocity to catch up to the leader, the result should be Number.POSITIVE_INFINITY.

Block Version

const trailing = { pos: 30, vel: 1.2 };
const leading = { pos: 300, vel: 1.0 };

const timeStepsToIntercept = do {
  if (trailing.vel <= leading.vel)
    Number.POSITIVE_INFINITY;
  else {
    let steps = 0;
    while(leading.pos > trailing.pos) {
      trailing.pos += trailing.vel;
      leading.pos += leading.vel;
      ++steps;
    }
  }
}
console.log(timeStepsToIntercept);

This looks pretty good. Since a while loop utilizes a simple exit condition, the chances of needing to complicate things with break is much reduced; there is no need to use an if-else construct to get the result we want in this instance.

Arrow Version

const trailing = { pos: 30, vel: 1.2 };
const leading = { pos: 300, vel: 1.0 };

const timeStepsToIntercept = do => {
  if (trailing.vel <= leading.vel)
    return Number.POSITIVE_INFINITY;

  let steps = 0;
  while(leading.pos > trailing.pos) {
    trailing.pos += trailing.vel;
    leading.pos += leading.vel;
    ++steps;
  }
  return steps;
}
console.log(timeStepsToIntercept);

Observations

Both of these are nearly identical. Since the while loop ends when the desired value is calculated, the loop can be kept simple. The while loop can work well with do-block, but the moment you start doing anything more complex, such as using an if-else to conditionally break out, the same issues that affect for can manifest.

The only real "gotcha" in this example is the usage of the increment operator ++: if the more common postfix operator was used instead of the prefix operator, the do-block would have reported the wrong answer.

But that is just one of those trivia things you need to be aware of, and not really a fault of the do-block.

Other Issues With Do-Blocks

Given that a do-block just looks like a do-while construct without the while part, you have to ask: should this be permitted?

const result = do {
  if (Math.random() > 0.5) 42;
  else 0;
} while (false);

Should the engine detect and flag obviously useless expressions as an error unless the do-block is in expression position? The following is almost certainly a bug, after all.

// The result of this block is not being used.
do {
  if (Math.random() > 0.5) 42;  // Error?
  else 0;  // Error?
}

What About the Program Flow Difference?

Earlier, I stated that one of the key differences was that do-block can use flow-control statement like return and yield to affect the program flow outside of its block while the do-arrow can not.

Truth is, I couldn't think of an example to showcase that. In all the examples I thought up, these statements were always better placed outside the do. I couldn't find an example where it would be a clear advantage to have such a thing inside the do expression itself, and I didn't want to have a contrived example.

If anyone can produce such an example demonstrating this difference, that would be very helpful.

Comparisons to Other Languages

Other languages that offer expression-oriented programming have been built from the ground up to support and utilize it. ECMAScript unfortunately was not and do-block would suffer from a lack of support by its language.

The difficulty is in dealing with loops, which have been demonstrated to be a weakness for the do-block. Other languages consider the way loops work as expressions very carefully.

In Scala, the traditional for-loop is replaced with the for-comprehension and the classical while loop is one of the very few things that cannot be evaluated as an expression; Scala programmers are meant to use the for-comprehension and tail-recursive functions wherever possible for looping instead, only falling back on the while loop when it is absolutely necessary.

In Rust, the only type of traditional loop that can be evaluated as an expression is the infinite loop which requires the use of break in order to leave its body. Because a break is mandatory to keep the program from becoming stuck, it can safely be used to provide the result of the loop expression.

None of this was a consideration for ECMAScript's loop structures. Some combination of these options would need to be taken in regards to do-block:

  1. Try to cope with some of its awkwardness by adopting the new break syntax.
  2. Disallow using the completion-value of the problematic for and while loops within a do-block, forcing programmers to use more traditional methods to extract information from loops and mitigating the side-effect issue.
  3. Rework existing loop structures and/or create new ones that work better with the do-block. Perhaps the old array-comprehension idea could be resurrected. Or maybe a for-else and while-else construct could be introduced, where the else block is only executed if no break was encountered in the loop's body. That would be very Python-like.

On the other hand, all of ECMAScript's current flow-control structures are designed to be utilized by normal functions and they all work swimmingly with do-arrow with no adjustments necessary.

There is also prior art for do-arrow in ECMAScript's family of languages. CoffeeScript's do keyword is often used in exactly this same way to produce a result from a complicated set of statements and prevent scope pollution. It is why I based the do => { ... } syntax on it.

Conclusion

When you really dig into it, the do-block is at best as good as do-arrow when it comes to the if-else and switch constructs. But when loops are involved, a do-block is more likely to cause a programmer to write bad and/or confusing code.

This problem can only be mitigated with a trade-off: the do-block either needs to be less useful or the language needs to be more complex. Neither of these are particularly good trades. The do-block also requires changes to the syntax of break in order to get cleaner, more readable code in switch statements and loops.

And when you introduce that change to break, all you're really doing is emulating an immediately-invoked function expression, but with a less familiar look and feel. The do-block offers little that the do-arrow does not. Implementing do-block would come at significant cost, and all of it just to avoid having to type return in your do expression's body.

In my opinion, do-block isn't worth it.

IIFEs are already the idiomatic solution to the problem that this proposal is trying solve. Why not just use this proposal to formalize their usage into the language with a slick syntax like that of the do-arrow?

devsnek commented 5 years ago

FWIW both examples in Other Issues With Do-Blocks are syntax errors.

I also find that your examples tend towards operations better suited for function bodies anyway.

// There is no reason to use a function here except the status quo
const x = {
  if (foo()) {
    f();
  } else if (bar()) {
    g();
  } else {
    h();
  }
};
// use `do` to compartmentalize code while maintaining a clear logical flow
function a() {
  const c = do {
    const t = acquireThing();
    if (t === 1337) {
      return t;
    }
    t * 5;
  };
  // do something with `c`, and `t` is out of scope
}

It's also worth mentioning that transpilers can do a lot of cool stuff with do expressions.

zenparsing commented 5 years ago

Thank you @JHawkley for putting together this very thorough analysis! It will take a little bit of time for me to absorb everything, but I'll let you know my thoughts when I do.

Also, another idea that's been floating around for a while is the concept of an "async do". It might be interesting to compare how an "async do-block" might compare with an "async do-arrow".

JHawkley commented 5 years ago

@devsnek I hope that they are syntax errors, because it seems like that would be for the best.

And yeah, that's a good, simple example of using return in a do-block. That is one of the few things a do-arrow can't do.

Looking at this example, though, it bugs me a little. Usually when you create a variable, you intend to use that variable, but if the do expression assigned to it causes a return, that variable may never be used.

It seems to violate my expectations a little bit. This seems like one of those things I would try to avoid doing in my own code, since it seems like a surprise. But I can see how it can still be useful.

Maybe this is why I just couldn't think of an example for this particular use case... I'm biased against doing something like this, and my brain was trying to avoid it.

When I was trying to think up examples of my own, I kept focusing my thoughts on generator functions and using yield in a do-block instead of return. The yield statement would not violate my expectations as it isn't a terminal statement like return; the do-block would still have an opportunity to finish evaluating and a variable assignment would still occur (assuming the generator function's iterator continued to be used).

@zenparsing I did see that idea floated. I will think on it and see if I can find any insights about it for you. But maybe not tonight. I've spent enough time writing about this for now.

claudepache commented 5 years ago

@JHawkley

The title of this issue is ”Do-Expressions vs IIFEs”, but you are in fact comparing ”do-blocks” and “do-arrows”.

You’ll say that ”do-arrows” are ”IIFEs”. That’s true semantically, but not syntactically, the big difference being that the latter is part of ECMAScript, the former is not. So, in order to make your intention clear, what are you exactly discussing:

  1. spec do-blocks vs. spec do-arrows (or similar); or
  2. spec do-blocks vs. not spec anything (i.e., use IIAEs: (() => { /* ... */ })() (not to be confused with IIAFEs: (async function() { /* ... */ })()))?

I presume you are discussing (1)?


That said, here is my critic on the proposed do-arrow syntax:

do => { /* ... */ }

This is very similar to an arrow function with a single parameter:

foo => { /* ... */ }

The only syntactical difference is that the parameter name is replaced by a keyword. However the semantics is quite different.


I have not yet read your analysis thoroughly, but I’m already making the following remark:

Other Issues With Do-Blocks

Given that a do-block just looks like a do-while construct without the while part, you have to ask: should this be permitted?

const result = do {
  if (Math.random() > 0.5) 42;
  else 0;
} while (false);

Should the engine detect and flag obviously useless expressions as an error unless the do-block is in expression position? The following is almost certainly a bug, after all.

// The result of this block is not being used.
do {
  if (Math.random() > 0.5) 42;  // Error?
  else 0;  // Error?
}

The ECMAScript grammar has already similar situations, with a known effective solution: see https://github.com/tc39/proposal-do-expressions/issues/31#issuecomment-418514110.

JHawkley commented 5 years ago

@claudepache

I gave IIFEs a specialized syntax primarily so they're viewed on more level ground. Having to use (() => { ... })(); in the IIFE examples while comparing them to the much cleaner do { ... } syntax might have created a bias toward the more syntactically pleasing option and therefore colored people's opinions as they read the document.

And since this proposal is about creating new syntax, why not show what such a syntax for an IIFE might also look like?

Well, just know that the do-arrow syntax was for illustrative purposes only. I regret not making that clearer in the original document. My apologies! 🙇

But that's a very true observation about the syntax I chose for the IIFE; I really overlooked that problem. I'm not too sure how that could be addressed. You definitely would want an arrow there, since it essentially is a function. That's important for indicating where flow-control statements change context.

Maybe drop the do and use only a thin-arrow for an IIFE instead?

const foo = -> {
  if (Math.random() > 0.5) return 42;
  return 0;
}

You might say, that function's arrow is so thin that only its return value is tangible! 😄

However, it was brought up in the original arrow-functions proposal that one of the reasons that having a shorthand for a non-binding function using the -> syntax was dropped was because, "...it’s confusing to have two arrows..." So using -> at all might not be popular with the committee for the same reason.

Like all "good" problems, this one doesn't appear to have a perfect solution. Any syntax chosen would probably require an argument of "usefulness over confusion" be made.

gibson042 commented 5 years ago

@JHawkley break does not have the black-hole behavior you're describing, at least not in every context. Many statements use UpdateEmpty to copy the preceding completion value: http://jsfiddle.net/xk3854uz/

JHawkley commented 5 years ago

@gibson042 I don't understand what you're trying to tell me. I appear to have demonstrated that break works in the same manner you have done in this jsFiddle.

JHawkley commented 5 years ago

@gibson042 Ahh, wait, I think I understand what you're saying. No, that is true; it is not actually setting the completion value of the switch statement. It is just exiting the block, and whatever was last evaluated is propagated as the completion value.

What I was trying to say is that the syntax break with <expr> would really only have a use in a terminal code-path of a do-block and its only practical use would be for setting the completion value of the whole do-block. It wouldn't have much of a meaning anywhere else.

It probably could be used outside of a do-block...

let foo;
switch ( 1 ) {
  case 0: break with (foo = 0);
  case 1: break with (foo = "this string");
  default: break with (foo = "default");
}

And so break with <expr>; would probably be equivalent to <expr>; break;...

But why would you use it this way outside a do expression? It is a syntax change that would only support do expressions.

JHawkley commented 5 years ago

The major take away I found from all this is that this proposal will need to do a lot more than just define a syntax for a do expression. There will need to be many other changes all over the language to make using this new do construct palatable and friendly.

If you read any part of this document, read the For Loop and Comparisons to Other Languages sections which go into the problems that do expressions would have with ECMAScript's loops and explains why changes to them would be important to properly support them inside of do expressions.

pitaj commented 5 years ago

I have never thought this proposal needed any signification clarification, and even after reading your OP, and the other discussion around these topics, I still don't understand what people have a problem with.

I feel like possibly one of the reasons people keep bringing up the early-exit issue and comparing to IIFEs and whatnot is because they just don't understand the inspiration behind the proposal and the main support behind it.

One of the main features behind this proposal is statement-as-expression. Without that feature, this proposal has, in my opinion, no reason to exist. If you think requiring a keyword to return a value from a do-expression is a good idea, you are missing the point entirely. Make your own proposal for an IIFE shortcut if you really want that, don't try to co-opt this one.

No disrespect, but I'd go as far as to disagree with @zenparsing. This is a completely useless discussion and I very much wish that this repository had some strong moderation to eliminate issues that are off-topic, misunderstood, and otherwise contrary to the spirit of the proposal.

gibson042 commented 5 years ago

@JHawkley there is no need for break with … anywhere AFAICT, because blocks in which break is semantically relevant all appear to use UpdateEmpty to reach behind it for their completion values. Which is demonstrated by the linked demonstration, because eval can expose statement completion values.

JHawkley commented 5 years ago

@gibson042 You are correct, the break with <expr> syntax is not necessary... But it would be desirable by users of the do expression for the purposes of code clarity.

JHawkley commented 5 years ago

@pitaj ECMAScript's loops have problems when used as expressions. One of the purposes of this discussion is to state that clearly with examples. Having statements-as-expressions may be the main feature of this proposal, but I'm trying to demonstrate that there are problems with the concept due to the way ECMAScript has designed the behavior of those statements.

This feature would have a lot of jank to it if adjustments are not made to overcome the problem and I do not believe that this proposal would pass muster without this problem taken into consideration by the proposal.

I don't appreciate you being so dismissive regarding these concerns. This "early-exit issue" keeps being brought up for a good reason; exiting early from a loop is an important ability to have and it is not a graceful process with a do expression right now.

As I said, other languages that have expression-oriented programming give very special consideration to loops, considerations that ECMAScript has not given because it is not an expression-oriented language.

It is a functional language, and functions work very well with loops because return is always a terminal statement. And that is why shifting focus to IIFEs over the do expression is likely a good idea; they actually fit into ECMAScript as it was designed.

pitaj commented 5 years ago

I guess I'll deconstruct it bit-by-bit, then. I'm highly aggravated by your general lack of curly braces so I'm going to insert them where they should be, according to every popular JS style guide I know of:

If-Else

As you pointed out, no meaningful difference.

Switch

Your issue is with how switch works, not with how do-blocks work. If JS had a Rust-like match syntax (there is a proposal for it), then this would be entirely moot.

break with

You don't need it. Just use <expr>; break;. It's not a problem.

For loop

AFAIK, you can just write the first example like this:

const numbers = [1,3,5,2,4,6];
const firstEven = do {
  for (const num of numbers) {
    if (num % 2 === 0) {
      [num];
      break;
    }
  }
} || [];
firstEven.forEach(console.log);

While Loop

You don't need the increment operator (most style guides ban it anyways), just use two statements instead:

const trailing = { pos: 30, vel: 1.2 };
const leading = { pos: 300, vel: 1.0 };

const timeStepsToIntercept = do {
  if (trailing.vel <= leading.vel) {
    Number.POSITIVE_INFINITY;
  } else {
    let steps = 0;
    while (leading.pos > trailing.pos) {
      trailing.pos += trailing.vel;
      leading.pos += leading.vel;
      steps += 1;
      steps;
    }
  }
}
console.log(timeStepsToIntercept);

The early-exit point here is moot, because it's likely this kind of expression would be used in a function anyways, in which case return would be just as useful.

The while loop can work well with do-block, but the moment you start doing anything more complex, such as using an if-else to conditionally break out, the same issues that affect for can manifest.

You can't just claim this without giving examples and expect to be taken seriously.

Other Issues With Do-Blocks

Syntax ambiguity has been addressed many times throughout issues on this repository. These things have been conquered before in the language specification. The arguments for alternative syntax should be around brevity or programmer confusion, not about parser issues.

Detecting useless expressions is left up to linters and the like. This is highly unlikely to change.

What About the Program Flow Difference?

I though of this example pretty quickly, it may not be the best, but it shows some usefulness:

A do block as the body of an arrow function, which only saves one set of braces, but allows for statement-as-expression anywhere:

const isEven = (number) => do {
  if (number === 0) { return true; }

  !!(number % 2)
};

Comparisons to Other Languages

The difficulty is in dealing with loops, which have been demonstrated to be a weakness for the do-block.

You've demonstrated no such thing, even without my corrections to your examples.

In Rust, the only type of traditional loop that can be evaluated as an expression is the infinite loop which requires the use of break in order to leave its body.

Rust does not have null or undefined. ES does. A simple solution to the loop problem is to make all loops just have a completion value of undefined unconditionally. However, the current completion value paradigm I have no problem with, personally.

On the other hand, all of ECMAScript's current flow-control structures are designed to be utilized by normal functions and they all work swimmingly with do-arrow with no adjustments necessary.

Wow, if you gut the main feature of this proposal, all of the sudden all of the carefully chosen things you've pointed out become irrelevant. How convenient! Go make your own proposal.

But when loops are involved, a do-block is more likely to cause a programmer to write bad and/or confusing code.

This has been discussed before. We don't need a 3000-word essay to point it out. Contribute to existing issues on the matter if you want. It's a trivially solvable problem even if you consider it a problem. I don't, as it's an optional feature people are unlikely to use, and trivially caught by a linter.

This problem can only be mitigated with a trade-off: the do-block either needs to be less useful or the language needs to be more complex.

No it doesn't. Completion values are not currently exposed in the language with the only exception being eval, which nobody cares about breaking.

The do-block also requires changes to the syntax of break in order to get cleaner, more readable code in switch statements and loops.

Only if you consider <expr>; break; particularly gross. It's not required by any means. The frequency at which people will be using the main feature of do blocks (statement-as-expression) is much much higher than they will be using it with loops or switch.

And when you introduce that change to break, all you're really doing is emulating an immediately-invoked function expression, but with a less familiar look and feel.

Except that you still have statement-as-expression, the main feature of this proposal.

The do-block offers little that the do-arrow does not.

Besides its main feature, you mean. The entire reason for its existence.

Implementing do-block would come at significant cost, and all of it just to avoid having to type return in your do expression's body.

In my opinion, do-block isn't worth it.

You just don't value the main feature of this proposal. That's fine, but don't act like you're just proposing an alternate syntax or behavior. Make your own proposal.

IIFEs are already the idiomatic solution to the problem that this proposal is trying solve.

Then create a proposal for an IIFE syntax. There probably already is one. Good luck convincing people that saving four characters is worth it.

Why not just use this proposal to formalize their usage into the language with a slick syntax like that of the do-arrow?

Because this proposal is about statements-as-expressions.

other languages that have expression-oriented programming give very special consideration to loops, considerations that ECMAScript has not given because it is not an expression-oriented language.

This perspective applies to every change you'll ever make to the language. ES wasn't built with functional programming in mind, yet you see a pipeline proposal. ES wasn't built with object oriented programming in mind, yet you see classes.

I think statement-as-expression fits well within ES. I'd love it if ES was built for it in the first place, but here we are, just now getting [].flatMap.

JHawkley commented 5 years ago

@pitaj You first say, and I am paraphrasing, "this guy doesn't know what he's talking about. Why can't we shut him up?" And then you belittle me throughout your post "correcting" my code while disregarding many of the points it was fashioned to make.

This is a pretty disrespectful thing to do to someone who just spent their whole weekend studying this and preparing a report to hopefully guide and assist this proposal.

I am really offended by you, @pitaj.

I'm highly aggravated by your general lack of curly braces so I'm going to insert them where they should be...

I'm sorry that you dislike my coding style, but that is neither here-nor-there.

You can't just claim this without giving examples and expect to be taken seriously.

I'll accept that criticism. I should have shown how the same issue that affects for also affects while, but I thought the for loop sufficed for that. A for loop is just a while loop with some special mechanisms that occur between iterations of the loop, after all.

I thought people would just know that... You can re-write the for loop with while without problems.

When I was doing the while loop example, it was late and I just went with the first example that came to me.

So, I'm sorry for that. I should have done better.

Rust does not have null or undefined. ES does. (...) However, the current completion value paradigm I have no problem with, personally.

Yes, indeed. The current thinking in language design is that null was a mistake, and we got undefined to deal with in ECMAScript too. It is good that Rust has neither.

But since ECMAScript has them, we need to deal with them.

I think it is a very bad idea to allow a do expression to implicitly resolve to undefined if no code-path results in a terminal expression. I believe ECMAScript would be unique among expression-oriented programming languages in permitting that. The conventional wisdom is that expressions should have a defined answer and those that do not should be considered errors.

Without a protection like that, you'd be far more likely to cause undefined to leak into your code. Perhaps @pitaj would be okay with that, but I definitely wouldn't be. We should be doing everything we can to ensure that undefined is never a surprise. (And the same should apply to null as well.)

A simple solution to the loop problem is to make all loops just have a completion value of undefined unconditionally.

Which is sort of option number 2, except that you still wouldn't want that undefined to leak out of the do expression.

If you allow undefined to leak out of do expressions, then some of what I said stops being an issue. But the mental stress on the programmer would increase greatly, needing to worry that all code-paths terminate in an expression to prevent an undefined from suddenly manifesting in their code.

I would hope that others who are here understand the wisdom of not permitting this to happen.

ljharb commented 5 years ago

Just leaving a friendly reminder to everyone in this thread equally: all TC39 repos follow our Code of Conduct. Please adhere to it.

pitaj commented 5 years ago

I didn't mean any disrespect or offense. I respect the effort you put into this, I just don't like to see this kind of effort wasted on what I see as redundant discussion. I did use the word useless but a better word is redundant.

All of the problems you brought up have been discussed in previous issues on this repository. That includes but is not limited to early-exit, break semantics, loop completion values, and IIFE vs block.

I wish you had, instead of creating a monolithic report about all of your problems with the proposal, contributed your views to those individual discussions. I hope you weren't motivated to centralize your comments in order to push for this IIFE shortcut syntax.


I code in rust a lot, and I really love it, but ES has no idiomatic concept of an Option. null or undefined is used in place of None. Comparing the two languages is probably useful when considering novel, ergonomic language features, but one must consider the idioms of ES when proposing features for it.

The lack-of-completion-value issue is not exclusive to loops. For instance, consider an if without an else. The following do block, under current semantics as I understand them, would result in either 1 or undefined:

const thing = do {
  if (a === 3) {
    1;
  }
};

Returning undefined in an undefined case seems perfectly reasonable to me. Heck, even the name matches. Considering this, I see no inconsistency when it comes to loops.

Now, maybe you want do expressions to require an explicit value. You want the code above to result in an error. That's a novel concept and one that I support, but it isn't really consistent with the behavior of ES. For instance, functions without a return statement result in undefined automatically.

In ES, all code-paths result in either a "real" value, or undefined. The current semantics of do expressions is consistent with that.

zenparsing commented 5 years ago

@JHawkley As an experienced contrarian, I knew you were going to get hammered eventually : )

@pitaj As the expression goes, "don't shoot the messenger".

The analysis in this post is very thorough and should be useful when considering the downsides to this proposal. Specifically, it makes a good case that relying on completion value semantics in this manner does not result in more readable code. The expr; break; pattern in particular is pretty bad.

I believe this question is also raised in #21.

jonaskello commented 5 years ago

I see nothing in this analysis about the performance gains of do expressions. I'm no expert in JS execution engines so it might not be an issue but I know that function invocations are expensive in themselves because of having to push everything in the current context to the stack and then pop it. If you really want to work with expressions you would have to put IIFE all over your code-base which means a lot of extra function invocations. Perhaps the engine can inline the calls I don't know but anyway having do available I think all expenses of function invocations could be avoided?

zenparsing commented 5 years ago

Do expressions with IIFE semantics would not have to involve function calls, either at a specification level or at an implementation level. You would essentially evaluate them as a block and "catch" any return completions.

jonaskello commented 5 years ago

@zenparsing I'm not sure what you mean by "Do expressions with IIFE semantics". Do you mean do expressions as proposed in this repo? In that case are you saying do expressions as proposed in this repo would have a performance benefit over using the IIFE as available today since the proposed do expressions do not incur function calls? If so I would say that is relevant info for this comparision.

zenparsing commented 5 years ago

@jonaskello Sorry, by "do expressions with IIFE semantics" I mean the thing called "do-arrow" in this issue.

jonaskello commented 5 years ago

@zenparsing Ah, thanks I did not read this issue carefully enough. I got the impression this issue compared this repo's proposal for do blocks to traditional already existing IIFEs. I guess that's what you get for only reading the header and the first lines :-):

Do-Expressions vs IIFEs: A Comparison It has occurred to me that a comparison between the do expression and the currently idiomatic immediately-invoked function expression (IIFE) has not been done yet. Doing this would be important for determining how much is actually being gained from introducing the do expression to the language and what challenges need to be overcome in its implementation.

I see now that this issue actually goes on to propose yet another new syntax called do-arrow that does not exist today and the comparison is actually for two new syntaxes. From above my question is also answered in the section about the new do-arrow syntax:

  1. The body of the do-arrow is executed immediately when it is being evaluated and is compulsively inline-optimized; there is no actual function-call overhead associated with the use of do-arrow.

My point was that this is not true for the already existing IIFE syntax. Sorry for the confusion.

devinrhode2 commented 4 years ago

Never would have guessed do => {} is not just a plain arrow function expressions.. That's some strange syntax.

bakkot commented 3 years ago

Having the characteristics of an arrow-function, flow-control statements (like return, yield, etc.) cannot be used to influence program flow external to the do-arrow. The only way external program flow can be influenced directly is by throwing an exception.

If I'm reading this right, you propose that a do-arrow within an async function would not be able to use await to pause the execution of the outer function (just as a do-arrow within a function* would not be able to use yield to pause the execution of the outer function).

I think that not being able to await in the body of the do-arrow would be a major downside. The whole benefit of async/await is that it lets you write asynchronous code while structuring your code the same way you would structure synchronous code, but here'd we'd be introducing a new way to structure synchronous code which could not be used by asynchronous code.

I'd be very reluctant to adopt any version of do expressions which couldn't be used seamlessly in asynchronous code. That is to say, I feel strongly that do expressions should inherit the ability to await from the surrounding code, just as most other expressions do (but by contrast to IIFEs and arrows).

Jack-Works commented 3 years ago

Hope the difference between the do expression and IIFE can be documented in the README