Beware of CoffeeScript Comprehensions
I've recently spent some time addressing bugs and speed issues with the first large CoffeeScript project (nearly 10,000 lines of code) I've worked on with multiple developers.
For the most part, the process was painless. CoffeeScript hides a great deal of complexity beneath it's glossy syntax, which is great for writing simple and readable code. But there is always tension between high-level languages and raw performance. If you're an experienced Javascript developer, the compiled output of a snippet of CoffeeScript code should be recognizable, readable and pretty much what you'd write if you wrote Javascript directly.
Here's an example:
list = [1..3]
console.log(i) for i in list
Becomes:
var i, list, _i, _len;
list = [1, 2, 3];
for (_i = 0, _len = list.length; _i < _len; _i++) {
i = list[_i];
console.log(i);
}
Great, a simple for loop which avoids recalculating list.length
on every iteration. However, CoffeeScript also overloads the for
loop with the ability to do Comprehensions. Comprehensions allow you to manipulate the items being iterated over, provide a native way of running map
, filter
, reject
and other manipulative functions on a collection. Whether a for
loop acts as a comprehension is dependent on context.
Here are some examples:
Assigning a for
loop to a variable:
list = [1..3]
output = for i in list
console.log(i)
Becomes:
var i, list, output;
list = [1, 2, 3];
output = (function() {
var _i, _len, _results;
_results = [];
for (_i = 0, _len = list.length; _i < _len; _i++) {
i = list[_i];
_results.push(console.log(i));
}
return _results;
})();
Notice that we now create an empty array to hold the result of the comprehension, then the last line of the for
loop is used as the value which is pushed into that array. Additionally, there is a wrapping anonymous function which immediately executes to keep some of the loop variables from leaking out of their scope.
This result can also be triggered as a one-liner wrapped in parenthesis:
list = [1..3]
output = (console.log(i) for i in list)
For the most part, this makes sense. You are requesting an output variable, so one is created, even if the results aren't meaningful (just the return value of console.log
). I would like to point out that this code will create 1 anonymous function every single time it is executed. If this loop were used frequently enough, such as inside a setInterval
or requestAnimationFrame
loop, it could begin producing and throwing away up to 60 anonymous functions per second, per usage. Eventually, the Javascript Garbage Collector will cleanup these unused functions, causing the framerate to stutter.
If you want to avoid this, you can write the push
portion of your array building manually:
list = [1..3]
output = []
output.push(i+1) for i in list
Becomes:
var i, list, output, _i, _len;
list = [1, 2, 3];
output = [];
for (_i = 0, _len = list.length; _i < _len; _i++) {
i = list[_i];
output.push(i + 1);
}
Implicitly Returning Comprehensions
Here's where you need to pay close attention. If you have a for
loop as the last piece of code in a function, it will be used as the return value and generate a resulting array, even when not explicitly requests.
For example:
printI = ->
list = [1..3]
console.log(i) for i in list
Becomes:
var printI;
printI = function() {
var i, list, _i, _len, _results;
list = [1, 2, 3];
_results = [];
for (_i = 0, _len = list.length; _i < _len; _i++) {
i = list[_i];
_results.push(console.log(i));
}
return _results;
};
In our codebase, this happened very often. The above code is simply for debugging, there is no need to create, build and return an array every single time it is called. Our code had a render tree, on every frame, we would render the root object, then use a for
loop to render each of that object's children. Which means we were building and discarding these implicit comprehension arrays once for every single drawn component in our system every single frame. It adds up.
Solution
My suggestion is to document all method return values and set them explicitly when writing CoffeeScript. Here is how the above methods should look:
# Log each item in the array
#
# list - An array of integers to be logged
#
# Returns undefined.
printI = (list) ->
console.log(i) for i in list
undefined
printI([1..3])
Which becomes a simple loop with no return value:
var printI;
printI = function(list) {
var i, _i, _len;
for (_i = 0, _len = list.length; _i < _len; _i++) {
i = list[_i];
console.log(i);
}
return;
};
printI([1, 2, 3]);
Check Your Own Code
Try searching through your output Javascript for return _results
, that will reveal if your code might be accidentally returning unnecessary comprehensions.