Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Introduce let statement. #5445

Open
Inve1951 opened this issue Mar 11, 2023 · 6 comments
Open

Proposal: Introduce let statement. #5445

Inve1951 opened this issue Mar 11, 2023 · 6 comments

Comments

@Inve1951
Copy link
Contributor

Inve1951 commented Mar 11, 2023

Do you have thoughts on a let keyword for coffeescript?

Let me walk you through my idea below.

It could be syntactic sugar for the current do (a, b, x) => expression, while avoiding extra indentation, like this:

a = null
func = ->
  let a = 1    # capture `a` in local scope and set it to 1.
  for b in c   # saved a level of indentation
    let b      # promote b into local scope
    2          # using `await` would be fine too

In current coffeescript this can almost be expressed like so:

a = null
func = ->
  do (a = 1) =>                  # extra level of indentation
    for b in c then do (b) =>    # breaks flow if async
      2    

But as annotated, this loop becomes a problem when the loop body (the IIFE's body) uses the await keyword because that changes the return type of func, from a Promise that will resolve to an Array, to an (already resolved) Array of Promises. The loop's iterations now run in parallel. If you did not intend for this you now have a critical bug, at least waiting to happen.
So really it would have to be this code to preserve meaning:

a = null
func = ->
  do (a = 1) =>
    do => for b in c    # note that `do (b) =>` is illegal without shadowing
      2

In above example the let keyword eliminates the syntactic cruft necessary to express the same scoping rules in a bug-free manner, making it easy to write correct code. It also enables the loop to be a generator and to return from func early by not introducing a new function boundary.

Also, if above code used slim arrows -> instead of fat ones (=>), the potential this association is lost. Another thing to watch out for.

Context

There have been many requests to support JS' let and const with the primary driver being finer control over variable scopes.

The let "statement" proposed here addresses general scoping concerns in a readable manner. I'd define its rules like this:

  • It promotes identifiers already found in lexical scope to local scope.
  • It suppresses bubbling a var declaration to the enclosing function body if applicable, generating an in-place let instead.
  • An initialization expression can optionally assign to the captured (locally-scoped) identifier.
  • It has no expression value / is disallowed as an expression. If a branch evaluates to it, it evaluates to undefined. (a = let b is illegal)
  • It can be used only 1 identifier at a time. (no let a = 1, b, c = 4)
  • It could be allowed to use let more than once with the same identifier in the same block scope by generating a { let ... } block, but this is maybe unintentional. Without this, an explicit do => and let can still be used when needed, exposing the user to the usual pitfalls associated with it (e.g. having to conditionally await do =>). let would solve this automagically but arguably it's a corner case and the compile error may be preferred.

Simply put, the proposed let statement outputs a JS let statement. But additionally it initializes to the shadowed identifier's value, if any:

Input:

a = 1
b = (x) ->
  let a
  let y = 2

Output:

var a, b;

a = 1;

b = function(x) {
  var y;
  let a = a;
  y = 2;
};

Note: With #5377 implemented all var statements in the output will be omitted in favor of let/const at the assignment site, and let a = a becomes const a = a.

Possible future extensions include other assignment operators, which could operate on the shadowed identifier's value to initialize the local capture.

Conclusion

This proposal gives people their block scoping and offers a readable, unambiguous solution to common variable capturing issues, avoiding the pitfalls and mental overhead associated with current workarounds.

It also supersedes do (a, b = 1) => expressions. They can now be expressed as:

do ->
  let a
  let b = 1

  • current CoffeeScript version: 2.7.0
@edemaine
Copy link
Contributor

This sounds cool! (if somewhat hard to implement) A nice feature is that let is already a keyword in CoffeeScript, so this won't break existing code.

One note is that let a = a doesn't give you access to the shadowed a variable in JavaScript, so your example output needs adjustment. One approach is let a1 = a and relabel all instances of a within the lexical scope to a1. Another approach is do-style IIFEs. Maybe there's something better?

I also find it a little weird that let a initializes with a value — it's very counter to the equivalent JavaScript code. I could imagine wanting let a just declare that a is a local variable that shadows something else, without initializing it (though this could be accomplished in your proposal with let a = undefined). I agree that it'd be convenient for replacing do blocks to just write let a.

I've also long been a proponent of for let support in CoffeeScript, as in for let item in array or for let item from iterable, which would be a natural extension of this proposal (I think). In my experience, this would remove most need for do.

For what it's worth, Civet supports let a as a way to override automatic variable declaration, but doesn't yet support let a = a for bringing the parent scope's value for a into the child scope. I think this would be a nice extension, though.

@STRd6
Copy link
Contributor

STRd6 commented Mar 11, 2023

For code generation of the let a = a where a is in the outer scope it could go something like:

a = 1
b = (x) ->
  let a
  let y = 2
---
var a, b;

a = 1;

b = function (x) {
  var y;
  const a1 = a;
  {
    let a = a1;
    y = 2;
  }
};

That way only a transient ref is needed rather than relabeling all identifiers in a scope.

@shurko0x4cfd
Copy link

I prefer implicit const with no parentheses and no commas:

fn = x1 x2 -> x1 * lg x2

---

const fn = (x1, x2) => x1 * Math.log(x2)

@vendethiel
Copy link
Collaborator

That’s ambiguous. You’re breaking pretty much every single callback in coffee.

@Inve1951
Copy link
Contributor Author

Inve1951 commented Mar 19, 2023

I was so sure let a = a is valid JS. 👀 TIL
I don't know if it's better to rename user variables or output the bloat:

const a1 = a;
{
  let a = a1;
  // ...
}

Both options kinda suck. Could still collapse multiple lets into a single such block which might be an ok compromise.

Edit: I think it's fine. The user gets correct variable names and downstream tooling can strip the block. (minification, dead code elimination, etc)

@bpj
Copy link

bpj commented Apr 16, 2024

Not being able to shadow variables in an upper scope or a calling function is a PITA.

In MoonScript, which is to Lua what CS is to JS, all variables not initialized or declared in an upper scope are local by default unless explicitly made global with export foo,1 and you can use the local foo statement to explicitly shadow variables from an upper scope, although unlike in Lua you cannot assign in the local statement which I consider a misfeature in MS.

Unlike JS/CS variables declared in calling functions are thankfully not visible in called functions in Lua/MS (unless the callee is a closure within the caller) but MS has the using keyword which would be even more useful in CS: if you in MS say some_func = (foo using bar, baz) -> (or just some_func = (using bar, baz) ->) all assignments within some_func other than to bar and baz are implicitly local even if a variable of the same name exists in an upper scope. You can even say other_func = (foo using nil) -> (or other_func = (using nil) ->), where Lua/MS nil corresponds to JS/CS null, and all assignments inside the function become implicitly local. I wish you could say do using ... too in MS, but alas you can't (yet).

Footnotes

  1. In Lua/MS a module is simply a script which returns some value, usually a table (corresponding to both object and array) with functions at the bottom. The statement foo = require"rel.path.to.foo" simply assigns the return value of rel.path.to.foo.lua or rel.path.to.foo.moon or rel.path.to.foo.so to the local foo variable, looking up the required script in the paths listed in the LUA_PATH and LUA_CPATH environment variables.

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

No branches or pull requests

6 participants