原文地址:https://davidwalsh.name/es6-generators
作者 Kyle Simpson,发布于 2014年7月21日
生成器(generator),作为一种新的函数,是JavaScript ES6 带来的最令人兴奋的新特性之一。名字或许有点陌生,不过初步了解之后你会发现,它的行为更加陌生。本文的目的是帮你了解生成器,并且让你认识到为什么对于 JS 的未来而言它是如此重要。
执行-结束
首先让我们来看下它相较于普通函数“执行-结束”模式的不同之处。
不知道你是否注意过,对于函数你一直以来的看法就是:一旦函数开始执行,它就会一直执行下去直到结束,这个过程中其他的 JS 代码无法执行。
示例:
setTimeout(function(){
console.log("Hello World");
},1);
function foo() {
// 注意:不要做这样疯狂的长时间运行的循环
for (var i=0; i<=1E10; i++) {
console.log(i);
}
}
foo();
// 0..1E10
// "Hello World"
在上面例子中,for
循环会执行相当长的时间才会结束,至少超过1毫秒,但是定时器回调函数中的 console.log(..)
语句并不能在 foo()
函数执行过程中打断它,所以它会一直等在后面(在事件循环队列上),直到函数执行结束。
如果 foo()
的执行可以被打断呢?那岂不给我们的程序带来了灾难?
这就是多线程编程带来的挑战(噩梦),不过还好,在 JavaScript 领域我们不用担心这种事情,因为 JS 始终是单线程的(同时只会有一个指令或函数在执行)。
注意:Web Worker 机制可以将 JS 程序的一部分在一个单独的线程中执行,与 JS 主程序并行。之所以说这不会带来多线程的问题,是因为这两个线程可以通过普通的异步事件来彼此通信,仍然在事件循环的一次执行一个的行为模式下。
执行-停止-执行
ES6 生成器(generator)是一种不同类型的函数,可以在执行过程中暂停若干次,并在稍后继续执行,使得其他代码可以在其暂停过程中得到执行。
如果你对并发或线程编程有所了解,你可能听过“协作(cooperative)”这个词,意思是一个进程(这里指函数)可以自主选择什么时间进行中断,从而可以与其他代码协作。与之相对的是“抢占”,意思是一个进程/函数可以在外部被打断。
从并发行为上来说,ES6 生成器是“协作的”。在生成器函数内部,可以通过 yield
关键字来暂停函数的执行。不能在生成器外部停止其执行;只能是生成器内部在遇到 yield
时主动停止。
不过,在生成器通过 yield
暂停后,它不能自己继续执行。需要通过外部控制来让生成器重新执行。我们会花一点时间来阐述这个过程。
所以,基本上,一个生成器函数可以停止执行和被重新启动任意多次。实际上,可以通过无限循环(如臭名昭著的 while (true) { .. }
)来使得一个生成器函数永远不终止。尽管在通常的 JS 编程中这是疯了或者出错了,但对于生成器函数这却会是非常合理的,并且有时候就是你需要的!
更重要的是,生成器函数执行过程中的控制并不仅仅是停止和启动,在这个过程中还实现了生成器函数内外的双向消息传递。对于普通函数,是在最开始执行时获得参数,最后通过 return
返回值。而在生成器函数中,可以在每个 yield 处向外发送消息,在每次重新启动时得到外部返回的消息。
语法!
让我们开始深入分析这全新和令人兴奋的生成器函数的语法。
首先,新的声明语法:
function *foo() {
// ..
}
注意到 *
了没?看起来有点陌生和奇怪吧。对于了解其他语言的人来说,这看起来很像是一个函数的指针。但是别被迷惑了!这里只是用于标记特殊的生成器函数类型。
你可能看过其他文章/文档使用了 function* foo() { }
而不是 function *f00() { }
(*
的位置有所不同)。两种都是合法的,不过最近我认为 function *foo() { }
更准确些,所以我后面会使用这种形式。
下面,我们来讨论下生成器函数的内容。大多数情况下,生成器函数就像是普通的 JS 函数。在生成器的 内部 只有很少的新的语法需要学习。
我们主要的新玩具,前面也提到过,就是 yield
关键字。yield __
被称为“yield 表达式”(而非语句),因为生成器重新执行时,会得到一个返回给生成器的值,这个值会作为 yield __
表达式的值使用。
示例:
function *foo() {
var x = 1 + (yield "foo");
console.log(x);
}
在执行到 yield "foo"
这里时,生成器函数暂停执行,"foo"
会被发送到外部,而(如果)等到生成器重新执行时,不管被传入了什么值,都会作为这个表达式的结果值,进而与 1
相加后赋值给变量 x
。
看出来双向通信了吗?生成器将 "foo"
发送到外部,暂停自身的执行,然后在未来某一时间点(可能是马上,也可能是很久之后!),生成器被重新启动并传回来一个值。这看起来就像是 yield
关键字产生了一个数据请求。
在任何使用表达式的位置,都可以在表达式/语句中只使用 yield
,这就像是对外发送了 undefinded
值。如:
// 注意:这里的 `foo(..)` 不是生成器函数!!
function foo(x) {
console.log("x: " + x);
}
function *bar() {
yield; // 只是暂停
foo( yield ); // 暂停,等待传入一个参数给 `foo(..)`
}
生成器迭代器
“生成器迭代器”,很拗口是不是?
迭代器是一种特殊的行为,或者说设计模式,指的是我们从一个有序的值的集合中通过调用 next()
每次取出一个值。想象一个迭代器,对应一个有五个值的数组:[1,2,3,4,5]
。第一次调用 next()
返回 1
,第二次调用 next()
返回 2
,以此类推。在所有的值返回后,next()
返回 null
或 false
或其他可以让你知道数据容器中的所有值已被遍历的信号。
我们在外部控制生成器函数的方式,就是构造一个 生成器迭代器 并与之交互。这听起来比实际情况要复杂。来看下面的例子:
function *foo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
为了获得生成器函数 *foo()
的值,我们需要构造一个迭代器。怎么做呢?很简单!
var it = foo();
噢!所以,像一般函数那样调用生成器函数,其实并没有执行其内部。
这有点奇怪是吧。你可能还在想,为什么不是 var it = new foo();
。不过很遗憾,语法背后的原因有点复杂,超出了我们这里讨论的范围。
现在,为了遍历我们的构造器函数,只需要:
var message = it.next();
这会从 yield 1
语句那里得到 1
,但这并不是唯一返回的东西。
console.log(message); // { value:1, done:false }
实际上每次调用 next()
会返回一个对象,返回对象包含一个对应 yield
返回值的 value
属性,以及一个表示生成器函数是否已经完全执行完毕的布尔型的 done
属性。
继续迭代过程:
console.log( it.next() ); // { value:2, done:false }
console.log( it.next() ); // { value:3, done:false }
console.log( it.next() ); // { value:4, done:false }
console.log( it.next() ); // { value:5, done:false }
有意思的是,done
属性在获取到 5
这个值时仍为 false
。这是因为从 技术上 讲,生成器函数的执行还未结束。我们还需要最后一次调用 next()
,这时如果我们传入一个值,它会被用作表达式 yield 5
的结果。然后 生成器函数才会结束。
所以,现在:
console.log( it.next() ); // { value:undefined, done:true }
生成器函数的最后一个返回结果表示函数执行结束,但没有值返回(因为所有的 yield
语句都已执行)。
你可能会想,如果在生成器函数中使用 return
,返回的值会在 value
属性中吗?
是…
function *foo() {
yield 1;
return 2;
}
var it = foo();
console.log( it.next() ); // { value:1, done:false }
console.log( it.next() ); // { value:2, done:true }
…也不是
依赖生成器的 return
值不是个好主意,因为当生成器函数在 for .. of
循环(见下文)中进行迭代时,最后的 return
值会被丢弃。
下面,我们来完整地看下生成器函数在迭代时的数据传入和传出:
function *foo(x) {
var y = 2 * (yield (x + 1));
var z = yield (y / 3);
return (x + y + z);
}
var it = foo( 5 );
// 注意:这里没有向 `next()` 传入任何值
console.log( it.next() ); // { value:6, done:false }
console.log( it.next( 12 ) ); // { value:8, done:false }
console.log( it.next( 13 ) ); // { value:42, done:true }
可以看到,通过迭代初始化时调用的 foo( 5 )
仍然可以进行传参(对应例子中的 x
),这和普通函数相同,会使 x
的值为 5
。
第一个 next(..)
调用,没有传入任何值。为什么?因为没有对应的 yield
表达式来接收传入的值。
不过即使第一次调用时传入了值,也不会有什么坏事发生。传入的值只是被丢弃了而已。ES6 规定这种情况下生成器函数要忽略没有用到的值。(注意:在实际写代码的时候,最新版的 Chrome 和 FF 应该没问题,不过其他浏览器可能不是完全兼容的,或许会在这种情况下抛出异常。)
语句 yield (x + 1)
向外发送 6
。第二个调用 next(12)
向正在等待状态的 yield (x + 1)
表达式发送了 12
,所以 y
的值为 12 * 2
,也就是 24
。然后 yield (y / 3)
(yield (24 / 3)
)向外发送值 8
。第三个调用 next(13)
向表达式 yield (y / 3)
发送了 13
,使得 z
的值为 13
。
最终,return (x + y + z)
是 return (5 + 24 + 13)
,也就是说返回的最后的 value
是 42
。
把上面的内容多看几遍。对于大多数人来说,最初看的时候都会感觉很奇怪。
for..of
ES6 也在语义层面上加强了迭代模式,它提供了对迭代器执行的直接支持:for..of
循环。
示例:
function *foo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
return 6;
}
for (var v of foo()) {
console.log( v );
}
// 1 2 3 4 5
console.log( v ); // 仍旧是 `5`,而不是 `6` :(
可以看到,foo()
创建的迭代器会被 for..of
循环自动捕获,然后被自动进行遍历,每次返回一个值,直到 done:true
返回。done
为 false
时,会自动提取 value
属性赋值给迭代变量(上例中为 v
)。一旦 done
是 true
,循环迭代终止(也不会处理最后返回的 value
,如果有的话)。
就像上文提到过的那样,for..of
循环忽略并丢弃了最后的 return 6
的值。所以,由于没有暴露 next()
调用,还是不要在像上面那种情况下使用for..of
循环。
总结
OK,以上就是生成器的基础知识了。如果还是有点懵,不用担心。所有人一开始都是这样的!
很自然地,你会想这个外来的新玩具在自己的代码中实际会怎么使用。其实,有关生成器还有很多的东西。我们只是翻开了封面而已。所以,在发现生成器是/将会多么强大之前,我们还得更进一步学习。
在你试着玩过上面的代码片段之后(试试 Chrome 最新版或 FF 最新版,或者带有 --harmony
标记的 node 0.11+ 环境),可能会思考下面的问题:
- 异常如何处理?
- 一个生成器能够调用另一个吗?
- 异步代码怎么应用生成器?
这些问题,以及其他更多的问题,将会在该系列文章中讨论,所以,请继续关注!
该系列文章共有4篇,这是第一篇,如有时间,其他3篇也会在近期陆续翻译出来。
ES6 Generators: Complete Series
- The Basics Of ES6 Generators
- Diving Deeper With ES6 Generators
- Going Async With ES6 Generators
- Getting Concurrent With ES6 Generators
另外,有关 for..of
的部分,其实有个细节文章没有解释。for..if
接收的并不是迭代器(实现了 iterator 接口,也就是有 next()
方法),而应该是实现了 iterable 接口的对象。
之所以生成器函数调用后的返回值可以用于 for..of
,是由于得到的生成器对象同时支持了 iterator 接口和 iterable 接口。
iterable 接口对应一个特殊的方法,调用后返回一个迭代器,对于生成器对象而言,这个接口方法返回的其实就是对象自身。
由于同时支持了两个接口,所以生成器函数返回的生成器对象既能直接调用 next()
,也可以用于 for..in
循环中。