23 Mar 2011

Flexible callback arguments

Recently, I released a small library called Nimble, which is an experiment in unifying a synchronous and asynchronous API. Its early days yet, but I thought I'd share my approach so others can explore the viability of the technique too.

I wanted to combine the functions of Underscore.js and my own Async library, so a single function can act both synchronously and asynchronously. This is easy enough to achieve by adding an optional callback to each function, but its not so simple when modifying the iterator. Below is an example of the various ways people might use a synchronous function:

_.map([1,2,3], function (value) { ... });
_.map([1,2,3], function (value, index) { ... });
_.map([1,2,3], function (value, index, arr) { ... });

You have the option of specifying all the arguments, or just the ones you need. However, with an asynchronous map, we need to pass in a callback. This is conventionally the last argument to a function in node.js. The problem is, we can't omit parameters from our iterator because we always need the last argument:

_.map([1,2,3], function (value, index, arr, callback) { ... });

That's looking a bit verbose! Most of the time, however, the arr and index arguments are not used. Becase of this, the original Async library's map function just used the following arguments for iterators:

async.map([1,2,3], function (value, callback) { ... });

That saves you some typing, but it's annoying being unable to use the other parameters. It also means the async API differs even more from the original synchronous one. My approach to this problem when writing Nimble was to inspect the iterator's arity. For those of you not familiar with the term, arity is the number of arguments a function accepts. To find out the arity of a function, just look at its .length property.

var fn = function (one, two, three) { ... };
// fn.length == 3

var fn = function (one) { ... };
// fn.length == 1

This works cross-browser, and allows us to modify the arguments we pass to the iterator. First, I define a list of the full arguments, then I remove elements not used by the async iterator and add a callback to the end:

var test = function (iterator) {
    // the full list of available arguments
    var args = ['value', 'index', 'arr'];

    // remove the unused arguments
    args = args.slice(0, iterator.length - 1);

    // add the callback to the end
    args.push('callback');

    // run the iterator with the new arguments
    return iterator.apply(this, args);
};

console.log('Example one:');

test(function (value, index, arr, callback) {
    console.log(value);
    console.log(index);
    console.log(arr);
    console.log(callback);
});

console.log('Example two:');

test(function (value, callback) {
    console.log(value);
    console.log(callback);
});

Running this file would result in the following output:

Example one:

value
index
arr
callback

Example two:

value
callback

As you can see, we're now able to vary the number of arguments a function accepts, and the callback is always just the last argument accepted. This is not without its problems however. If an iterator does not define its arguments in the normal way, and instead uses the arguments object, we won't know how many arguments to provide.

test(function () {
    console.log(arguments[0]);
    console.log(arguments[1]);
    console.log(arguments[2]);
    console.log(arguments[3]);
});

Would give the following output:

value
index
callback
undefined

Ok, that's not what we'd expect. What you would probably expect in this circumstance, is for the whole list of possible arguments to be passed in. Lets update our test function to handle iterators with an arity of zero.

var test = function (iterator) {
    // the full list of available arguments
    var args = ['value', 'index', 'arr'];

    if (iterator.length) {
        // remove the unused arguments
        args = args.slice(0, iterator.length - 1);
    }

    // add the callback to the end
    args.push('callback');

    // run the iterator with the new arguments
    return iterator.apply(this, args);
};

You should now see the following output when running the example above:

value
index
arr
callback

Success! If you want to see the above technique in action, check out the Nimble library. I'd also like to hear about any potential issues introduced by this idea in the comments.