async.js and promises
A friend of mine linked Callbacks are imperative, promises are functional: Node’s biggest missed opportunity blog post, during our own discussion about async.js vs promises.
I agree, that async.js lies on the lower abstraction level than promises. But if I'd be the one writing node.js low-level APIs, I'll pick callbacks. They aren't pretty, but continuation-passing style is still functional.
Why would I prefer callbacks for low-level APIs? Because they are so damn simple. Everything is explicit and visible. On the other hand there are lots of details hidden under promise abstraction. Promises/A+ specification tries to reveal how they should work though. Yet for user land application code I might prefer promises as it's much easier to compose them. And functions like then/node.liftAll make it easy to wrap modules using callback interface.
Promise type
The type signature of fs.readFile
is
readFile :: String -> (Either Error Buffer -> IO ()) -> IO ()
The promised version could be
readFile :: String -> Promise Buffer
Luckily, it's easy to make type synonyms in Haskell:
newtype Promise a = Promise ((Either String String -> IO ()) -> IO ())
or using the Continuation Monad
type Promise a = ContT () IO (Either String a)
async.map
Returning to the original post, it's unfair to compare async.map
with map
& list
. By the way, list
is called q.all
or when.all
in Q and when.js respectively.
More fair is to compare when.all
with async.parallel
. Slightly modifying the fs.stat
example:
var fs_stat = node.lift(fs.stat); // node = require("when/node");
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
var tmp = paths.map(oneparam(fs_stat));
var statsPromises = when.all(tmp);
statsPromises.then(function(stats) {
// use the stats
});
var fs_stat = _.curry(fs.stat, 2);
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
var tmp = paths.map(oneparam(fs_stat));
var statsCallback = _.partial(async.parallel, tmp);
statsCallback(function (err, stats) {
// use the stats
});
where
// helper to use with map
function oneparam(f) {
return function (x) {
return f(x);
};
}
As you can see, not so different. Source code layout is quite similar. The amount of boilerplate is same also.
However, what happens, and when is different.
Memoization of the results
James works thru a problem of avoiding hitting the file-system twice. You could reduce the problematic callback code and promised based solution into two snippets:
var callback = _.partial(fs.stat, "file1.txt"); // construct computation
callback(callbackify(console.log)); // run it and use the result
callback(callbackify(console.log)); // run it and use the result, again
and
var promise = node.lift(fs.stat)("file.txt"); // construct computation, and run it
promise.then(console.log); // use the result
promise.then(console.log); // use the result, again
where
function callbackify(fn) {
return function (err, res) {
if (!err) {
fn(res);
}
};
}
They look very similar, but their behaviour is different, as you can see from the comments.
Should we work in IO or Promise monad?
If we rewrite problematic callback code in Haskell, we will see the solution!
import Control.Monad.Trans.Cont
type Promise a = ContT () IO (Either String a)
-- We use this just to be clear
data Stats = Stats String
deriving (Show)
stat :: String -> Promise Stats
stat path = ContT $ statImpl path
statImpl :: String -> (Either String Stats -> IO ()) -> IO ()
statImpl path callback = do
-- cause a side-effect
putStrLn $ "stat " ++ show path
-- execute the callback
callback $ Right (Stats path)
promise :: Promise Stats
promise = stat "file.txt"
main :: IO ()
main = do
runContT promise print -- kind of promise.done(console.log)
runContT promise print -- but we are running the computation twice
The output is
stat "file.txt"
Right (Stats "file.txt")
stat "file.txt"
Right (Stats "file.txt")
We run the computation twice. We can rewrite the code, to have only one runContT
:
promisePrint :: (Show a) => a -> Promise ()
promisePrint x = liftIO (Right `fmap` print x)
work :: Promise Stats -> Promise ()
work pstats = do
s <- pstats
promisePrint s
promisePrint s
return $ Right ()
main :: IO ()
main = runContT (work promise) print
The output is
stat "file.txt"
Right (Stats "file.txt")
Right (Stats "file.txt")
Right ()
Similarly, the JavaScript example in the original post
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
async.map(paths, fs.stat, function(error, results) {
// use the results
});
fs.stat(paths[0], function(error, stat) {
// use stat.size
});
could be rewritten to:
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
function work(error, results) {
// use the results
foo(results);
// use only the first result
bar(results[0]);
}
// start the execution
async.map(paths, fs.stat, work);
Conclusion
I hope that the examples gave you some insights about promises and raw callbacks. As you see, using bare callbacks isn't that hard, if you know what you are doing.
And as mentioned in the introduction section, I'd prefer using callbacks for libraries' low-level API. They add no overhead, and you have all control about the execution. Also users of your library could select their promise library freely (without adding casting overhead).
For the application code, like the stat
examples, promises are better. You could avoid callback hell without promises. Just name your functions and avoiding nested callbacks. But promises give you nice (looking) abstraction, which is also easier to use, not forgetting nuances like always dispatching then
callbacks asynchronously.