【译】ES6 生成器 - 1. ES6 生成器基础

原文地址: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() 返回 nullfalse 或其他可以让你知道数据容器中的所有值已被遍历的信号。

我们在外部控制生成器函数的方式,就是构造一个 生成器迭代器 并与之交互。这听起来比实际情况要复杂。来看下面的例子:

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),也就是说返回的最后的 value42

把上面的内容多看几遍。对于大多数人来说,最初看的时候都会感觉很奇怪。

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 返回。donefalse 时,会自动提取 value 属性赋值给迭代变量(上例中为 v)。一旦 donetrue,循环迭代终止(也不会处理最后返回的 value,如果有的话)。

就像上文提到过的那样,for..of 循环忽略并丢弃了最后的 return 6 的值。所以,由于没有暴露 next() 调用,还是不要在像上面那种情况下使用for..of 循环。

总结

OK,以上就是生成器的基础知识了。如果还是有点懵,不用担心。所有人一开始都是这样的!

很自然地,你会想这个外来的新玩具在自己的代码中实际会怎么使用。其实,有关生成器还有很多的东西。我们只是翻开了封面而已。所以,在发现生成器是/将会多么强大之前,我们还得更进一步学习。

在你试着玩过上面的代码片段之后(试试 Chrome 最新版或 FF 最新版,或者带有 --harmony 标记的 node 0.11+ 环境),可能会思考下面的问题:

  1. 异常如何处理?
  2. 一个生成器能够调用另一个吗?
  3. 异步代码怎么应用生成器?

这些问题,以及其他更多的问题,将会在该系列文章中讨论,所以,请继续关注!


该系列文章共有4篇,这是第一篇,如有时间,其他3篇也会在近期陆续翻译出来。

ES6 Generators: Complete Series

  1. The Basics Of ES6 Generators
  2. Diving Deeper With ES6 Generators
  3. Going Async With ES6 Generators
  4. Getting Concurrent With ES6 Generators

另外,有关 for..of 的部分,其实有个细节文章没有解释。for..if 接收的并不是迭代器(实现了 iterator 接口,也就是有 next() 方法),而应该是实现了 iterable 接口的对象。

之所以生成器函数调用后的返回值可以用于 for..of,是由于得到的生成器对象同时支持了 iterator 接口和 iterable 接口。

iterable 接口对应一个特殊的方法,调用后返回一个迭代器,对于生成器对象而言,这个接口方法返回的其实就是对象自身。

由于同时支持了两个接口,所以生成器函数返回的生成器对象既能直接调用 next(),也可以用于 for..in 循环中。

推荐阅读更多精彩内容