給急躁的程式設計師的 JavaScript(ES2022 版)
請支持這本書:購買捐款
(廣告,請不要封鎖。)

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(),我們必須先建立反覆運算器/可反覆運算的 itergenFunc2() 現在暫停在主體「之前」。

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(),執行會繼續從我們先前暫停的地方。一旦我們遇到第二個 yieldgenFunc2() 會暫停,而 .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() 結果的屬性 .donetrue,這表示反覆運算器已完成。

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 僅在產生器內部直接運作,到目前為止,我們尚未看到委派產生的方式給其他函式或方法。

我們先來檢視哪些做法無法運作:在下列範例中,我們希望 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 中關於產生器的章節涵蓋了兩個超出本書範圍的功能