0%

JavaScript 权威指南 12:迭代器与生成器

可迭代对象及其相关的迭代器是 ES6 的一个特性,数组(包括 Typed Array)、字符串、Set 对象和 Map 对象都是可迭代的。这意味着可以通过 for/of 循环来迭代它们,... 运算符能够扩展 可迭代对象,迭代器可以用于结构赋值。而且有些接收 Array 对象的内置函数/构造函数也可以接收任意迭代器。

本章解释迭代器的原理,并展示如何创建可迭代的数据结构。理解迭代后,再学习生成器,它是 ES6 的一个新特性,用于简化迭代器的创建。

迭代器原理

要理解 JavaScript 中的迭代,必须理解 3 个不同的类型:

  • 首先是可迭代对象,类似于 Array、Set、Map,都是可以迭代的
  • 其次是迭代器对象,用于执行迭代
  • 最后是迭代结果对象,保存每次迭代的结果

具体来说:

  • 可迭代对象 是指任何具有特定迭代器方法的对象,且该方法返回 迭代器对象
  • 迭代器对象 指的是任何具有 next() 方法的对象,且该 next() 方法返回 迭代结果对象
  • 迭代结果对象 是具有属性 valuedone 的对象

要迭代一个 可迭代对象,首先要调用其迭代器方法获得一个 迭代器对象。然后,重复调用这个 迭代器对象next() 方法,直至返回 done 属性为 true 的迭代结果对象

可迭代对象的迭代器方法没有使用惯用名称,而是使用了符号 Symbol.iterator 作为名字。下面展示了可迭代对象的迭代过程:

1
2
3
4
5
6
7
8
let iterable = [99, 100];
let iterator = iterable[Symbol.iterator]();

// => 99
// => 100
for (let result = iterator.next(); !result.done; result = iterator.next()) {
console.log(result.value);
}

特别地,内置可迭代数据类型的迭代器对象本身也是可迭代的(即迭代器对象本身也具有名为 Symbol.iterator 的方法,返回它们自己):

1
2
3
4
5
6
7
8
9
10
> let list = [1, 2, 3, 4, 5];
> let iter = list[Symbol.iterator]()
> iter === iter[Symbol.iterator]()
true

> let head = iter.next()
> head.value
1
> [...iter]
[ 2, 3, 4, 5 ]

实现可迭代对象

在 ES6 中,可迭代对象非常重要。因此,只要你的数据类型表示某种可迭代的结构,就应该考虑把它们实现为可迭代对象。

  • 为了让类可迭代,必须实现一个名为 Symbol.iterator 的方法。这个方法必须返回一个迭代器对象
  • 迭代器对象必须有一个 next() 方法,而这个 next() 方法必须返回一个迭代结果对象,该对象有一个 value 属性和/或一个布尔值 done 属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Range {
constructor(from, to) {
this.from = from;
this.to = to;
}

has(x) {
return typeof x === "number" && this.from <= x && x <= this.to;
}

toString() {
return `{ x | ${this.from} <= x <= ${this.to} }`;
}

[Symbol.iterator]() {
let next = Math.ceil(this.from);
let last = this.to;
return {
next() {
return (next <= last) ? {value: next++} : {done: true};
},

// 让迭代器本身也是可迭代的
[Symbol.iterator]() { return this; },
};
}
}

for (let x of new Range(1, 10)) {
console.log(x);
}

console.log(...new Range(-2, 2));

除了可以把类变成可迭代的类之外,定义返回可迭代值的函数也很有用。如下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function map(iterable, f) {
let iterator = iterable[Symbol.iterator]();
// 返回的对象是既是一个迭代器对象,也是一个可迭代对象
return {
[Symbol.iterator]() { return this; },

next() {
let v = iterator.next();
if (v.done) {
return v;
} else {
return {value: f(v.value)};
}
}

}
}

console.log([...map([1, 2, 3], x => x * 2)]);

可迭代对象与迭代器有一个重要的特点,即它们天性懒惰:如果计算下一个值需要一定的计算量,则相应计算会推迟到实际需要下一个值的时候再发生

关闭迭代器方法:return() 方法

除了 next() 方法,迭代器对象还可以实现 return() 方法:

  • 如果迭代在 next() 返回 done 属性为 true 的迭代结果之前停止(最常见的原因是通过 break 语句提前退出 for/of 循环),那么解释器就会检查迭代器对象是否有 return() 方法
  • 如果有,解释器就会调用它(不传参数)​,让迭代器有机会关闭文件、释放内存,或者做一些其他清理工作
  • 这个 return() 方法必须返回一个迭代器结果对象。这个对象的属性会被忽略,但返回非对象值会导致报错

生成器

生成器是一种使用强大的新 ES6 语法定义的迭代器,特别适合要迭代的值不是某个数据结构的元素,而是计算结果的场景。生成器极大地简化了迭代器的创建。

要创建生成器,首先必须定义一个生成器函数。生成器函数在语法上类似常规的 JavaScript 函数,但使用的关键字是 function* 而非 function

  • 调用 生成器函数 并不会实际执行函数体,而是返回一个 生成器。这个生成器是一个迭代器
  • 调用生成器的 next() 方法会导致生成器函数的函数体从头(或从当前位置)开始执行,直至遇见一个 yield 语句
  • yield 是 ES6 的新特性,类似于 return 语句。yield 语句的值会成为调用迭代器的 next() 方法的返回值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 生成器函数的定义
function* oneDigitPrimes() {
yield 2;
yield 3;
yield 5;
yield 7;
}

// 调用生成器函数得到生成器
let primes = oneDigitPrimes();

// 生成器既是迭代器,也是可迭代对象,它的迭代器方法返回本身
console.log(primes[Symbol.iterator]() === primes);

// 生成器是迭代器
console.log(primes.next().value);
console.log(primes.next().value);
console.log(primes.next().value);
console.log(primes.next().value);
console.log(primes.next().done);

console.log([...oneDigitPrimes()]);

与常规函数一样,也可以使用表达式定义生成器。同样,只要在 function 关键字前面加个星号即可:

1
2
3
4
5
const seq = function*(from, to) {
for (let i = from; i <= to; i++) yield i;
};

[...seq(3, 5)]

在类和对象字面量中,定义方法时可以使用简写形式,省略 function 关键字。在这种情况下定义生成器,只要在应该出现 function 关键字的地方(如果用的话)加一个星号:

1
2
3
4
5
6
7
8
9
10
11
let o = {
x:1, y:2, z:3,

*g() {
for (let key of Object.keys(this)) {
yield key;
}
}
};

console.log([...o.g()]); //=> [ 'x', 'y', 'z', 'g' ]

不能使用箭头函数语法定义生成器函数

生成器在定义可迭代类时特别有用,例如上面的 Range 示例,其 [Symbol.iterator] 方法可以改写为生成器函数:

1
2
3
4
5
*[Symbol.iterator]() {
for (let x = Math.ceil(this.from); x <= this.to; x++) {
yield x;
}
}

如下是一个更复杂的生成器函数示例,它可以交替回送多个可迭代对象的元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function* zip(...iterables) {
let iterators = iterables.map(i => i[Symbol.iterator]());
let index = 0;

while (iterators.length > 0) {
if (index >= iterators.length) {
index = 0;
}

let item = iterators[index].next();
if (item.done) {
iterators.splice(index, 1);
} else {
yield item.value;
index++;
}
}
}

// [
// 1, 'ab', 'u', 2,
// 'cd', 'v', 3, 'w',
// 'x', 'y', 'z'
// ]
console.log([...zip([1, 2, 3], ["ab", "cd"], "uvwxyz")]);

yield* 与递归生成器

有时候我们有如下需求,在生成器函数中回送其他可迭代对象元素:

1
2
3
4
5
6
7
8
9
function* sequence(...iterables) {
for (let iterable of iterables) {
for (let item of iterable) {
yield item;
}
}
}

console.log([...sequence([1, 2, 3], "abc")]) //=>[ 1, 2, 3, 'a', 'b', 'c' ]

这种在生成器函数中回送其他可迭代对象元素的操作很常见,所以 ES6 为它定义了特殊语法。yield* 关键字与 yield 类似,但它不是只回送一个值,而是迭代可迭代对象并回送得到的每个值。所以上述代码可以简化为:

1
2
3
4
5
function* sequence(...iterables) {
for (let iterable of iterables) {
yield* iterable;
}
}

注意,yieldyield* 只能在生成器函数中使用,而箭头函数不能定义成生成器函数,因此箭头函数中不能出现 yield。

yield* 可以用来迭代任何可迭代对象,包括通过生成器实现的。这意味着使用 yield* 可以定义递归生成器,利用这个特性可以通过简单的 非递归迭代 遍历 递归定义的树结构

高级生成器特性

生成器函数最常见的用途是创建迭代器,但生成器的基本特性是可以暂停计算,回送中间结果,然后在某个时刻再恢复计算,这意味着生成器拥有超越迭代器的特性。

生成器函数的返回值

与其他函数一样,生成器函数也可以返回值(通过 return 返回值)。对于迭代器/生成器而言,其 next() 返回迭代结果对象,通常:

  • 如果 value 属性有定义,则 done 属性未定义或者为 false
  • 如果 done 是 true,那么 value 属性就是未定义的

但是当生成器返回值时,最后一次调用 next() 返回的对象 value 和 done 都有定义:value 是生成器返回的值,done 是 true(表示没有可迭代的值了)​

最后这个值会被 for/of 循环和扩展操作符忽略(因为 done 为 true),但手工迭代时可以通过显式调用 next() 得到:

1
2
3
4
5
6
7
8
9
10
11
12
function *oneAddDone() {
yield 1;
return "done";
}


console.log([...oneAddDone()]); // => [1]

let generator = oneAddDone();
console.log(generator.next()); // => { value: 1, done: false }
console.log(generator.next()); // => { value: 'done', done: true }
console.log(generator.next()); // => { value: undefined, done: true }

yield 表达式的值

事实上,yield是一个表达式(回送表达式)​,它可以也可以有值。

  • 调用生成器的 next() 方法时,生成器函数会一直运行直到到达一个 yield 表达式
  • yield 关键字后面的表达式会被求值,该值成为 next() 调用的返回值。此时,生成器函数就在求值 yield 表达式 的中途停了下来
  • 下一次调用生成器的 next() 方法时,传给 next() 的参数会变成暂停的 yield 表达式的值

如下是一个说明性质的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function* smallNumbers() {
console.log("next() 1th invoked, argument discard");

let y1 = yield 1;
console.log("next() 2th invoked, argument ", y1);

let y2 = yield 2;
console.log("next() 3th invoked, argument ", y2);

let y3 = yield 3;
console.log("next() 4th invoked, argument ", y3);

return 4;
}

let g = smallNumbers();
console.log("generator created");

let n1 = g.next("a");
console.log("generator yield", n1.value);

let n2 = g.next("b");
console.log("generator yield", n2.value);

let n3 = g.next("c");
console.log("generator yield", n3.value);

let n4 = g.next("d");
console.log("generator return", n4.value);
1
2
3
4
5
6
7
8
9
generator created
next() 1th invoked, argument discard
generator yield 1
next() 2th invoked, argument b
generator yield 2
next() 3th invoked, argument c
generator yield 3
next() 4th invoked, argument d
generator return 4

尤其要注意,注意以上代码是不对称的。第一次调用 next() 启动生成器,但传入的值无法在生成器中访问到

生成器的 return() 和 throw() 方法

综上我们得知,我们可以接收生成器回送或返回的值。同时,也可以通过生成器的 next() 方法给运行中的生成器传值。

除了通过 next() 为生成器提供输入之外,还可以调用它的 return()throw() 方法,改变生成器的控制流。顾名思义,在生成器上调用这两个方法会导致它返回值或抛出异常,就像生成器函数中的一下条语句是 return 或 throw 一样。

之前说过,对于迭代器,如果迭代器定义了 return() 方法且迭代提前停止,解释器会自动调用 它的 return() 方法,从而让迭代器有机会关闭文件或做一些其他清理工作。对生成器而言:

  • 我们无法定义这样一个 return() 方法来做清理工作,但可以在生成器函数中使用 try/finally 语句,保证生成器返回时(在 finally 块中)做一些必要的清理工作
  • 在强制生成器返回时,生成器内置的 return() 方法可以保证这些清理代码运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function* test() {
try {
yield 1;
yield 2;
yield 3;
}
finally {
console.log("finally block exec");
}
}

// 1
// 2
// 3
// finally block exec
for (let v of test()) {
console.log(v);
}

// 1
// finally block exec
for (let v of test()) {
console.log(v);
if (v == 1) {
break;
}
}

// 1
// 2
// finally block exec
let g = test();
for (let v of g) {
console.log(v);
if (v == 2) {
g.return();
}
}

生成器的 throw() 方法为我们提供了(以异常形式)向生成器发送任意信号的途径。调用 throw() 方法就会导致生成器函数抛出异常。如果生成器函数中有适当的异常处理代码,则这个异常就不一定致命,而是可以成为一种改变生成器行为的手段。

生成器小结

生成器是一种非常强大的通用控制结构,它赋予我们通过 yield 暂停计算并在未来某个时刻以任意输入值重新启动计算的能力:

  • 可以使用生成器在单线程 JavaScript 代码中创建某种协作线程系统
  • 也可以利用生成器来掩盖程序中的异步逻辑,这样尽管某些函数调用依赖网络事件,实际上是异步的,但代码看起来还是顺序的、同步的

利用生成器的代码通常比较晦涩,JavaScript 专门新增了 async/await 语法(后续介绍),来简化生成器的这种用法。