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' という yield された値はオブジェクトでラップされていることに注意してください。これが、イテレーターが常に値を配信する方法です。

iter.next() を再度呼び出すと、以前に一時停止した場所から実行が継続されます。2 番目の yield が検出されると、genFunc2() が一時停止し、.next() は yield された値 '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() のように、一時停止することなく値をイテラブルに設定するだけではいけないのでしょうか?

一時停止により、ジェネレーターはコルーチン (協調的にマルチタスクされるプロセスと考えてください) の多くの機能を提供します。たとえば、イテラブルの次の値を要求すると、その値は (要求に応じて) 遅延して計算されます。次の 2 つのジェネレーター関数は、それが何を意味するのかを示しています。

/**
 * 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()yieldfor-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() に 1 行の番号付き行のみを要求します。次に、genLines() に 1 行の番号なし行のみを要求します。

この漸進主義は、たとえば、genLines() が大きなテキストファイルから行を読み取る場合でも機能し続けます。numberLines() に番号付き行を要求すると、genLines() がテキストファイルから最初の行を読み取るとすぐに 1 つ取得できます。

ジェネレーターがない場合、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() を呼び出し、後者が前者に対して 2 つの値を yield することを期待しています。残念ながら、単純なアプローチは失敗します。

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

なぜこれは機能しないのですか?関数呼び出し bar() は、無視するイテラブルを返します。

必要なのは、foo()bar() によって yield されるものをすべて yield することです。それが 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 背景:外部イテレーション vs. 内部イテレーション

次のセクション の準備として、オブジェクトの「内部」にある値を反復処理する 2 つの異なるスタイルについて学ぶ必要があります。

次のセクション には、両方のスタイルのイテレーションの例があります。

38.4 ジェネレーターのユースケース:トラバーサルの再利用

ジェネレーターの重要なユースケースの 1 つは、トラバーサルを抽出して再利用することです。

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 内部イテレーション (プッシュ)

トラバーサルコードを再利用する 1 つの方法は、内部イテレーションを使用することです。トラバースされた各値は、コールバックに渡されます (行 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 外部イテレーション (プル)

トラバーサルコードを再利用する別の方法は、外部イテレーションを使用することです。トラバースされたすべての値を yield するジェネレーターを作成できます (行 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 ジェネレーターの高度な機能

Exploring ES6ジェネレーターに関する章では、本書の範囲を超える 2 つの機能について説明しています。