Loading…

题外话

选一张好的题图真是重要,可以不断push你把坑填了,让你下意识的希望写出一些出色的内容以期望其可以配得上这张优秀的照片。

好了,闲话挂起,一如既往,这篇「谈谈」我们来谈谈未来十年的ECMAScripht标准中一个新的且相当重要的实现。

什么是生成器

认识我的友人都知道,在我有限的时间里所玩过的几门语言中,对JavaScript我实际上是有偏爱的,但是对它的服务端平台Node.js却一直又爱又恨,以至于网上很多人建议学习Node.js作为第一门语言时,我经常是反而去推荐Python,原因无他,当Coder选择node.js作为平台来实现项目时,无非看中的就是它基于Reactor模型异步调用的高效,但这中间却需要编码人员付出相当的心血来维护随之而来的"副作用"——callback hell, aka 回调噩梦。

但随着ECMAScript标准的逐渐推进,在最新一版的ES6里出现了新的特性——Generator Function,通过其在流程控制中的特殊行为,可以使得编程者不再陷在回调地狱里难以自拔。

如果单纯从yield的行为与表现来看,简而言之,我们可以认为它是一个异步的return。

事实上,我认识yield这个关键词不可谓不曲折了,第一次见yield是在公司里写Lua脚本,彼时年幼,不知道coroutine是个什么玩意儿,生生的漏过了Lua蕴藏着的独一无二的宝藏,以至于当被callback hell嵌套的不能自拔,回过头来再看时,才发现Lua的协程实现又何止惊为天人。

后来陆陆续续的在玩Python、学C#里分别见到过yield的使用场景,而最后一次见才是ES6上的generator实现。而碍于智商有限,也费了不少功夫才完全理解yield的运作机制,传参原理,不过反过来想想control-flow本来在PLT里就是相对麻烦的课题。

一个标准的Generator Function会以如下模式运行:

function * Generator(){  
    console.log('output1')
    yield 1
    console.log('output2')
    return 2
}

var method=Generator()  
method.next() //  =>    Object {value: 1, done: false}  
method.next() //  =>    Object {value: 2, done: true}  

函数Generator在运行至yield时,会主动将控制权交换至外部环境并且保持当前函数的上下文环境,同时局部计算值会被以Object.value的方式被yield返回出去。而当外部环境以method.next()再次回呼时,生成器函数会重新获得控制权,并且顺序执行直到遇到下个yield为止,当函数在遇到return或者最后一个yield时,一如既往,整个函数体结束,函数体内对应的活动对象相继被回收。

Co函数解析

但仅仅有yield还不足以解决掉callback hell,TJ Holowaychuk则和他的组员在node支持generator之后对yield进行了二次封装,编写了一个名为co的函数,来达到了我们最初的期望。在此我借用一段我在学习时所用到代码,来完整解析co函数与yield相互操作时的工作流程。

function co(GenFunc) {  
  return function(cb) {
    var gen = GenFunc()
    next()
    function next(err, args) {
      if (err) {
        cb(err)
      } else {
        if (gen.next) {
          var ret = gen.next(args)
          if (ret.done) {
            cb && cb(null, args)
          } else {
            ret.value(next)
          }
        }
      }
    }
  }
}

function delay(time) {  
  return function(fn) {
    setTimeout(function() {
      fn(null, time) 
    }, time)
  }
}


co(function* () {  
  var a
  a = yield delay(200) // a: 200
  a = yield delay(a + 100) // a: 300
  a = yield delay(a + 100) // a: 400
})(function(data) {
  console.log(data) // print 400, 最后的值被回调出来
})

在使用co函数时,用户必须向函数注入两个回调,Generator会首先被注入co函数,通过co函数内部定义的next方法在每次执行完逻辑后对Generator的实例进行回呼,直到Generator的实例的done属性状态为true,将执行结果传入另外一个回调,以供编码者使用其计算结果。

而如你所见,co函数在使用yield切换控制权之余,还允许编码者通过其来传递参数。上述的代码中delay函数分别传递了200、a+100、a+100等三次参数。其整个流程如下所示。 在上述的这段代码中,有一个隐蔽的但却至关重要的一个步骤。co在执行的过程中将其内部定义的一整个next函数通过ret.value的接口传递给thunkify后的异步函数,并且做为它计算结果的出口,而表面上来看即是用next函数取代了原先的自定义回调,最后再通过next方法的签名与Ins.next(args)的接口方法把计算结果回传给Generator实例。在这一进一出的传递过程中,co与Generator实例之间形成了一个互相调用的handle loop,从而达到异步调用间的转换的目的。

Yieldable

如果你对上述代码有足够的审视,那么我想你不难发现另外一个需要注意的地方: 可yield的函数类型。

事实上,任何的解决方案都是有所谓的成本的,而在这个基于co的异步转同步的实现方案中,为了使co能按照预期的流程来执行,我们必须把原有的函数进行thunkify/promisify化的处理,比如下述的thunkify过程:

fs.readFile(File,'utf-8',fn)

//不得不变换成如下的CPS形式

function read(file) {  
  return function(fn){
    fs.readFile(file, 'utf8', fn);
  }
}

理论上来说,Thunk和Promise都是co所支持的可yield类型,但鉴于ES6标准在经历了一系列考量后将Promise规范纳入了官方的标准,因此在现有的工程实践中选择Promise作为co传递的底层会获得更好的兼容性,当然,Thunk使用起来会更简单一些。

根据不可靠消息,thunk在目前的V8实现上有6倍于原生Promise的效率。

Continuation

因为前阵子在看SICP的缘故,既然谈到了Generator、CPS,顺水推舟,我们就免不了谈一下Continuation,因为从本质上来说,Generator可以被认为是一种semi-continuation,对应的,类LISP的语言则实现了full-continuation。

其实就异步转同步的这个问题上来说,通常最容易被程序员所接受的则是上文提过的Coroutine协程,而Generator可以用来作coroutine的模拟实现,但实际上最简单的实现方案应该是call-with-current-continuation,也就是通常来说的call/cc。

//ruby除了支持两种形态的Fiber以外,还附带支持了call/cc。
require "continuation"

arr = [ "Freddie", "Herbie", "Ron", "Max", "Ringo" ]  
callcc{|cc| $cc = cc}  
puts(message = arr.shift)  
$cc.call unless message =~ /Max/

// => Freddie,Herbie,Ron,Max

Continuation以近乎快照的模式保存住了当时程序中的上下文,从而使得程序可以在未来的某一个时间点返回当时的上下文进行操作。这犹如时光机器般的表现模式,无怪乎,冯东说其为:

Lisp 中最难以驾驭但是又最富裕表现力的概念。

事实上,Continuation对象在生成之后作为一种控制结构来使用,在调用时会从他所表示的控制点处恢复运行,而其本身允许多次重入。听起来似乎和yield很像,但实际上它拥有更强大的表现力和自由度,搭配LISP自举的特性,Continuation可以自己为宿主环境实现流程控制,在此摘录一段wiki中的scheme coroutine实现。

 ;;; A naive queue for thread scheduling.
 ;;; It holds a list of continuations "waiting to run".
   (define call/cc call-with-current-continuation)
   (define *queue* '())

   (define (empty-queue?)
     (null? *queue*))

   (define (enqueue x)
     (set! *queue* (append *queue* (list x))))

   (define (dequeue)
     (let ((x (car *queue*)))
       (set! *queue* (cdr *queue*))
       x))

   ;;; This starts a new thread running (proc).

   (define (fork proc)
     (call/cc
      (lambda (k)
        (enqueue k)
        (proc))))

   ;;; This yields the processor to another thread, if there is one.

   (define (yield)
     (call/cc
      (lambda (k)
        (enqueue k)
        ((dequeue)))))

   ;;; This terminates the current thread, or the entire program
   ;;; if there are no other threads left.

   (define (thread-exit)
     (if (empty-queue?)
         (exit)
         ((dequeue))))

Be5将基于call/cc的方案和基于coroutine的方案简而言之的概括为以下两点,其实是相当贴切的。

callcc 是功能集最小的。

而程序员最容易接受的是 coroutine。

await 以及 async (Traceur Compiler)

这部分先不谈了....有空再补

最后,谈谈看法

之前知乎上,曾经有人问到过「如何看待 ECMAScript 6 新标准的?」,Be5在其中表示对Generator的反感,而我对他反感的原因持类似的意见:

同步代码的好处是它的执行顺序如同它的代码顺序一样是线性的,因此与人脑的思维模式保持了一致,而Co虽然解决了视觉逻辑上的抽象,但是内部的执行流程反而因为Generator穿来穿去的模式变得更加复杂,如果不能理解它最后在栈中运行过程,代码中则难免会出现一些不能按照直觉来判断的错误和异常,所以从某种角度上来说会增加错误处理的成本。

一如FibJS的作者,响马所言,

编程范式的改变,需要一整套解决方案,包括协程引擎,调度器,重新封装的 api,仅靠一个核心引擎很难改变。generator 可以从形式上面同步化逻辑,但是入口和出口仍是异步,需要每个人都小心翼翼的先理解异步,再去写同步。二者都很难改变 nodejs 实质异步的门槛。

Update Profile

待续...

About the author
comments powered by Disqus