从协程到状态机--regenerator源码解析(一)

前言:


本系列文章主要分析facebook/regenerator的实现原理,该模块将ES6的generator函数转化为普通ES5的函数。Babel就是靠它来转化generator函数与async函数,可见它的使用之广泛。

虽说如此,网上关于这样一个重要的基础工具库源码的解析的文章少之又少,而源码里的注释也不够详尽,文档也缺乏其实现原理的解释。因此,我希望这篇以及之后的几篇文章可以帮助大家了解regenerator库究竟是如何将ES6的generator函数和async函数转化为ES5的函数的。

本系列文章需要读者有基础的JavaScript知识。


你需要知道的前置知识:


generator函数与协程:


在ES6规范中,generatror函数指用function*定义的函数,它的特点是在函数内部执行时可以暂时退出并在之后重入,在这过程中函数的上下文维持原装。这里举一个简单的例子:


function* abc() {
    yield 'a'
    yield 'b'
    yield 'c'
}



不同于常规函数要么不执行,要么就执行完,generatro函数内部的每一次yield就把控制权叫出来,因此在它之上可以很方便的写出一个协程(所谓的协程,就是非抢占的,会主动让出执行的运行单元————看,多么的像generator函数做的事)库来,比如co


goto语句和label


在javascript里是没有goto语句,下文为了方便说明,在伪代码里会用到goto,该语句含义和c语言的goto类似,表示立刻跳转到某个label处运行。


本文概要


作为系列的第一篇文章,本文介绍如何通过一定的步骤和规则,人工来把一个简单的generator函数转化成es5的函数。

下面我们就正式开始吧


人工转化的一个例子


我们从一个简单的generator函数开始转化,以下函数会返回10以内的偶数:


function* evenNumber() {
    for (let a = 0; a < 10; ++a) {
        if (a % 2 === 0) {
            yield a;
        }       
    }
}```

而我们最终需要完成的目标函数应该有这样的行为: 
```javascript
function evenNumber() {
    /*
     *  这里就是我们要填的代码
     */
     ......
}
const iter = evenNumber()
iter.next() //  { value: 0, done: false }
iter.next() //  { value: 2, done: false }
iter.next() //  { value: 4, done: false }
iter.next() //  { value: 6, done: false }
iter.next() //  { value: 8, done: false }
iter.next() //  { value: undefined, done: true }



当然,真正的es6的generator函数还要实现[Symbol.iterator]之类的我们就先不考虑。

标题已经剧透了,最终的函数会变成一个状态机,至于要细究为什么一个generator函数可以等价的变成一个状态机这种问题会让这篇文章变得奇怪起来,所以我们还是回归正题,来想一下这个状态机函数会长什么样子。

首先,它应该有个run函数,每次调用next的时候就应该跑一下run,然后,状态机的状态之类的应该作为函数的闭包内的变量,这样子可以使重入的时候有正确的上下文,简单的骨架如下所示:


function evenNumber() {
    let state = .... //表示状态机的状态
    function run() {
        while (true) {
            switch(state) {
                里面应该是一大堆的case语句
            }
        }

    }
    return {
        iter(){
            const value = run();
            const done = .... //done表示有没有完成
            return {
                value,
                done
            }
        }
    }
}



然后,就是怎么往run函数里面填case了,这也是整个状态机的核心所在。下面我们就来将如何找到状态机有几个状态。

首先我们对原来的函数需要做一些控制流的转化,先把for循环变成while 循环,大概就是这个样子:


let a = 0;
while (a < 10) {
    ...

    a ++;
}



接着,让我们心怀对dijkstra的愧疚,把while循环转成用goto语句的形式:


let a = 0;

label_loop:
if (a >= 10) {
    goto label_outer;
}
if (a % 2 === 0) {
    yield a;
}   
a++;
goto label_loop;

label_outer:
return;



然后,我们再做如下的转化:如果某个if语句块里有跳转语句,包括yield, break, return, continue等,就把这样的if语句拆开来,让if语句里只有一句goto。稍微了解过一点汇编的同学看了大概会比较眼熟,这就是把高级语言里的if变成汇编的条件跳转。拆完后的代码会变成这个样子:


function* evenNumber() {
    / * 代码块 0 */
    let a = 0;

    label_loop:
    /* 代码块1 */

    if (a >= 10) {
        goto label_outer;
    }

    if (a % 2 !== 0) {
        goto label_incr;
    } 

    yield a;

    label_incr:
    /* 代码块2 */

    a ++;
    goto label_loop;

    label_outer:
    /* 代码块 3 */

    return;

}



这里代码好像变的有点奇怪的样子,但顺着整个逻辑走一下应该是没什么问题的。注意到,我们把转化后的函数用yield和label这两种语句的结束切分成不同的代码块,当然yield后紧接着一个label的时候不需要把空的代码块给分出来。这样子,代码就分成了4大块,这4个大块就是状态机的4种状态了。把这些分出来的代码块填到switch里,就会变成这个样子:


function evenNumber() {

    let state = 0; // 表示当前状态机的状态
    .....我们忽视掉需要写在前面一些其他的东西

    function run() {
        while (true) {
            switch (state) {
                case 0:
                    let a = 0; //这边当然不能用let写
                case 1:
                    if (a >= 10) {
                        state = 3;
                        break;
                    }
                    if (a % 2 !== 0) { 
                        state = 2;
                        break;
                    } 
                    state = 2;
                    return a;

                case 2:
                    a++;
                    state = 1;
                    break;
                case 3:
                    return stop();
            }       
        }       
    }

    ......我们忽视掉写在后面的一些其他东西

}



yield就是return,同时把state设置成下一个代码块的state,goto就是把state改成对应label的state,同时break,最后的return就是return stop()stop()怎么实现下文解释),非常的简单易懂,不是吗?

在人肉走run函数的时候,要注意下我们并没有在每一个case语句后写break,因此在匹配到某个状态后,会按照顺序fallback执行之后case语句,直到return或者break,这也是我们所期望的。

改成switch的样子后,我们发现函数内的局部变量定义还不能跨state保存:当case 0 里赋值的变量跑到case 1里然后被return之后,再跑run进入这串switch的时候就丢失了,这当然是不对的,因此,我们要把所有的局部变量都做提升,变成run函数外闭包的变量,这样子,对于原来局部变量的赋值或更改就会保留下来。

提升的过程也比较简单:


function evenNumber() {

    let state = 0; // 表示当前状态机的状态
    let a // 原来的局部变量变成闭包的变量


    .....我们忽视掉需要写在前面一些其他的东西
    function run() {
        while (true) {
            switch (state) {
                case 0:
                    a = 0; //变量的定义且初始变成单纯的变量定义
                case 1:
                    if (a >= 10) {
                        state = 3;
                        break;
                    }
                    if (a % 2 !== 0) { 
                        state = 2;
                        break;
                    } 
                    state = 2;
                    return a;

                case 2:
                    a++;
                    state = 1;
                    break;
                case 3:
                    return stop();
            }       
        }       
    }



    ......我们忽视掉写在后面的一些其他东西
}



解决了局部变量的问题后,接下来就是要返回一个可以next的东西了,这个东西的next函数要跑一下run,再把这次run完后return的结果返回,这样子,我们就可以借助闭包实现了函数重入的上下文。


function evenNumber() {

    let state = 0; // 表示当前状态机的状态
    let a // 原来的局部变量变成闭包的变量

    let done = false; 
    function stop() {
        done = true;
    }

    function run() {
        while (true) {
            switch (state) {
                case 0:
                    a = 0; //变量的定义且初始变成单纯的变量定义
                case 1:
                    if (a >= 10) {
                        state = 3;
                        break;
                    }
                    if (a % 2 !== 0) { 
                        state = 2;
                        break;
                    } 
                    state = 2;
                    return a;

                case 2:
                    a++;
                    state = 1;
                    break;
                case 3:
                    return stop();
            }       
        }       
    }
    return {
        next() {
            if (done) {
                return {
                    value: undefined,
                    done: true
                }
            }
            const value = run();
            return {
                value,
                done
            }
        }
    }
}



这样子我们就实现了一个简单的generator函数到es5函数的转换,没有任何难度吧。

通过以上的简单例子,人工转化一个generator函数需要这几个步骤:
0. 提升本地变量,每一个本地变量都要变成函数闭包外的变量,把变量声明语句变成变量初始化赋值语句
0. 找到含有跳转的控制流语句(包括循环语句),改写这些语句,变成goto语句
0. 把代码根据yield和label分成若干个部分,每一个部分就是状态机的一个状态。写个大的switch case,把这些代码填到case里
0. 把yield改写成改变state后return,goto就是改写成改掉state后break。
0. 补充完整代码,返回一个可以调用next,并且next会返回valuedone的东西。

regenerator转化generator函数的步骤也和上述我们人工转化非常类似。这其中,最复杂的是步骤2,regenerator库里代码量最多的文件也就是在处理这个步骤。

下篇文章会详细的讲解JavaScript里各种控制流语句怎么转化为goto语句。

编辑于 2018-06-01