We’ve been using CoffeeScript at Artillery for well over two years and we’re pretty satisfied with it. The language adds useful features to JavaScript like concise function declarations, the existential operator (?
), expressive loop syntax, and traditional-style classes, which have made development faster and easier. We’re usually coding with the underlying JavaScript in mind, and swapping between CoffeeScript in the editor and JavaScript in the browser hasn’t been a problem at all.
Unfortunately, sometimes the CoffeeScript compiler does things we don’t expect. Here are some of the surprises that we’ve encountered over the years.
Can you see what’s wrong with this code?
url = require 'url'
exports.doStuff = (obj) ->
result = url.parse obj.url
return result.host
exports.doMoreStuff = (obj) ->
results = []
for asset in obj.assets
url = asset.url
results.push url
return results
CoffeeScript doesn’t shadow symbols from outer definitions. Calling doStuff()
after doMoreStuff()
results in the error “Cannot read property ‘parse’ of undefined” because the top-level symbol url
gets redefined as part of the loop.
Our workaround is to always append lib
to top-level variables with common names (url
, path
, asset
, image
). During code review, the reviewer would probably suggest that the url
module be imported with the name urllib
to avoid the mistake.
CoffeeScript has powerful constructs for loops: You can use for item in items
to iterate over an array, for item, i in items
to iterate with the index, for key, value of obj
to iterate over the properties of an object, and for key of obj
to iterate over just the property names. So what’s wrong with this this code?
for id, player in obj
player.send 'hello'
Just iterating over a map of player ID to player objects and sending the players a greeting, right? But the above compiles to:
var id, player, _i, _len;
for (player = _i = 0, _len = obj.length; _i < _len; player = ++_i) {
id = obj[player];
player.send('hello');
}
By confusing the in
and of
operators, the program loops over the map as if it were an array. The map probably doesn’t have a length
property and this code probably results in the contents of the for loop not being executed, which is very difficult to debug.
What’s even worse is the opposite: iterating over an array like an object. For example:
for name of names
list.append "Player: #{ name }"
This compiles to the following:
var name;
for (name in names) {
list.append("Player: " + name);
}
This actually works, but iterating over an array using the in
operator is a very bad idea. There are edge cases where the loop may skip elements of the array or worse. Left uncaught, these mistakes hang around unnoticed until something weird happens months later.
That functions in CoffeeScript return implicitly is a long-debated sticking point. We’re leaning toward the side that thinks it’s a bad idea.
Annoyingly, if you don’t want your function to return anything in JavaScript, you have to add a return
in CoffeeScript. For example, when the following example is compiled, a return
is inserted before bar.quux()
. If we don’t want doStuff()
to return anything — say, if we don’t want to accidentally return a private value that someone might accidentally use later — we have to add an extra return
statement to the end of the function.
exports.doStuff = (obj) ->
bar = new Bar(obj.baz)
bar.quux()
That’s annoying, but it’s not the bad part. Consider this very-contrived class and method, and assume that the renderer is part of a complicated drawing pipeline and that draw()
gets called sixty times per second:
class Renderer
draw: ->
for entity in @entities
if @camera.frustum.contains entity.model.aabb
@device.drawModel entity.model
CoffeeScript compiles the draw()
method into:
Renderer.prototype.draw = function() {
var entity, _i, _len, _ref, _results;
_ref = this.entities;
_results = [];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
entity = _ref[_i];
if (this.camera.frustum.contains(entity.model.aabb)) {
_results.push(this.device.drawModel(entity.model));
} else {
_results.push(void 0);
}
}
return _results;
};
Because of implicit returns, the for loop becomes the return value, and this creates an array unnecessarily. Adding a return
fixes this, but doing so is an extra step that we often forget.
Our game engine uses a component-entity system where reusable behaviors are added to stateless entities in the form of “component scripts.” Components respond to events, such as when they’re added to the scene or when a frame tick occurs. Here’s a simplified version of our Defender
component, which is attached to any entity that takes damage from an attack:
# Components/Defender.coffee
OnInit: ->
@hp = 50
@isDead = false
console.log 'initialized:', this
OnDamageTaken: (value) ->
@hp -= value
if @hp <= 0
@isDead = true
This compiles to a single object, which becomes the prototype for all instances of the component which are attached to entities:
({
OnInit: function() {
this.hp = 50;
this.isDead = false;
return console.log('initialized:', this);
},
OnDamageTaken: function(value) {
this.hp -= value;
if (this.hp <= 0) {
return this.isDead = true;
}
}
});
Say you’re a sloppy typist like me and you accidentally dedent the console.log()
statement. This compiles to:
({
OnInit: function() {
this.hp = 50;
return this.isDead = false;
}
});
console.log('initialized:', this);
({
OnDamageTaken: function(damage) {
this.hp += damage;
if (this.hp <= 0) {
return this.isDead = true;
}
}
});
Assuming that the engine is eval
-ing the code and using the result, OnInit
never gets called! If you’re like me and you rarely need look at the compiled JavaScript, this problem can take a while to find.
We’ve mitigated a lot of these mistakes through peer code reviews and automated checks. We created a CoffeeScript style guide for ourselves which helps lessen ambiguity during code review reviews, and we’ve added CoffeeLint to our Git pre-commit hook, which enforces some of our style rules. In researching implicit returns I found a third-party rule which checks for implicit returns. We even have a few simple checks that make sure we don’t check in debugger
statements. All of the problems mentioned are certainly annoyances, but they’re not the end of the world.
Share: Y
blog comments powered by Disqus