Search code examples
javascriptcoffeescriptglobal-variablescompiler-bug

Why do e += 1 and e = e + 1 compile differently in CoffeeScript?


I always assumed that <var> += 1 and <var> = <var> + 1 have the same semantics in JS.
Now, this CoffeeScript code compiles to different JavaScript when applied to the global variable e:

a: ->
  e = e + 1
b: ->
  e += 1

Note that b uses the global variable, whereas a defines a local variable:

({
  a: function() {
    var e;
    return e = e + 1;
  },
  b: function() {
    return e += 1;
  }
});

Try it yourself.
Is this a bug or is there a reason why this is so?


Solution

  • I think I would call this a bug or at least an undocumented edge case or ambiguity. I don't see anything in the docs that explicitly specifies when a new local variable is created in CoffeeScript so it boils down to the usual

    We do X when the current implementation does X and that happens because the current implementation does it that way.

    sort of thing.

    The condition that seems to trigger the creation of a new variable is assignment: it looks like CoffeeScript decides to create a new variable when you try to give it a value. So this:

    a = ->
      e = e + 1
    

    becomes

    var a;
    a = function() {
      var e;
      return e = e + 1;
    };
    

    with a local e variable because you are explicitly assigning e a value. If you simply refer to e in an expression:

    b = ->
      e += 1
    

    then CoffeeScript won't create a new variable because it doesn't recognize that there's an assignment to e in there. CS recognizes an expression but isn't smart enough to see e +=1 as equivalent to e = e + 1.

    Interestingly enough, CS does recognize a problem when you use an op= form that is part of CoffeeScript but not JavaScript; for example:

    c = ->
      e ||= 11
    

    yields an error that:

    the variable "e" can't be assigned with ||= because it has not been defined

    I think making a similar complaint about e += 1 would be sensible and consistent. Or all a op= b expressions should expand to a = a op b and be treated equally.


    If we look at the CoffeeScript source, we can see what's going on. If you poke around a bit you'll find that all the op= constructs end up going through Assign#compileNode:

    compileNode: (o) ->
      if isValue = @variable instanceof Value
        return @compilePatternMatch o if @variable.isArray() or @variable.isObject()
        return @compileSplice       o if @variable.isSplice()
        return @compileConditional  o if @context in ['||=', '&&=', '?=']
      #...
    

    so there is special handling for the CoffeeScript-specific op= conditional constructs as expected. A quick review suggests that a op= b for non-conditional op (i.e. ops other than ||, &&, and ?) pass straight on through to the JavaScript. So what's going on with compileCondtional? Well, as expected, it checks that you're not using undeclared variables:

    compileConditional: (o) ->
      [left, right] = @variable.cacheReference o
      # Disallow conditional assignment of undefined variables.
      if not left.properties.length and left.base instanceof Literal and 
             left.base.value != "this" and not o.scope.check left.base.value
        throw new Error "the variable \"#{left.base.value}\" can't be assigned with #{@context} because it has not been defined."
      #...
    

    There's the error message that we see from -> a ||= 11 and a comment noting that you're not allowed to a ||= b when a isn't defined somewhere.