“协程(coroutine)”于我而言还是比较新的概念,Lua 也是刚接触不久。不过碰巧这段时间我又在看 ES6 生成器的文章:
- 【译】ES6 生成器 - 1. ES6 生成器基础
- 【译】ES6 生成器 - 2. 深入理解 ES6 生成器
- 【译】ES6 生成器 - 3. ES6 生成器异步编程
- 【译】ES6 生成器 - 4. 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()
方法,每次调用时可以返回一个结果对象。这个迭代结果对象包含 value
和 done
两个属性。在较新版本的 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 目前还是“单线程”的,所以引入协程也是很自然的选择吧。
如有错漏,欢迎指正。
感谢阅读!
更多参考资料:
- Coroutines - Programming in Lua
- Generator - MDN
- Iterators and generators - MDN
- Using Fetch - MDN