JavaScript for impatient programmers (ES2022 edition)
请支持本书:购买捐赠
(广告,请勿屏蔽。)

38 同步生成器(高级)



38.1 什么是同步生成器?

同步生成器是函数定义和方法定义的特殊版本,它们总是返回同步可迭代对象

// Generator function declaration
function* genFunc1() { /*···*/ }

// Generator function expression
const genFunc2 = function* () { /*···*/ };

// Generator method definition in an object literal
const obj = {
  * generatorMethod() {
    // ···
  }
};

// Generator method definition in a class definition
// (class declaration or class expression)
class MyClass {
  * generatorMethod() {
    // ···
  }
}

星号 (`*`) 将函数和方法标记为生成器

38.1.1 生成器函数返回可迭代对象并通过 `yield` 填充它们

如果我们调用一个生成器函数,它会返回一个可迭代对象(实际上是一个也是可迭代的迭代器)。生成器通过 `yield` 运算符填充该可迭代对象

function* genFunc1() {
  yield 'a';
  yield 'b';
}

const iterable = genFunc1();
// Convert the iterable to an Array, to check what’s inside:
assert.deepEqual(
  Array.from(iterable), ['a', 'b']
);

// We can also use a for-of loop
for (const x of genFunc1()) {
  console.log(x);
}
// Output:
// 'a'
// 'b'

38.1.2 `yield` 暂停生成器函数

使用生成器函数涉及以下步骤

因此,`yield` 不仅仅是向可迭代对象添加值——它还会暂停并退出生成器函数

让我们通过以下生成器函数来检查这意味着什么。

let location = 0;
function* genFunc2() {
  location = 1; yield 'a';
  location = 2; yield 'b';
  location = 3;
}

为了使用 `genFunc2()`,我们必须首先创建迭代器/可迭代对象 `iter`。`genFunc2()` 现在在其主体“之前”暂停。

const iter = genFunc2();
// genFunc2() is now paused “before” its body:
assert.equal(location, 0);

`iter` 实现了迭代协议。因此,我们通过 `iter.next()` 控制 `genFunc2()` 的执行。调用该方法会恢复暂停的 `genFunc2()` 并执行它,直到遇到 `yield`。然后执行暂停,`.next()` 返回 `yield` 的操作数

assert.deepEqual(
  iter.next(), {value: 'a', done: false});
// genFunc2() is now paused directly after the first `yield`:
assert.equal(location, 1);

请注意,产生的值 `'a'` 被包装在一个对象中,这就是迭代器始终传递值的方式。

我们再次调用 `iter.next()`,执行从我们之前暂停的地方继续。一旦我们遇到第二个 `yield`,`genFunc2()` 就会暂停,`.next()` 返回产生的值 `'b'`。

assert.deepEqual(
  iter.next(), {value: 'b', done: false});
// genFunc2() is now paused directly after the second `yield`:
assert.equal(location, 2);

我们再调用一次 `iter.next()`,执行继续,直到它离开 `genFunc2()` 的主体

assert.deepEqual(
  iter.next(), {value: undefined, done: true});
// We have reached the end of genFunc2():
assert.equal(location, 3);

这一次,`.next()` 结果的属性 `.done` 为 `true`,这意味着迭代器已完成。

38.1.3 为什么 `yield` 会暂停执行?

`yield` 暂停执行有什么好处?为什么它不像数组方法 `.push()` 那样简单地用值填充可迭代对象而不暂停呢?

由于暂停,生成器提供了许多*协程*的功能(想想协作多任务处理的进程)。例如,当我们请求可迭代对象的下一个值时,该值是*延迟*计算的(按需)。以下两个生成器函数演示了这意味着什么。

/**
 * Returns an iterable over lines
 */
function* genLines() {
  yield 'A line';
  yield 'Another line';
  yield 'Last line';
}

/**
 * Input: iterable over lines
 * Output: iterable over numbered lines
 */
function* numberLines(lineIterable) {
  let lineNumber = 1;
  for (const line of lineIterable) { // input
    yield lineNumber + ': ' + line; // output
    lineNumber++;
  }
}

请注意,`numberLines()` 中的 `yield` 出现在 `for-of` 循环内。`yield` 可以用在循环内,但不能用在回调函数内(稍后会详细介绍)。

让我们结合两个生成器来生成可迭代对象 `numberedLines`

const numberedLines = numberLines(genLines());
assert.deepEqual(
  numberedLines.next(), {value: '1: A line', done: false});
assert.deepEqual(
  numberedLines.next(), {value: '2: Another line', done: false});

这里使用生成器的关键好处是,一切都以增量方式工作:通过 `numberedLines.next()`,我们只向 `numberLines()` 请求一个带编号的行。反过来,它只向 `genLines()` 请求一个不带编号的行。

例如,如果 `genLines()` 从一个大型文本文件中读取行,这种增量性将继续起作用:如果我们向 `numberLines()` 请求一个带编号的行,一旦 `genLines()` 从文本文件中读取了第一行,我们就会得到一个。

如果没有生成器,`genLines()` 将首先读取所有行并返回它们。然后 `numberLines()` 将对所有行进行编号并返回它们。因此,我们必须等待更长的时间才能获得第一个带编号的行。

  练习:将普通函数转换为生成器

exercises/sync-generators/fib_seq_test.mjs

38.1.4 示例:映射可迭代对象

以下函数 `mapIter()` 类似于数组方法 `.map()`,但它返回一个可迭代对象,而不是一个数组,并且按需生成结果。

function* mapIter(iterable, func) {
  let index = 0;
  for (const x of iterable) {
    yield func(x, index);
    index++;
  }
}

const iterable = mapIter(['a', 'b'], x => x + x);
assert.deepEqual(
  Array.from(iterable), ['aa', 'bb']
);

  练习:过滤可迭代对象

exercises/sync-generators/filter_iter_gen_test.mjs

38.2 从生成器调用生成器(高级)

38.2.1 通过 `yield*` 调用生成器

`yield` 只能直接在生成器内部工作——到目前为止,我们还没有看到将 `yield` 委托给另一个函数或方法的方法。

让我们首先看看什么*不起作用*:在下面的例子中,我们希望 `foo()` 调用 `bar()`,以便后者为前者产生两个值。唉,一个天真的方法失败了

function* bar() {
  yield 'a';
  yield 'b';
}
function* foo() {
  // Nothing happens if we call `bar()`:
  bar();
}
assert.deepEqual(
  Array.from(foo()), []
);

为什么这不起作用?函数调用 `bar()` 返回一个可迭代对象,我们忽略了它。

我们希望的是 `foo()` 产生 `bar()` 产生的所有内容。这就是 `yield*` 运算符的作用

function* bar() {
  yield 'a';
  yield 'b';
}
function* foo() {
  yield* bar();
}
assert.deepEqual(
  Array.from(foo()), ['a', 'b']
);

换句话说,前面的 `foo()` 大致相当于

function* foo() {
  for (const x of bar()) {
    yield x;
  }
}

请注意,`yield*` 适用于任何可迭代对象

function* gen() {
  yield* [1, 2];
}
assert.deepEqual(
  Array.from(gen()), [1, 2]
);

38.2.2 示例:迭代树

`yield*` 允许我们在生成器中进行递归调用,这在迭代树等递归数据结构时非常有用。以以下二叉树的数据结构为例。

class BinaryTree {
  constructor(value, left=null, right=null) {
    this.value = value;
    this.left = left;
    this.right = right;
  }

  /** Prefix iteration: parent before children */
  * [Symbol.iterator]() {
    yield this.value;
    if (this.left) {
      // Same as yield* this.left[Symbol.iterator]()
      yield* this.left;
    }
    if (this.right) {
      yield* this.right;
    }
  }
}

方法 `[Symbol.iterator]()` 添加了对迭代协议的支持,这意味着我们可以使用 `for-of` 循环来迭代 `BinaryTree` 的实例

const tree = new BinaryTree('a',
  new BinaryTree('b',
    new BinaryTree('c'),
    new BinaryTree('d')),
  new BinaryTree('e'));

for (const x of tree) {
  console.log(x);
}
// Output:
// 'a'
// 'b'
// 'c'
// 'd'
// 'e'

  练习:迭代嵌套数组

exercises/sync-generators/iter_nested_arrays_test.mjs

38.3 背景:外部迭代与内部迭代

为了准备下一节,我们需要了解两种不同的迭代对象“内部”值的风格

下一节将提供两种迭代风格的示例。

38.4 生成器的用例:重用遍历

生成器的一个重要用例是提取和重用遍历。

38.4.1 要重用的遍历

例如,考虑以下遍历文件树并记录其路径的函数(它使用Node.js API来实现)

function logPaths(dir) {
  for (const fileName of fs.readdirSync(dir)) {
    const filePath = path.resolve(dir, fileName);
    console.log(filePath);
    const stats = fs.statSync(filePath);
    if (stats.isDirectory()) {
      logPaths(filePath); // recursive call
    }
  }
}

考虑以下目录

mydir/
    a.txt
    b.txt
    subdir/
        c.txt

让我们记录 `mydir/` 内的路径

logPaths('mydir');

// Output:
// 'mydir/a.txt'
// 'mydir/b.txt'
// 'mydir/subdir'
// 'mydir/subdir/c.txt'

我们如何重用此遍历并执行除记录路径之外的其他操作?

38.4.2 内部迭代(推送)

重用遍历代码的一种方法是通过*内部迭代*:每个遍历的值都被传递给一个回调函数(A 行)。

function visitPaths(dir, callback) {
  for (const fileName of fs.readdirSync(dir)) {
    const filePath = path.resolve(dir, fileName);
    callback(filePath); // (A)
    const stats = fs.statSync(filePath);
    if (stats.isDirectory()) {
      visitPaths(filePath, callback);
    }
  }
}
const paths = [];
visitPaths('mydir', p => paths.push(p));
assert.deepEqual(
  paths,
  [
    'mydir/a.txt',
    'mydir/b.txt',
    'mydir/subdir',
    'mydir/subdir/c.txt',
  ]);

38.4.3 外部迭代(拉取)

重用遍历代码的另一种方法是通过*外部迭代*:我们可以编写一个生成器来产生所有遍历的值(A 行)。

function* iterPaths(dir) {
  for (const fileName of fs.readdirSync(dir)) {
    const filePath = path.resolve(dir, fileName);
    yield filePath; // (A)
    const stats = fs.statSync(filePath);
    if (stats.isDirectory()) {
      yield* iterPaths(filePath);
    }
  }
}
const paths = Array.from(iterPaths('mydir'));

38.5 生成器的高级功能

《探索 ES6》中关于生成器的章节涵盖了本书范围之外的两个功能