• 漫谈程序控制流


    作者: Liu, Martin 

    前言

    随着JavaScript最新版本号ECMAScript2015(ES6)的正式公布,以及babel这样的ES6ES5的工具的慢慢成熟, 在真实产品里使用ES6已经全然可行了。

    JS的朋友们,是时候点开es6features看一下了。

    值得一提的是。ES6特性里居然包含尾调用优化(tailcall),真是要点个赞。然而,这并没有什么用。

     

    ES6Generator谈起

    ES6众多新特性里。Generator无疑是一个非常酷的东西。

    cool在哪里?看code:

    // : 下面code能够在chrome dev toolconsole里直接跑

    function* fib() { // function*来定义generator

      var pre =0, cur =1;

      for (;;) { // 无限循环

        var temp = pre;

        pre = cur;

        cur += temp;

        var reset =yield cur; // yield决定next()的返回值

        if (reset){

        pre =0, cur =1;

        }

      }

    }

    var g =new fib();

    console.log(g.next().value); // 1

    console.log(g.next().value); // 2

    console.log(g.next().value); // 3

    console.log(g.next().value); // 5

    console.log(g.next(true).value); // 1

    在这里,我们定义了一个fibonacci数列的generator, 每次调用next(), yield下一个数字; next的參数则会是yield表达式的值,比方next(true),  yield cur就返回true

    能够看到code里有一个无限循环,但并不会导致CPU hang, 由于每一次yield后,程序会暂停,而在next()之后又继续执行。

    而这,便是generatorcool的地方:它改变了程序的控制流

     

    简单来说。yield停止,next继续,于是通过这个规则,我们就能够控制程序的执行顺序。

    于是我们能够写出这种code:

    co(function* (){

        var data1 =yield ajax_call_1(); // 发出一个ajax请求

        console.log(data1); // 输出response

        var data2 =yieldajax_call_2(data1); // 发出还有一个请求

        console.log(data2); // 输出response

        var data3 =yieldajax_call_3(data1); // 发出还有一个请求

        console.log(data3); // 输出response

        ...

    });

     

    //----------------------------------

    // 下面为对照的callback写法

    (function(){

      ajax_call_1(function(data1){

          console.log(data1);

          ajax_call_2(data1, function(data2){

              console.log(data2);

              ajax_call_3(data2, function(data3){

                  console.log(data3);

                  ...

              });

              ...

           });

        });

    })();

    Co那一段看似同步的代码实际上是异步运行的,可是直观简单美丽。写起来能够谈笑风生,比callback不知道高到哪去(关于这一点,能够看tj写的callbacks vscoroutines)

    至于那个co,就是某个奇怪的函数,在适当的时候(比方callback)调一下nextgenerator继续跑。感兴趣的话。请搜索co

     

    如今让我们总结一下: generator是一种特殊的子程序。而yield是一种流程控制指令,在generator里使用yield会将程序的控制权交还给调用者(即返回调用处),而外界调用generatornext方法会让该generator继续运行。generator能够用来做iterator,也能够用来玩魔法(同步转异步),由于它提供了一种较为优雅的流程控制方式

     

    只是。说是magic。但事实上程序的世界。并没有无根之木、无源之水。

    接下来,就让我们回溯本源,探一探各种流程控制结构的来龙去脉

    关于控制流(control flow)

    所谓控制流,说白了就是程序运行的顺序。

    我们知道,程序运行的基本原理是:cpuprogram counter(一个寄存器)拿指令的内存地址,然后去内存拿指令来运行,运行过程中会改变program counter的值(比方加1,也就是顺序运行)。如此循环往复直至结束。

    程序流程的控制,实际上就是在特定的情况下,更新特定的值到program counter。而上升到编程的层次,则是提供代表特定策略的流程控制语句,用以实现各种丰富的功能。

    一般而言。流程控制语句能够分为下面几类:

    • 无条件分支

    就是goto了。想去哪去哪(当然还是有限制的。比方c语言里goto就不能跳出当前function)。其缺点在于,程序可读性/可维护性easy变得极差。

    • 条件分支

    这个事实上就是大家耳熟能详的各种基本流程控制语句了,比方if-else, switch,以及for, whileloop语句

    • 无条件终止程序

    比方exit, return

    • 执行位在不同位置的一段指令。但完毕后会继续执行原来要执行的指令

    包含子程序(subroutine)、协程(coroutine)及延续性(continuation)。

    (注:generator实际上是一种coroutine)

    前三种比較直白简单。也比較常见,我们主要看第四种。

    执行还有一段指令。然后返回原指令段中继续执行

    这样的情况最常见的就是subroutine(比方function或者OO语言里的method)了。一般都是通过call stack来实现,每次function call都产生一个stack frame压入栈顶,该function结束时将其出栈,这样栈顶就变回其callerstack frame了,于是能够继续运行caller的代码。

    我们看一下典型的subroutine调用:

    function doOtherthing(){

        // block B

        {

            console.log("executing...doOtherthing");

            return;

        }

    }

     

    function doSomething(){

        // block A

        {

            console.log("executing...doSomething");

        }

     

        doOtherthing();

     

        // block C

        {

            console.log("continueexecuting...doSomething");

            return;

        }

    }

     

    doSomething();

    // executing...doSomething

    // executing...doOtherthing

    // continue executing...doSomething

    这里没什么奇怪的东西。分成几个block仅仅是为了方便引用,相信学过几天编程的都能理解。

    如今我们从控制流的角度分析一下这个程序。block B明显与AC不在一处,但运行顺序却是A=>B=>CA运行后是subroutine调用。jumpB处运行;而B运行后会返回到C处运行,这也正是return语句的语义。

    subroutine调用的运行顺序是固定的。这是由于return是一个keyword,提供隐式的流程控制。我们并不能像操作一个object一样来操作它——等等,假设能够呢?

    假设return的语义能够被抽象出来并能在程序中操作,那么我们将能够保存随意的运行点。而且在随意时候返回该处继续运行。

    这句话的意思是,我们的程序将能够实现随意的控制流。而无需运行环境的支持,比方说我们能够在ES5里实现generator

    你也许已经听过这样的抽象的名字:continuation

    Continuation

    在计算机科学里,Continuation是指能够被编程语言訪问的、对程序控制流程/状态的抽象表示。简单来说,就是程序执行时的某个执行点,比方上文所说的block B里的return执行后的那个点。

    return语句能够理解为隐式的调用了currentcontinuation

     

    我们说current continuation, 是指在那个点之后将要运行的代码,比方B return时。currentcontinuation就是整个block C

     

    说到continuation,就不得不说CPS(continuationpassing style),顾名思义,就是显式的将continuation作为參数传递,以此来进行流程控制。

    而我们寻常写的code叫做direct style,比方上文doSomething那段code

    我们如今将之前的code改写成CPS:

    functiondoOtherthing(k){

        // block B

        {

            console.log("executing...doOtherthing");

            k();

        }

    }

     

    function doSomething(k){

        // block A

        {

            console.log("executing...doSomething");

        }

     

        doOtherthing(function(ret){

            // block C

            {

                console.log("continueexecuting...doSomething");

                k();

            }

        });

    }

     

    doSomething(function(){});

    // executing...doSomething

    // executing...doOtherthing

    // continue executing...doSomething

    改写后运行结果是一样的。能够看到函数调用变成了callback的形式,而return都变成了k()。这个k就是传入的continuation

     

    注意。这里的重点并不在于callback形式。而在于CPS变换,之前的code和这段code是等价的。在这里我们是手动做的CPS变换,但实际上,全部direct stylecode都能够被自己主动变换成CPScode(至于怎么变换。能够尝试看How to compile with continuations)

     

    为何要强调自己主动的CPS变换?由于它能够用来实现一个瑞丽无匹的强大函数call/cc

    call/cc

    scheme语言里有一个著名的函数,叫做call-with-current-continuation, 一般简称为call/cc

    call/cc接受一个函数作为參数。并捕捉current continuation然后将之传递给这个函数。而continuation一被调用,call/cc马上返回,返回值即为传给continuation的參数。
    比方:

    (let ((a (call/cc

              (lambda (k)

                (begin

                  (display"will execute ") ; 输出 "will execute "

                  (k 1)

                  (display"will not execute")))))) ; 不运行

      (display a)) ; 输出1

    再来段JS的版本号,JS里当然是没有call/cc的了。只是这最好还是碍我用JS来表达。此处如果js里有一个等价的callcc

    var a = callcc(function(k){

        console.log('will execute'); // 输出 "will execute"

        k(1);

        console.log('will not execute'); // 不会运行

    });

     

    console.log(a); // 输出1

    这段code等同于:

    (function(k){

        console.log('will execute'); // 输出 "will execute"

        k(1);

    })(function(a){

        console.log(a);

    });

    这实质上就是自己主动做了CPS变换。

    有了call/cc。就能够直接在程序里操作continuation了。仅从功能上来说,就能够实现各种高级控制流,而不须要编译器/解释器这个level的支持了。

    接下来就让我们看看call/ccpower

    Coroutine

    Coroutine(协程)是一种类似subroutine但更灵活的控制结构。它同意有多个程序进入点,能够任意暂停继续运行,主要用来做nonpreemptivemultitasking(非抢占式多任务处理)

     

    coroutine中,能够通过yield语句来转移控制权,比方两个coroutinec1c2。在c1中调用yield to c2(伪代码), 就会去运行c2,在c2中又调用yield to c1,就会继续运行c1(又一次进入之前的运行点)

    听起来和之前说的generator有些像?事实上generator就是一种coroutine。我们之后会讲到。

     

    Donald Knuth说:"subroutinecoroutine的特例",由于subroutine能够看作不使用yieldcoroutine

     

    我们说coroutine是用来做nonpreemptive multitasking(非抢占式多任务处理)的。要理解这一点,最好先理解preemptivemultitasking(抢占式多任务处理)

    我们熟知的基于多线程的多任务/并发处理就是preemptive, 程序控制权由调度器而非程序自身来决定。实质上就是在程序外部强行打断程序的执行,再依据某种策略(优先级,动态时间片)决定由哪个线程继续执行。

    coroutine是自已决定将控制权交给谁(yield)。因而不会有race condition, 不须要考虑锁的问题,能够极大的简化并发编程。

    这里要提一下为何我们熟知的是抢占式多任务处理,由于人们须要流畅的同一时候做多件(不相关的)事的能力,比方在上网时下载和听音乐,而抢占式的多任务处理有助于实现这一点(不会由于控制权被占用而导致其他应用hang)。而编程时关注的是怎样更高效的做好事情,而且开发人员知晓所有的context。也就easy明确怎样去协调控制权,所以从编程的角度。非抢占式多任务处理反而更有优势。

    而从详细实现的角度来看,coroutine通常是语言级别的实现,实际上是在用户态进行上下文切换,不会陷入内核态,因而更高效。

    coroutine的缺点是无法利用多核。它仅仅能做concurrency,而不能做parallelism,由于一般它是跑在一个线程上。多个coroutine不能同一时候执行。可是也有改进的方案。比方go语言的goroutine, 就是work在一个线程池之上的。只是这样就须要更复杂的调度了,当然名字也华丽的变了。

    下面是用callcc实现简单的协程(只是不能执行):

    var queue = [];

    function isEmpty(){

        return queue.length==0;

    }

    function enqueue(x){

        queue.push(x);

    }

    function dequeue(){

        return queue.shift();

    }

    function run(f){

        callcc(function(k){

            enqueue(k);  // current continuation enqueue

            f();

        });

    }

    function $yield(){

        callcc(function(k){

            enqueue(k);

            dequeue()(); // dequeue某个continuation并运行

        });

    }

     

    functiondoSomething(str){

        for(;;) {

            console.log(str);

            $yield(); // 放弃控制权

            // point C

        }

    }

     

    run(function(){

        doSomething("A");

    });

     

    // point A

    run(function(){

        doSomething("B");

    });

     

    // point B

    if(!isEmpty){

        dequeue()();

    }

     

    // 理论上的输出结果为

    // A

    // B

    // A

    // B

    // ..., AB交替输出

    简单描写叙述一下程序的运行:

    1. 运行第一个run
      • continuation指向point A。然后它被enqueue, 此时queue为[A]
    2. 运行doSomething("A")
      • 输出A
      • 运行$yield()
        • 将当前continuation enqueue, 此时queue为[A, C-A]
        • dequeue并运行。此时queue为[C-A], 从point A处运行
    3. point A, 运行第二个run
      • continuation指向point B, 然后它被enqueue。此时queue为[C-A, B]
    4. 运行doSomething("B")
      • 输出B
      • 运行$yield()
        • 将当前continuation enqueue, 此时queue为[C-A, B, C-B]
        • dequeue并运行,此时queue为[B, C-B], 从C-A处运行
    5. C-A处
      • 输出A
      • 运行$yield()
        • enqueue, queue为[B, C-B, C-A]
        • dequeue并运行。此时queue为[C-B, C-A], 从B处运行
    6. B处,dequeue并运行。从C-B处运行,输出B,并enqueue, 此时queue为[C-A, C-B]
    7. [C-A, C-B] -> [C-B, C-A] -> [C-A, C-B] 循环, A和B交替输出

    可见通过callcc和一个queue。我们能够轻易的实现coroutine

    Generator

    Generator又叫Semi-Coroutine(半协程)Asymmetric Coroutine(不正确称协程),它本质上仍是协程,和一般的协程的差别在于,generator仅仅能把控制权交还给它的caller, coroutine是能够决定把控制权交给谁。

    先看一个callcc实现的generator:

    function fib(){

        var controlState =function($yield){

            var pree =0;

            var pre =1;

            while (true){

                callcc(function(resume){

                    controlState = resume;

                    $yield(pree);

                });

                var tmp = pree;

                pree = pre;

                pre = tmp + pre;

            }

        };

     

        return {

            next:function(){

                return callcc(controlState);

            }

        };

    }

    next调用时,进入controlState函数。$yield一旦调用,立即返回,可是controlState已经被替换成内部循环处的continuation,因而当next再调用时会回到循环处继续运行。

     

    事实上generator能够和coroutine互相转化,由于它们本质上是一样的东西。generator加一个scheduler就能够实现coroutine(yield一个value, 然后依据value决定resume哪个generator)

    来一个用ES6 generator模拟couroutine的样例(能够在chrome dev tool里执行):

    function* ge1(){

        for (var i =1; ;i++){

            console.log('running...generator1, '+ i +' times');

            yield'g2';

        }

    }

     

    function* ge2(){

        for (var i =1; ;i++){

            console.log('running...generator2, '+ i +' times');

            yield'g1';

        }

    }

     

    function schedule(){

        var map = {

            'g1': ge1(),

            'g2': ge2()

        };

        var current ='g1';

        for(var i =0; i <100; i++) {

            current = map[current].next().value; // current 'g1''g2'间来回变化

        }

    }

     

    schedule();

    Delimited Continuation

    关于continuation, 另一个值得一提的是DelimitedContinuationScala里就支持这样的continuation

    它是在一个限定的区域里,捕捉continuation并详细化成一个函数。以供复用。通常是通过reset+shift来表达:

    (reset

     (display (*2 (shift k

                          (k 2)

                          (k 4)

                          (k 3)))))

    JS来翻译一下:

    reset(function(){

        var x =shift(function(k){

            k(2);

            k(4);

            k(3);

        });

        console.log(2* x);

    });

    // 4

    // 8

    // 6

    再翻译成CPS

    (function(k){

        k(2);

        k(4);

        k(3);

    })(function(x){

        console.log(2* x);

    });

    如今应该非常好理解了,类似于call/cc的情况。只是用reset限定了一个scopeshift之后reset之内的代码捕捉并封装成一个函数,然后传递给shift块里的那个函数。

    这里一个要注意的点是。shift里的k是一个函数。所以它能够多次使用;而call/cc里的k调用一次就退出了,后边的都会ignore

    从这个角度而言。delimitedcontinuation是纯粹的函数,而undelimited continuation不是。因而delimited continuation更直观更符合直觉,也就更适合我们用来编程

    最后

    碍于能力以及篇幅,本文仅是对程序控制流的浅尝辄止。

    纵观而言。各种高阶的流程控制结构,都与continuation相关,这是由于continuation是对(隐式的)控制流本身的抽象。

    只是现代的高级语言里。一般不直接提供first-classcontinuation, 而是提供如generator,coroutine甚至delimited continuation等的高阶控制结构,由于它们足够强大而又相对call/cc更可控更易于理解。

    而它们的实现也不会是像我这里所写的那样简单,甚至也不一定是基于call/cc去实现。然而其基本原理是一致的。因此理解continuation, 理解CPS,理解call/cc,将有助于更好的玩转各种流程控制。

    References:

    https://github.com/lukehoban/es6features

    https://medium.com/@tjholowaychuk/callbacks-vs-coroutines-174f1fe66127

    https://en.wikipedia.org/wiki/Control_flow

    https://en.wikipedia.org/wiki/Continuation

    https://en.wikipedia.org/wiki/Continuation-passing_style

    https://en.wikipedia.org/wiki/Generator_(computer_programming)

    https://en.wikipedia.org/wiki/Coroutine

    https://en.wikipedia.org/wiki/Delimited_continuation
  • 相关阅读:
    Struts2之Domain Model(域模型)。
    struts2接收参数的5种方法
    java泛型中特殊符号的含义
    @value取值
    Spring分页实现PageImpl<T>类
    eclipse快捷键整理
    String字符串的截取
    Java调用ASP.NET的webservice故障排除
    根据wsdl文件用soapUi快速创建webService服务(有图有真相)
    @Autowired标签与 @Resource标签 的区别
  • 原文地址:https://www.cnblogs.com/liguangsunls/p/7347012.html
Copyright © 2020-2023  润新知