jscex 原理探析

2010-12-28 05:37

jscex 原理探析

by Belleve Invis

at 2010-12-27 21:37:13

original http://typeof.net/2010/12/a-simple-study-on-jscex-mechanism/

这是我第一次给别人的类库写文章,说实话,用 Lofn 中的阻塞原语生成的代码可不是很容易看懂(虽然代码很好写……),相比之下,老赵jscex 要好用得多。虽然 jscex 还没有完成(很多语法都处理不了),不过呢,第一,他已经大致能投入使用;第二,说说总比没有好,而且这个很有潜力的东西还是需要来推荐的。

相信无数的 JSER 都有过这样的经历,动画,然后用 while 循环来做,结果在浏览器卡了一小会有,要运动的元素直接蹦到目标位置上去了。于是,不解,论坛提问,然后查 setTimeoutsetInterval 的案例,无数挫折后终于成功。jscex 给他们带来了曙光:只需要写个循环,循环里放个定时器回调,接下来一切事情由 jscex 解决。然后——哇,动起来了耶!

嘛,先看案例:

var moveAsync = eval(Jscex.compile("$async", function(e, startPos, endPos, duration) {
    for (var t = 0; t < duration; t += 50) {
        e.style.left = startPos.x + (endPos.x - startPos.x) * (t / duration);
        e.style.top = startPos.y + (endPos.y - startPos.y) * (t / duration);
        $await(Jscex.Async.sleep(50));
    }

    e.style.left = endPos.x;
    e.style.top = endPos.y;
}));
ps. 等价的 lofn 阻塞原语代码
using async, sleep in library "async";
using asynchronous as async;
var moveAsync = asynchronous function(e, startPos, endPos, duration):
    for(var t = 0; t < duration; t += 50):
        e.style.left = ...;
        e.style.top = ...;
        await.. sleep 50;
    end;

    e.style.left = endPos.x;
    e.style.top = endPos.y;
end

和 lofn 的阻塞原语不同,jscex 生成的代码采用所谓“Monad”的技术,或者说是基于任务的异步模型。整个异步函数会被拆解成若干个任务,回调嵌入回调。只是,原来写 N 层回调的工作现在由编译器完成,不用人肉折磨。看看上面那段代码经过 jscex 倒腾过的结果:

function(e, startPos, endPos, duration) {
    return $async.Start(this, function() {
        return $async.Combine(
            $async.Delay(function() {
                var t = 0;
                return $async.Loop(
                    function() { return t < duration; },
                    function() { t += 50; },
                    $async.Delay(function() {
                        e.style.left = startPos.x + (endPos.x - startPos.x) * t / duration;
                        e.style.top = startPos.y + (endPos.y - startPos.y) * t / duration;
                        return $async.Bind(Jscex.Async.sleep(50), function() {
                            return $async.Normal();
                        });
                    })
                );
            }),
            $async.Delay(function() {
                e.style.left = endPos.x;
                e.style.top = endPos.y;
                return $async.Normal();
            })
        );
    });
};

这是那个 MoveAsync 函数应该有的样子,谢天谢地,我们有 jscex,否则肯定会被这一对 function 给搞晕死!

异步编程向来很难,这是真话,在几乎所有的语言/平台上,我们都必须面对成堆的回调,把原本清晰的流程拆的七零八落,因为异步程序通常是:先通知某个对象“我要点东西,你给我搞来”,然后——总不能就这样挂着吧!哦,于是,回调:在用 AJAX 的时候,我们是注册 onreadystatechange;在等用户反应的时候,我们用 onclick。用上这些事件的代码大致是这样:

// ......
someobj.onsuccess = function(data){
    process(data)
};
someobj.getData()

嗯,世界可没有那么美好,于是乎加上失败处理的代码:

// ......
someobj.onsuccess = function(data){
    process(data)
};
someobj.onfail = function(){
    alert("Fail!")
}
someobj.getData({timeout: 3000})

不过我们想要的是这样的代码:

var data = someobj.getData({timeout: 3000});
if(data){
    process(data)
}

问题的核心在于,someobj.getData 不能在当前的流程中形成阻塞,因为只是通知了 someobj “要个东西”,就忽闪而过了。上面的代码只是个很简单的案例,想一下,如果你要在一个循环中放上这样的 someobj.getData,逐个获取数据然后处理,那个复杂度,呵呵……

我们希望的是,能够用某种方法来让一个流程从中间暂停(至少要长得像是暂停),然后在某个时候继续。在一些语言(比如 lua)里面叫做协同例程。协同例程里面用 coroutine.yield() 就可以把它自己挂起,然后等外界消息。C# 提供的迭代器(yield)也有相同的功效,虽然要弱一点。

可惜,JavaScript 没有这种东西,我们不能在函数执行到半截的时候把它挂起,然后等到某个合适的时候继续。

遇到异步问题的可不是只有我和老赵,一个叫 Jasmine 的测试框架也遇到了异步的问题,他们的方法是用 DSL(源码来自官网):

describe('Spreadsheet', function() {
    it('should calculate the total asynchronously', function () {
        var spreadsheet = new Spreadsheet();
        spreadsheet.fillWith(lotsOfFixureDataValues());
        spreadsheet.asynchronouslyCalculateTotal();

        waitsFor(function() {
            return spreadsheet.calculationIsComplete();
        }, "Spreadsheet calculation never completed", 10000);

        runs(function () {
            expect(spreadsheet.total).toEqual(123456);
        });
    });
});

这样的处理方法不错,事实上,在我还没有给 lofn 做阻塞原语的时候,我也想过这样的代码:(我使用了 lofn 的惯用法:柯里化而非多参数)

describe("Spradsheet") {
    it("should aclculate the total asynchronously") asynchronous {
        var speadsheet;
        @run {
            spradsheet = new Spradsheet;
            spradsheet.fillWith lotsOfFixureDataValues();
            spradsheet.asynchronouslyCalculateTotal();
        }

        @waits spradSheet.calculationIsComplete, 
            timeout: 10000, 
            onTimeout: "Spradsheet calculation never completed"

        @run {
            expect(wpradSheet.total).toEqual 123456
        }
    }
}

首先,DSL 设计的自由度很大,不同人可能有不同的“DSL 接口”,相互之间难以互通(“倒腾”);其次,这代码还是被无数的 function 拆的七零八落,感觉还是不爽。

这时候,老赵横空出世,带着他高超的编译器技术来拯救人类!他的想法是:因为 JavaScript 本身动态性极强,语义分析可以说是天方夜谭,那痛苦自然难以避免。但是让人的痛苦减少到最低,总没坏处吧!于是他的 jscex 有这样的约定:

  • 用一个特殊的“关键字”(在异步调用中,是 $await)表示可能“有事情”(在异步调用中就是阻塞)的操作
  • 用编译器把普通函数转换为类似 DSL 的形式

jscex 的基本部分称为任务(虽然源码中没有显式出现这个词),一个任务的范式如下:

aTask = {
    start: function(callback){
        ......
        callback(result)
    })
}

start 方法接受一个函数作为回调,在捣腾一番之后调用这个回调。注意,回调只会调用一次,而且调用完,就称任务 完成 了,这个任务接下来的命运就是析构。下面是一个简单的任务实现:延迟调用回调 n 毫秒:

var sleep = function(dur){
    return {
        start: function(callback){
            setTimeout(function(){
                callback()
            }, dur)
        }
    }
}

// 使用
sleep(1000).start(function(){
    ......
});

既然有了这个简单的范式,自然可以想到,连续执行两个任务可以这样写(我实在是看不惯 continuus,所以 mizpell 回):

var continus = function(a, b){
    return {
        start: function(callback){
            a.start(function(){
                b.start(callback)
            })
        }
    }
}

Hmmmm,不难理解吧。类似的还可以做循环,用递归实现:

var loopWhile = function(conditionF, t){
    var loop = {
        start: function(callback){
            if(!conditionF())
                callback()
            else
                t.start(function(){
                    loop.start()
                })
        }
    };
    return loop;
}

这里假定 conditionF 是个简单的函数,不是任务。好吧,其实要是任务也很好做,这里利用了任务回调的回传:

var loopWhile = function(condition, body){
    var loop = {
        start: function(callback){
            condition.start(function(continueQ){
                if(conditionQ)
                    body.start(function(){
                        loop.start(callback)
                    })
                else
                    callback()
            })
        }
    };
    return loop;
}

为了增加任务的种类,这里做一个不像任务的任务:immediate,他的作用是“执行函数;然后执行回调,没有任何延迟”:

var immediate = function(f){
    return {
        start: function(callback){
            callback(f())
        }
    }
}

现在我们可以去对付最开始的案例了,不过呢,先考虑下最简单的状况吧:显示 123,间隔一秒。假定显示用 display 函数完成,在平常,用裸的 setTimeout 怎么做呢?嗯:

var play = function(){
    display(1);
    setTimeout(function(){
        display(2);
        setTimeout(function(){
            display(3);
        }, 1000)
    }, 1000)
}

在使用了上面的任务范式后,代码是这样:

var play = function(){
    continus(immediate(function(){display(1)}),
        continus(sleep(1000),
            continus(immediate(function(){display(2)}),
                continus(sleep(1000),
                    immediate(function(){display(3)}) ) ) ) ).start(function(){});
}
ps. 现在是看到 lofn 好处的时候了
var play = function:
    continus(immediate {display 1},
        continus(sleep 1000, 
            continus(immediate {display 2},
                continus( sleep 1000,
                    immediate {display 3} ) ) ) ).start(function{})
end

我了个去,这代码还复杂了呢,但是别看嵌套层数变大了,代码的“熵”(混乱度)实际上下降了。这种代码更适合用机器生成:jscex 就是生成这种代码(虽然具体细节不同)。而且这种长得很 lisp 的代码没有所谓的“数学的美感”吗?

好,上面的案例是一个异步方法“最应该有”的样子,不过显然可以优化。有没有感觉到最后那个小尾巴(.start(function(){}))很难看?确实,这个小尾巴很难看,而且怎么说也应该放在外面吧。然后,众多的 immediate 仍然很别扭。此外,我们真实写的代码肯定有复杂的控制流程——至少说得实现 return 吧,就是随时停止整个流程。

所以,前面任务的 callback 就要重构,参数从一个改成两个,第一个是流程状态:

var NORMAL = 0;
var RETURN = 1;

然后对应修改 continusimmediate

var continus = function(a, b){
    return {
        start: function(callback){
            a.start(function(phase){
                if(phase == NORMAL)
                    b.start(callback)
                else
                    callback.apply(this, arguments)
            })
        }
    }
}

var immediate = function(f){
    return {
        start: function(callback){
            callback(NORMAL, f())
        }
    }
}

loopWhile 也可以同样修改:

var loopWhile = function(condition, body){
    var loop = {
        start: function(callback){
            condition.start(function(phase, continueQ){
                if(phaseQ === NORMAL && conditionQ)
                    body.start(function(phase){
                        if(phase === NORMAL)
                            loop.start(callback)
                        else
                            callback.apply(this, arguments)
                    })
                else
                    callback.apply(this, arguments)
            })
        }
    };
    return loop;
}

解决问题要紧,就先不考虑什么异常、break 的问题了。为了消除 immediate,接下来是一个叫 bind 的函数,他是 continus 的变体,但是比 continus 有用得多:

// 定义
var bind = function(t, g){
    return continus(t, g())
}

做一个辅助函数 Normal

var Normal = function(){
    return {
        start: function(callback){
            callback(NORMAL)
        }
    }
}

可以推导:

immediate(f) === continus(immediate(f), Normal())
continus(t1, continus(immediate(f), t2) === bind(t1, function(){f(); return t2})

哈,这回里层的 immediate 彻底消失了!最外层的 immediate 也可以类似处理:

var flow = function(g){
    var t = g();
    return {
        start: function(callback){
            t.start(callback)
        }
    }
}
// 众人回应:直接 return g() 不就行了吗!

因此,前面的案例 play 就可以写成:

var play = flow(function(){
    display(1);
    return bind(sleep(1000), function(){
        display(2);
        return bind(sleep(1000), function(){
            display(3)
            return Normal()
        })
    })
})

play().start()

呼,代码清爽多了。

以上所写的这一些就是 jscex 的内核。下面比较下两者:

案例 jscex
bind Binder.bind
continus Binder.Combine
flow Binder.DelayBinder.Start (后者处理 this 指针的问题)
loopWhile Binder.Loop

可以看出,jscex 的 Binder 是一个更加灵活的方案。我所写的案例只是对 jscex 模型一个很粗略的模仿。jscex 通过 Narcissus 来解析已有的 js 代码,然后编译成使用了 Binder 的形式。jscex 源码中还包括了对 this 指针的处理、break 语句(在 Binder.Loop/loopWhile 里面加几个分支判断)、简单的异常,等等。

在最后,即使 jscex 现在还没有完成,但他仍然是很有潜力的。听说 lifesinger 也在做 async,还要用所谓的“新方法”,等时候到了写横评吧。