Skip to content

Difference from Promise

winterland edited this page Oct 25, 2015 · 10 revisions

Action.freeze

After reading introduction to Action, now you must have one question, let's use the example from FAQ section:

new Action(function(cb){
    readFile('fileA', function(err, data){
        if (err){
            cb(err);
        }else{

            cb(data);
        }
    });
})
.go(processOne)
.next(processTwo)
.go()

Above code will throw an Error, because go will not return a new Action, thus it will break the chain, but what if we want to fire an Action and return a new Action so we can continue adding callbacks to the chain? Let's meet the Action.freeze:

Action.freeze = function(action) {
    var callbacks, data, pending;
    // a state flag to mark if the action have returned
    pending = true;
    // a variable to hold the action data when action returned
    data = void 0;
    // during pending stage, all callbacks are saved to an Array
    callbacks = [];

    // Let's fire the missle
    action._go(function(_data) {
        var cb, j, len;
        // when this callback are reached
        // we mark the pending flag false save the data, and send data to callbacks
        if (pending) {
            data = _data;
            pending = false;
            for (j = 0, len = callbacks.length; j < len; j++) {
                cb = callbacks[j];
                cb(_data);
            }
            // release previous callbacks reference
            return callbacks = void 0;
        }
    });
    // Return a new `Action` immediately
    return new Action(function(cb) {
        if (pending) {
            // during pending stage, we can't give cb the data they want
            // so we save them, wait action returned
            return callbacks.push(cb);
        } else {
            // after pending stage, we already have the data
            // we directly give it to cb
            return cb(data);
        }
    });
};

Action.freeze fire the Action it received, create an new Action with two state, during pending stage, if this new Action are fired, we save the callbacks without feeding them value, until pending finish and we get the value, then we feed the value to previous callbacks we saved, after pending finished, any further callbacks will be feed the same value.

In another word, Action.freeze add Promise semantics to an Action.

fileAFreezed = Action.freeze(new Action(function(cb){
    readFile('fileA', function(err, data){
        if (err){
            cb(err);
        }else{

            cb(data);
        }
    });
}))

// at this point, 'fileA' is already been reading
// before reading is complete, processA will be saved in an Array
fileAFreezed
.next(processA)
.go()

...

// once `fileA` are read, any further callbacks will get the same fileA immediately
fileAFreezed
.next(processB)
.go()

Why not add Action.prototype.freeze() so we can chain it, you may ask. The answer is, this's where i think Promise went wrong, most of the time we don't need to be that stict, creating internal state and arrays just to throw them away after using, use Action.freeze will

  • Encourage user write more lazy code.

  • Make a memorized Action explicit.

Different semantics

From Action.freeze, we get a big picture how Action and Promise differ, let's check another example from FAQ;

Action.retry = function(times, action) {
    var a;
    return a = action.guard(function(e) {
        if (times-- !== 0) {
            // see how we reuse a here
            return a;
        } else {
            return new Error('RETRY_ERROR: Retry limit reached');
        }
    });
};

Action.retry(3, new Action(function(cb){
    readFile('fileA', function(err, data){
        if (err){
            cb(err);
        }else{

            cb(data);
        }
    });
}))
.next(processA)
.guard(function(e){
    if (e.message.indexOf('RETRY_ERROR') === 0)
        console.log('Retry read fileA failed after 3 times');
})
.go()

The code above will retry readFile fileA at most 3 times, the retry function recursively return Action a to perform the same action. Now consider how to do it using Promise:

Promise.retry = function(times, promiseFn) {
    var p = promiseFn()
    while(times--){
        // every time we failed, we use promiseFn to create a new Promise
        p = p.catch(promiseFn);
    }
    return p.catch(function(e) {
        throw new Error('RETRY_ERROR: Retry limit reached');
        // or use Promise.reject
    });
};

Promise.retry(3, function(){
    return new Promise(function(resolve, reject){
        readFile('fileA', function(err, data){
            if (err){
                reject(err);
            }else{

                resolve(data);
            }
        });
    }) 
}
.then(processA)
.catch(function(e){
    if (e.message.indexOf('RETRY_ERROR'))
        console.log('Retry read fileA failed after 3 times');
})

Now it's clear, you can't retry a Promise, since a Promise just resolve once, you have no way to reuse it, so we use a PromiseFn() to make a new Promise everytime. This means every retry you have to creating a internal state, array, etc and throw them away when they finish, instead of focusing on the value produced in the future by action like Promise, Action focus on the action itself, then provided a way to save callbacks before action finishes, while next provided a way to compose a callback with previous continuation and produced a new continuation waiting for next callback.

That's why i seperate Action.freeze, most of the time, you don't need such a strict behavior, all you want is building a callback chain with proper error handling. So with Action:

  • You can run the Action when you want to, it won't be scheduled to nextTick.

  • You can run any times you want, you can even return Action itself inside its next to do recursive action.

  • With Action.freeze, you get both of the world, Action is strictly powerful than Promise because we can implement Promise semantics on top of Action, but we can't do other way around.

Different cost

Let's use a simple Promise without reject as an example from Q

var isPromise = function (value) {
    return value && typeof value.then === "function";
};

var defer = function () {
    var pending = [], value;
    return {
        resolve: function (_value) {
            if (pending) {
                value = ref(_value); // values wrapped in a promise
                for (var i = 0, ii = pending.length; i < ii; i++) {
                    var callback = pending[i];
                    value.then(callback); // then called instead
                }
                pending = undefined;
            }
        },
        promise: {
            then: function (_callback) {
                var result = defer();
                // callback is wrapped so that its return
                // value is captured and used to resolve the promise
                // that "then" returns
                var callback = function (value) {
                    result.resolve(_callback(value));
                };
                if (pending) {
                    pending.push(callback);
                } else {
                    value.then(callback);
                }
                return result.promise;
            }
        }
    };
};

var ref = function (value) {
    if (value && typeof value.then === "function")
        return value;
    return {
        then: function (callback) {
            return ref(callback(value));
        }
    };
};

Without reject, every time you create a Promise, you create a internal varible to hold the resolve result, and an Array to hold callbacks. That's the same cost when you call Action.freeze.

When creating an Action, you save the reference of the go, that's all, creating Action are cheaper.

Action essence

Skip this part if it puzzles you.

In some FP languages continuation are used to express complex control structure, but they are not used to perform implicit parallel computations, consider following haskell values:

one :: Int
one = 1

oneCPS :: (Int -> a) -> a
oneCPS f = f 1

oneCPS (+1)
-- 2

Everytime we make something with type (a -> r) -> r from something with type a, we make a CPS transfrom, async function in javascript is exactly the same:

var file = readfileSync('data.txt')

var fileCPS = function(cb){
    readfileSync('data.txt', cb);
}
fileCPS(function(data){
    console.log(data);    
})

In haskell use ConT monad to wrap function with (a -> r) -> r type, note how fmap and >>= works for ConT:

instance Functor (ContT r m) where
    fmap f m = ContT $ \ c -> runContT m (c . f)

instance Monad (ContT r m) where
    return x = ContT ($ x)
    m >>= k  = ContT $ \ c -> runContT m (\ x -> runContT (k x) c)

It's exactly how Action.prototype.next deal with a nest Action:

Action.prototype._next = function(cb) {
    var self = this;
    return new Action(function(_cb) {
        return self.action(function(data) {
            var _data = cb(data);
            if (_data instanceof Action) {
                return _data._go(_cb);
            } else {
                return _cb(_data);
            }
        });
    });
};

We just use instanceof to dynamicly decide we want a fmap or >>=.

Since haskell use light weight thead to deal with parallel IO, the code oneCPS (+1) above will not return before (+1) finish, but consider above javascript functions:

fileCPS(function(data){
    console.log(data);    
})

fileCPS(...) returned immediately, without running console.log at all, and we only know console.log will run sometime later, this is how parallel IO works in javascript, by keep a threading pool in background, a function doesn't have to return when finish.

Certainly this's behavior is simple to understand at first, it create a lot of more problems, while i borrow a lot idea from haskell, some combinators are especially tricky, for example, the Action.freeze actually are js version of call/cc, since the (a -> r) -> r function in javascript are async, Action.freeze got a different semantics.

What's that semantics? Oh, it's just Promise.

Clone this wiki locally