JavaScript の Iterator パターン - コレクション走査の統一インターフェース

配列は for...of で回せます。Map も Set も同じ構文で回せます。内部のデータ構造がまったく異なるのに、同じ方法で要素を取り出せるのはなぜでしょうか。その答えが Iterator パターンです。コレクションの内部構造を隠蔽し、要素への順次アクセスを統一的なインターフェースで提供する振る舞いパターンであり、JavaScript では言語仕様そのものに深く組み込まれています。

Iterator プロトコルという約束事

JavaScript の Iterator パターンは、言語仕様として 2 つのプロトコルで定義されています。

Iterable プロトコル

オブジェクトが Symbol.iterator メソッドを持ち、そのメソッドが Iterator オブジェクトを返すという約束事です。配列、Map、Set、文字列などの組み込みオブジェクトはすべてこのプロトコルを満たしています。

Iterator プロトコル

next() メソッドを持ち、呼び出すたびに { value, done } 形式のオブジェクトを返すという約束事です。donetrue になったら走査の終了を意味します。

この 2 つのプロトコルを満たしてさえいれば、どんなデータ構造でも for...of やスプレッド構文の対象になります。まず、プロトコルの動きを手動で確認してみましょう。

const array = ["a", "b", "c"];
const iterator = array[Symbol.iterator]();

console.log(iterator.next()); // { value: "a", done: false }
console.log(iterator.next()); // { value: "b", done: false }
console.log(iterator.next()); // { value: "c", done: false }
console.log(iterator.next()); // { value: undefined, done: true }

for...of が内部で行っているのは、まさにこの next() の繰り返し呼び出しです。done: true になった時点でループが終了します。

自作 Iterable を作る

プロトコルを理解したところで、独自のデータ構造に Iterator を実装してみます。整数の範囲を表す Range クラスを作りましょう。

class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;

    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        }
        return { value: undefined, done: true };
      },
    };
  }
}

Symbol.iterator メソッドが Iterator オブジェクトを返し、その next() が呼ばれるたびに現在の値を返しつつカウンタを進めます。current は Iterator ごとに独立したクロージャに閉じ込められているため、同じ Range から複数の Iterator を同時に使っても干渉しません。

const range = new Range(1, 5);

for (const n of range) {
  process.stdout.write(`${n} `);
}
// 1 2 3 4 5

console.log([...range]);
// [1, 2, 3, 4, 5]

const [first, second, ...rest] = range;
console.log(first, second, rest);
// 1 2 [3, 4, 5]

for...of、スプレッド構文、分割代入がすべて動作しています。Iterable プロトコルを満たすだけで、言語が提供するあらゆる反復処理の仕組みに乗れるわけです。

Iterator パターンの登場人物

パターンとしての構造を整理します。

Iterable(集合体)

データを保持し、Symbol.iterator で Iterator を生成する側。配列や Map のような組み込み型、あるいは自作のコレクションクラスが該当する。

Iterator(反復子)

next() で要素を 1 つずつ取り出す側。走査の状態(現在位置)を内部に持ち、Iterable の内部構造には依存しない。

この分離によって、走査のロジックとデータの保持が独立します。同じコレクションに対して順方向・逆方向・フィルタ付きなど、複数の走査方法を用意することも可能です。

実践例:ツリー構造の走査

Iterator パターンの真価は、配列のような単純な構造ではなく、木構造やグラフのような複雑なデータ構造で発揮されます。ファイルシステムのようなツリーを深さ優先で走査する例を見てみましょう。

class TreeNode {
  constructor(name, children = []) {
    this.name = name;
    this.children = children;
  }

  [Symbol.iterator]() {
    const stack = [this];

    return {
      next() {
        if (stack.length === 0) {
          return { value: undefined, done: true };
        }
        const node = stack.pop();
        for (let i = node.children.length - 1; i >= 0; i--) {
          stack.push(node.children[i]);
        }
        return { value: node, done: false };
      },
    };
  }
}

スタックを使った深さ優先探索を next() の中で 1 ステップずつ実行しています。再帰を使わずに状態を保持できるのは、Iterator の呼び出し側が制御のタイミングを握っているからです。

const fileSystem = new TreeNode("root", [
  new TreeNode("src", [
    new TreeNode("index.js"),
    new TreeNode("utils", [
      new TreeNode("helpers.js"),
      new TreeNode("format.js"),
    ]),
  ]),
  new TreeNode("package.json"),
  new TreeNode("README.md"),
]);

for (const node of fileSystem) {
  console.log(node.name);
}
// root
// src
// index.js
// utils
// helpers.js
// format.js
// package.json
// README.md

ツリーの内部構造や走査アルゴリズムを知らなくても、for...of で全ノードにアクセスできます。幅優先探索が必要になったら、スタックをキューに変えた別の Iterator を追加するだけです。

ジェネレータによる簡潔な実装

next() を手書きするのは、状態管理が煩雑になりがちです。JavaScript のジェネレータ関数を使うと、yield で値を返すだけで Iterator プロトコルを自動的に満たせます。

class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  *[Symbol.iterator]() {
    for (let i = this.start; i <= this.end; i++) {
      yield i;
    }
  }
}

for (const n of new Range(1, 5)) {
  process.stdout.write(`${n} `);
}
// 1 2 3 4 5

先ほどの手動実装と比べると、コード量が大幅に減っています。ジェネレータは yield のたびに実行を中断し、next() が呼ばれたときに再開するため、Iterator の状態管理を言語ランタイムに任せられます。

ツリー走査もジェネレータで書き直してみましょう。

class TreeNode {
  constructor(name, children = []) {
    this.name = name;
    this.children = children;
  }

  *[Symbol.iterator]() {
    yield this;
    for (const child of this.children) {
      yield* child;
    }
  }
}

yield* は別の Iterable に走査を委譲する構文です。再帰的にすべての子ノードを走査しますが、コードとしてはたった 4 行で表現できています。手動のスタック管理が完全に不要になり、走査の意図がそのまま読み取れるようになりました。

遅延評価という恩恵

Iterator は要素を 1 つずつ取り出すため、全要素をあらかじめメモリに載せる必要がありません。無限に続くシーケンスも自然に表現できます。

function* fibonacci() {
  let a = 0;
  let b = 1;
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

このジェネレータは無限にフィボナッチ数を生成しますが、next() が呼ばれるまで次の値は計算されません。必要な分だけ取り出す仕組みと組み合わせれば、無限シーケンスを安全に扱えます。

function take(iterable, count) {
  const result = [];
  for (const value of iterable) {
    result.push(value);
    if (result.length >= count) break;
  }
  return result;
}

console.log(take(fibonacci(), 10));
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

next() が呼ばれる

yield まで実行し値を返す

次の next() まで一時停止する

この遅延評価の性質は、大量データの処理でも有効です。たとえばログファイルを 1 行ずつ読み込むジェネレータを書けば、数ギガバイトのファイルでもメモリ消費を一定に保てます。

組み込み Iterable の一覧

JavaScript には最初から Iterator プロトコルを実装しているオブジェクトが複数あります。

Symbol.iterator の挙動
Arrayインデックス順に要素を返す
StringUnicode コードポイント単位で文字を返す
Map[key, value] ペアを挿入順に返す
Set値を挿入順に返す
TypedArrayインデックス順にバイト値を返す

注目すべきは、通常のオブジェクト({})が Iterable ではないという点です。for...of でオブジェクトのプロパティを回すことはできません。必要であれば Object.entries() で配列に変換するか、Symbol.iterator を自前で実装することになります。

const config = {
  host: "localhost",
  port: 3000,
  debug: true,
};

// オブジェクトは直接 for...of できない
// for (const v of config) {} // TypeError

// Object.entries() で Iterable に変換
for (const [key, value] of Object.entries(config)) {
  console.log(`${key}: ${value}`);
}
// host: localhost
// port: 3000
// debug: true

for…of と for…in の違い

Iterator に関連してよく混同されるのが for...offor...in の違いです。

for...of

Iterable プロトコルに従い、Symbol.iterator が返す値を順に取り出す。配列の要素、Map のエントリ、Set の値など、データそのものを走査する。

for...in

オブジェクトの列挙可能プロパティのキーを走査する。プロトタイプチェーン上のプロパティも含まれるため、配列に対して使うと予期しない結果を招く。

const arr = ["x", "y", "z"];

// for...of は値を取り出す
for (const v of arr) process.stdout.write(`${v} `);
// x y z

// for...in はインデックス(キー)を取り出す
for (const i in arr) process.stdout.write(`${i} `);
// 0 1 2

配列の走査には常に for...of を使うのが安全です。for...in はオブジェクトのキー列挙を目的とした構文であり、配列に使うとプロトタイプ上のプロパティが紛れ込むリスクがあります。

JavaScript で自作クラスを for...of で走査可能にするために、最低限実装すべきものはどれですか?

  • toString メソッド
  • forEach メソッド
  • Symbol.iterator メソッドが Iterator オブジェクトを返すこと
  • length プロパティと数値インデックス
__RESULT__

for…of は Iterable プロトコルに従って動作します。Symbol.iterator メソッドが next() を持つ Iterator を返せば、どんなオブジェクトでも for…of の対象になります。forEach や length は配列固有の仕組みであり、Iterable プロトコルとは無関係です。

Iterator パターンは、JavaScript においては言語仕様そのものです。Symbol.iteratornext() という 2 つの約束事を守るだけで、for...of、スプレッド構文、分割代入、Promise.all など、言語が提供するあらゆる反復処理の仕組みに自作のデータ構造を接続できます。既存のコレクションで事足りるうちは意識する必要がありませんが、木構造やグラフ、無限シーケンスのような独自のデータ構造を扱う場面では、このプロトコルを実装する価値が出てきます。