JS 生成器 vs. Lua 协程

“协程(coroutine)”于我而言还是比较新的概念,Lua 也是刚接触不久。不过碰巧这段时间我又在看 ES6 生成器的文章:

然后很自然地发现两者其实是相似的东西。整理、对比的过程,肯定也会加深自己的理解,所以尽管是初学,还是贸然一试了。

JS 生成器

先来看一个简单的例子:

function *g() {
  yield 1
  yield 2
  yield 3
}

通过 function* 声明了一个生成器函数,这是 ES6/ES2015 引入的新的函数类型,也是下面主要分析的对象。

可以看到,函数体内还有新的 yield 关键字。yield 这里在函数执行时会产生“中断”,而中断时函数的执行环境(变量等)会被保存下来,然后在某个时刻,可以返回中断的位置继续执行函数,同时执行环境被还原。这是我理解的生成器函数的主要特性。

也就是说,和一般的函数不同,生成器函数的执行过程可能是这样的:

  • 开始执行
  • 暂停
  • 继续
  • 暂停
  • 继续
  • ...
  • 执行结束

而且暂停和继续之间,其他的代码可以获得控制权进行执行,并且决定在什么时候继续生成器函数的执行,甚至可以永远不继续执行生成器函数。

我们来运行一个完整的示例,看下生成器函数的用法:

var o = g()
console.log(o.next()) // {value: 1, done: false}
console.log(o.next()) // {value: 2, done: false}
console.log(o.next()) // {value: 3, done: false}
console.log(o.next()) // {value: undefined, done: true}

与普通函数不同,调用生成器函数并不是真正执行了函数,而是返回了一个“生成器对象”。从这一点上来看,生成器函数有点类似“构造函数”的感觉,每次调用返回一个新的对象。

当然,这个新的对象也是比较特殊的,它是一个迭代器对象,遵循“iterator”协议。从迭代的角度,也可以称之为“迭代器对象”。

iterator 协议也是 ES6 新增的,它要求支持该协议(或者说接口吧,虽然 JS 中并没有接口)的对象提供一个 next() 方法,每次调用时可以返回一个结果对象。这个迭代结果对象包含 valuedone 两个属性。在较新版本的 Chrome 控制台执行下示例代码,可以看到,value 是返回的值,done 表示迭代是否执行完成。

看到这里,貌似生成器的确和其名字一样,可以生成一些值,只不过这些值是断断续续地返回的,需要其他的代码主动去获取。基于这些,我们可以做一个能够持续不断返回数据的生成器函数:

function *num() {
  var i = 0
  while (true) {
    yield i++
  }
}

有点暴力,不过的确可行:

var n = num()
console.log(n.next()) // {value: 0, done: false}
console.log(n.next()) // {value: 1, done: false}
console.log(n.next()) // {value: 2, done: false}

可以一直调用 n.next() 下去,和前面的 *g() 不同,这个生成器函数貌似不会主动结束。

ES6 还引入了 for ... of 语句,专门用于迭代,例如,可以这样:

for (var i of g()) {
  console.log(i)
}

for (var n of num()) {
  if (n < 10) {
    console.log(n)
  } else {
    break
  }
}

这个也很容易理解,不多说了。

不过,这还不是有关生成器的全部,如果仅仅是这样,那和后面要介绍的 Lua 的协程的区别就太大了,比人家的能力差得太多。

生成器还支持在生成器函数内外进行数据传递,来看一个例子:

function *query(name) {
  var age = yield getAgeByName(name)
  console.log('name: `' + name + '` age: ' + age)
}

我们把生成器函数 *foo() 作为普通函数来看待,它封装了一段的逻辑。在运行时,外部传入一个名字(name),通过调用 getAgeByName() 来获得名字对应的年龄,然后打印出来。

我们假设 getAgeByName() 是这样的:

function getAgeByName(name) {
  var people = [{
    name: 'luobo',
    age: 18
  }, {
    name: 'tang',
    age: 20
  }]
 
  var person = people.find(p => p.name === name)
  return person.age
}

由于生成器函数与普通函数的执行过程不同,我们定义一个执行生成器函数的函数:

function run(g, arg) {
  var o = g(arg)

  next()

  function next() {
    var result = o.next(arg)
    arg = result.value
    if (!result.done) {
      next()
    }
  }
}

函数 run() 用于生成器函数 g,可以传入一个初始参数 arg,之后每次调用生成器对象的 next() 时,会将上一次调用的返回值传入。

yield 暂停生成器函数执行时,会将一个值返回到生成器外部,而外部程序在调用生成器对象的 next() 方法时可以传入一个参数,这个参数的值会作为 yield 表达式的值使用,然后继续执行生成器函数。

下面我们来实际执行一下上面的 *foo()

run(query, 'luobo') // name: `luobo` age: 18
run(query, 'tang') // name: `tang` age: 20

好像没什么了不起,而且本来很简单的过程,使用了生成器函数好像还有点复杂了。

还是上面的例子,如果我们改变下 getAgeByName() 函数的实现:

function getAgeByName(name) {
  return fetch('/person?name=' + name).then(res => res.json().age)
}

现在根据名字查找年龄的过程是异步的了,需要向服务器获取数据。如果是通过普通函数实现 *query() 的逻辑,那我们需要修改函数的实现,因为同步获取数据和异步是不同的编程方式,通常需要改用回调函数。

不过生成器本身的执行就是“异步”的,而且生成器支持数据传递。所以,借助这个特性,我们其实可以不必修改 *query() 的逻辑,而是在 run() 上做一下处理:

function run(g, arg) {
  var o = g(arg)

  next()

  function next() {
    var result = o.next(arg)
    arg = result.value
    if (!result.done) {
      // 返回值可能是 Promise 对象
      if (arg && typeof arg.then === 'function') {
        arg.then(val => {
          arg = val
          next()
        })
      } else {
        next()
      }
    }
  }
}

将对异步状态的处理拆分到了与逻辑无关的控制函数 run() 中,而具体的逻辑部分(*query())不需要修改代码。

看到这里,是不是有点意思了?当然,或许你早就知道这些了。

稍微总结一下 JS 生成器吧。

JS 生成器除了作为“生成器”来使用,还可以作为一种改善代码编写方式的技术,它可以使得我们能够写出类似“同步”执行的异步代码,这样的代码毕竟更易读和维护。

有关生成器的特性,其实还有很多,本文前面列出的四篇文章中有比较全面的介绍,这里不再赘述。

下面,我们来看 Lua 中的协程。

Lua 协程

先看一个例子:

co = coroutine.create(function ()
    coroutine.yield(1)
    coroutine.yield(2)
    coroutine.yield(3)
  end)

print(coroutine.resume(co)) --> true    1
print(coroutine.resume(co)) --> true    2
print(coroutine.resume(co)) --> true    3
print(coroutine.resume(co)) --> true
print(coroutine.resume(co)) --> false   cannot resume dead coroutine

这个例子和 JS 生成器一节的第一个例子类似,不过有一些区别:

  • Lua 的协程通过 coroutine 库来创建,coroutine.create() 接收的是普通函数,而 JS 则是新增了生成器函数这一新的函数类型
  • Lua 的协程对应的是 thread 类型的值,JS 的生成器函数调用后返回的是迭代器对象
  • Lua 的协程通过 coroutine.resume(co) 的模式来执行,JS 则是利用迭代器接口的 next() 方法来执行
  • Lua 的协程内部通过 coroutine.yield() 来产生中断,JS 则是通过 yield 关键字
  • Lua 的协程中断返回的是一组值,第一个值表示是否执行成功,后续的值为传递的数据,JS 函数只能返回一个值,所以是通过对象来传递状态和返回值的
  • Lua 的协程结束执行后再次调用,会产生异常,JS 不会

Lua 的 for ... in 也可以对协程进行迭代,与 JS 类似:

co = coroutine.wrap(function ()
    coroutine.yield(1)
    coroutine.yield(2)
    coroutine.yield(3)
  end)

for i in co do
  print(i)
end

不过区别在于,由于 Lua 的协程对应的是 thread 类型的值,而并非是迭代器,所以这里通过 coroutine.wrap() 将协程包装为迭代器返回。

Lua 的协程也支持进行数据传递,所以在 JS 部分介绍的所谓“同步”的异步模式,在 Lua 中也是可以实现的。

不过还是可以看出,Lua 的协程的确是“协程”,并不是为了实现“生成器”而设计,只不过“顺便”能够支持作为生成器来使用而已。而 JS 生成器则是作为生成器设计,生成器函数调用后返回的就是一个迭代器,而非像 Lua 那样的一个特殊类型的值(thread)。不过也可以利用 JS 生成器内部的“pause-resume”机制实现一些编程技巧,这就有点协程的味道了。

更深入的分析 Lua 的协程和 JS 生成器的区别,需要对 Lua 的协程有更深入的理解,不过我还没有这样的能力。所以,抱歉,目前只能在表面上做些文章。

总结

协程(coroutine)是一个重要的编程技术,在许多编程语言中都有体现。

Lua 中的协程,据相关文档介绍,是具有较完整的相关特性的实现,而且在 Lua 编程中有广泛的应用,是重要的技术。

JS 的生成器,在 ES6 中才引入,虽然叫做生成器,不过的确有些“协程”的特性,这也是为什么可以基于这些特性构建新的异步编程解决方案,因为生成器函数的执行具有“暂停-继续”的特性。显然协程这一重要的编程技术被引入到了 JS 中。

从更高的层面来讲,协程和多线程是两种解决“多任务”编程的技术。多线程使得同一时刻可以有多个线程在执行,不过需要在多个线程间协调资源,因为多个线程的执行进度是“不可控”的。而协程则避免了多线程的问题,同一时刻实质上只有一个“线程”在执行,所以不会存在资源“抢占”的问题。

不过在 JS 领域,貌似不存在技术选择的困难,因为 JS 目前还是“单线程”的,所以引入协程也是很自然的选择吧。

如有错漏,欢迎指正。

感谢阅读!

更多参考资料:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容