ウェブエンジニア問題集
第18章

イテレータとジェネレータ — for...ofの裏側を知る

16
この章の目次開く

for...of で配列をループしたり、スプレッド構文 [...arr] でコピーしたりする操作は、日常的に使っています。これらが動くのは、配列が**イテラブル(反復可能)**だからです。

この章では、for...of の裏側にあるイテレータプロトコルと、イテレータを簡単に作れるジェネレータ関数を整理します。仕組みを知ると、自分で作ったオブジェクトを for...of で回せるようになります。

学習者学習者

for...of って配列用のループだと思ってた。裏側に仕組みがあるって、どういうこと?

先生先生

配列だけじゃなくて、Map・Set・文字列・NodeListも for...of で回せるよね。それは全部「イテラブル」という共通のルールに従っているから。このルールを理解すると、自作オブジェクトでも同じことができるようになるんだ。

for...ofの裏側の仕組みを学ぶイメージ

イテラブルとイテレータ — 2つの役割

まず用語を整理します。似た名前ですが、役割が違います。

用語役割持っているもの
イテラブル(iterable)「反復できるオブジェクト」Symbol.iterator メソッド
イテレータ(iterator)「次の値を1つずつ返す係」next() メソッド

イテラブルは「反復できますよ」という宣言をしているオブジェクトで、イテレータは実際に値を1つずつ取り出す仕組みです。

for...of は、この流れを自動的にやってくれる構文です。


イテレータプロトコル — next()が返すもの

イテレータは next() メソッドを持つオブジェクトです。next() を呼ぶたびに、次の形のオブジェクトを返します。

{ value: 値, done: false }   // まだ値がある
{ value: undefined, done: true }  // もう終わり
js

配列のイテレータを手動で使ってみましょう。

const arr = ['a', 'b', 'c'];
 
// 配列はイテラブルなので、Symbol.iteratorメソッドを持っている
const iterator = arr[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 }
js

for...of は、内部でこれと同じことをやっています。done: true になったらループを終了します。

学習者学習者

じゃあ for...of を使わずに whilenext() で同じことができるってこと?

その通りです。for...of を展開すると、だいたい次のような処理になります。

const arr = ['a', 'b', 'c'];
const iterator = arr[Symbol.iterator]();
 
let result = iterator.next();
while (!result.done) {
  console.log(result.value); // 'a', 'b', 'c'
  result = iterator.next();
}
js

普段は for...of を使えば十分ですが、イテレータの仕組みを知っていると、自分でイテラブルなオブジェクトを作れるようになります。


標準で備わっているイテラブル

JavaScriptに最初からイテラブルとして用意されているものは、意外と多いです。

イテラブルfor...of で回した結果
Array各要素
String各文字(サロゲートペアも正しく扱う)
Map[key, value] のペア
Set各要素
TypedArray各要素
arguments各引数
NodeList各DOMノード
// 文字列もイテラブル
for (const char of 'Hello') {
  console.log(char); // 'H', 'e', 'l', 'l', 'o'
}
 
// Mapもイテラブル — 分割代入と組み合わせると読みやすい
const map = new Map([['name', 'Alice'], ['age', 25]]);
for (const [key, value] of map) {
  console.log(`${key}: ${value}`);
}
js
先生先生

前章で扱ったMap・Setfor...of で回せるのも、イテラブルだからなんだ。全部同じ仕組みの上に乗っている。

スプレッド構文・分割代入もイテレータを使っている

イテラブルを消費するのは for...of だけではありません。

const set = new Set([1, 2, 3]);
 
// スプレッド構文 — イテレータから全要素を取り出して配列に
const arr = [...set]; // [1, 2, 3]
 
// 分割代入 — イテレータから順に取り出す
const [first, second] = set; // first=1, second=2
 
// Array.from — イテラブルから配列を作る
const arr2 = Array.from(set); // [1, 2, 3]
js

つまり「イテラブルであること」は、for...of だけでなく、JavaScriptの多くの構文で活用される共通インターフェースです。


自作のイテラブルを作る

自分のオブジェクトに [Symbol.iterator]() メソッドを実装すると、for...of で回せるようになります。

たとえば、指定した範囲の整数を順に返す range オブジェクトを作ってみましょう。

const range = {
  from: 1,
  to: 5,
 
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
 
    return {
      next() {
        if (current <= last) {
          return { value: current++, done: false };
        }
        return { value: undefined, done: true };
      },
    };
  },
};
 
for (const num of range) {
  console.log(num); // 1, 2, 3, 4, 5
}
 
console.log([...range]); // [1, 2, 3, 4, 5]
js
学習者学習者

Symbol.iterator って見慣れない書き方だけど、結局は「next() を返すメソッドを決まった名前で用意する」ってことだよね?

先生先生

その理解で合ってるよ。Symbol.iterator という名前を使うのは、既存のプロパティ名と衝突しないようにするため。やっていることは「next() メソッドを持つオブジェクトを返す」だけだね。

イテレータプロトコルのルールまとめ

自作するときに守るべきルールを整理しておきます。

  1. オブジェクトが [Symbol.iterator]() メソッドを持つ → イテラブル
  2. そのメソッドが next() を持つオブジェクトを返す → イテレータ
  3. next(){ value, done } 形式のオブジェクトを返す
  4. done: true になったらそれ以降の value は無視される

ジェネレータ関数 — イテレータを簡単に作る

ここまでのイテレータの手動実装は、current の管理や done の判定を自分で書く必要があり、やや面倒です。

**ジェネレータ関数(function*)**を使うと、同じことをずっと簡潔に書けます。

function* range(from, to) {
  for (let i = from; i <= to; i++) {
    yield i;
  }
}
 
for (const num of range(1, 5)) {
  console.log(num); // 1, 2, 3, 4, 5
}
js
ジェネレータで簡潔にイテレータを作るイメージ

function* と yield の仕組み

ジェネレータ関数は、呼び出すとジェネレータオブジェクトを返します。このオブジェクトはイテレータであり、同時にイテラブルでもあります。

関数の実行は yield のところで一時停止します。next() が呼ばれると、停止していた位置から再開し、次の yield まで進みます。

function* counter() {
  console.log('start');
  yield 1;
  console.log('resumed');
  yield 2;
  console.log('almost done');
  yield 3;
  console.log('end');
}
 
const gen = counter();
 
console.log(gen.next()); // "start" → { value: 1, done: false }
console.log(gen.next()); // "resumed" → { value: 2, done: false }
console.log(gen.next()); // "almost done" → { value: 3, done: false }
console.log(gen.next()); // "end" → { value: undefined, done: true }
js
学習者学習者

yield って return みたいに値を返すけど、関数が終わらないんだね。次に next() を呼んだら続きから動くって、不思議…。

先生先生

そこがジェネレータの核心。普通の関数は「呼ばれたら最後まで走りきる」けど、ジェネレータは「途中で止まって、頼まれたら続きを走る」。この性質のおかげで、必要なときに1つずつ値を生成できるんだ。

ジェネレータと手動イテレータの比較

先ほど手動で作った range をジェネレータで書き直してみます。

// 手動イテレータ — 状態管理を自分で書く
const rangeManual = {
  from: 1,
  to: 5,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next() {
        if (current <= last) {
          return { value: current++, done: false };
        }
        return { value: undefined, done: true };
      },
    };
  },
};
 
// ジェネレータ — yieldで値を返すだけ
function* rangeGen(from, to) {
  for (let i = from; i <= to; i++) {
    yield i;
  }
}
js

ジェネレータを使うと、current の管理も done の判定も不要になります。for ループの制御フローがそのまま使えるので、直感的です。


yield* — 別のイテラブルに委譲する

yield* を使うと、別のイテラブルの要素を1つずつ委譲できます。

function* concat(a, b) {
  yield* a;
  yield* b;
}
 
const result = [...concat([1, 2], [3, 4])];
console.log(result); // [1, 2, 3, 4]
js

これは次のコードと同じ意味です。

function* concat(a, b) {
  for (const v of a) yield v;
  for (const v of b) yield v;
}
js

yield* はジェネレータ同士を組み合わせるときに特に便利です。

function* letters() {
  yield 'a';
  yield 'b';
}
 
function* numbers() {
  yield 1;
  yield 2;
}
 
function* combined() {
  yield* letters();
  yield* numbers();
}
 
console.log([...combined()]); // ['a', 'b', 1, 2]
js

ジェネレータの実用 — 遅延評価

ジェネレータの大きな利点は、値を必要になった時点で生成する(遅延評価) ことです。配列のように全要素を一度にメモリに載せる必要がありません。

無限シーケンス

ジェネレータは done: true を返さない限り終わらないので、無限のシーケンスを表現できます。

function* naturals() {
  let n = 1;
  while (true) {
    yield n++;
  }
}
 
// 最初の5つだけ取り出す
const first5 = [];
for (const n of naturals()) {
  if (n > 5) break;
  first5.push(n);
}
console.log(first5); // [1, 2, 3, 4, 5]
js

配列で「全自然数」を作ることはできませんが、ジェネレータなら next() を呼んだ分だけ値を生成するので問題ありません。

学習者学習者

無限ループなのに大丈夫なの?メモリ溢れたりしない?

先生先生

yield で止まるから大丈夫。全要素をメモリに溜め込む配列と違って、ジェネレータは1個生成したら止まって待つ。break すればそこで終わり。使う側が「いくつ欲しいか」を制御できるのが強みだね。

大量データの逐次処理

実務では、大きなファイルやAPIのページネーション結果を1件ずつ処理する場面で、ジェネレータの考え方が活きます。

function* paginate(fetchPage) {
  let page = 1;
  while (true) {
    const data = fetchPage(page);
    if (data.length === 0) return;
    yield* data;
    page++;
  }
}
js

すべてのページを配列に溜めてから処理するのではなく、1ページ分のデータを取得しては消費する、という流れを自然に書けます。


ジェネレータの追加機能

next()に値を渡す

next() に引数を渡すと、ジェネレータ内の yield 式の戻り値としてその値を受け取れます。

function* conversation() {
  const name = yield 'お名前は?';
  const age = yield `${name}さん、年齢は?`;
  return `${name}さん(${age}歳)`;
}
 
const gen = conversation();
console.log(gen.next());         // { value: 'お名前は?', done: false }
console.log(gen.next('Alice'));  // { value: 'Aliceさん、年齢は?', done: false }
console.log(gen.next(25));       // { value: 'Aliceさん(25歳)', done: true }
js

最初の next() はジェネレータを起動するだけなので、引数は無視されます。2回目以降の next() の引数が、直前の yield 式の結果になります。

return()とthrow()

ジェネレータオブジェクトには next() の他に return()throw() メソッドがあります。

function* nums() {
  yield 1;
  yield 2;
  yield 3;
}
 
const gen = nums();
console.log(gen.next());     // { value: 1, done: false }
console.log(gen.return(99)); // { value: 99, done: true } — 強制終了
console.log(gen.next());     // { value: undefined, done: true }
js
  • return(value) — ジェネレータを即座に終了させ、{ value, done: true } を返す
  • throw(error) — ジェネレータ内で例外を投げる。try...catch で捕捉可能

実務で直接使う場面は多くありませんが、for...ofbreak は内部的に return() を呼んでいます。


for...of と for...in の違い

for...offor...in は名前が似ていますが、別物です。

構文回すもの対象
for...of値(value)イテラブル(配列・Map・Set・文字列など)
for...inキー(key)オブジェクトの列挙可能プロパティ
const arr = ['a', 'b', 'c'];
 
// for...of — 値を取り出す
for (const value of arr) {
  console.log(value); // 'a', 'b', 'c'
}
 
// for...in — インデックス(キー)を取り出す
for (const index in arr) {
  console.log(index); // '0', '1', '2'(文字列!)
}
js
先生先生

配列に for...in を使うと、インデックスが文字列で返る上に、プロトタイプチェーン上のプロパティまで拾うことがある。配列のループには for...offorEach を使おう。

普通のオブジェクト({})はイテラブルではないので、for...of では回せません。

const obj = { name: 'Alice', age: 25 };
 
// エラー: obj is not iterable
// for (const v of obj) { ... }
 
// オブジェクトのキーと値を回したいなら Object.entries()
for (const [key, value] of Object.entries(obj)) {
  console.log(`${key}: ${value}`);
}
js

非同期イテレータ — for await...of

非同期処理と組み合わせて、for await...of で非同期にデータを1つずつ受け取ることもできます。

async function* fetchPages(url) {
  let page = 1;
  while (true) {
    const res = await fetch(`${url}?page=${page}`);
    const data = await res.json();
    if (data.items.length === 0) return;
    yield data.items;
    page++;
  }
}
 
async function processAll() {
  for await (const items of fetchPages('/api/users')) {
    for (const item of items) {
      console.log(item.name);
    }
  }
}
js

非同期ジェネレータは async function* で宣言し、中で awaityield の両方を使えます。for await...of がイテレータの各 next() の結果を await してくれるので、Promiseの解決を手動で待つ必要がありません。

非同期イテレータは Symbol.asyncIterator メソッドを持ちます。通常の Symbol.iterator との対応は次の通りです。

同期非同期
Symbol.iteratorSymbol.asyncIterator
function*async function*
for...offor await...of
next(){ value, done } を返すnext()Promise<{ value, done }> を返す

使いどころの判断基準

イテレータとジェネレータは強力ですが、すべての場面で使うべきというわけではありません。

ジェネレータが向いている場面

  • 大量データや無限シーケンス — 全件をメモリに載せたくないとき
  • ページネーションやストリーム処理 — 1件ずつ取得して逐次処理するとき
  • 複雑な状態遷移yield で自然に状態を表現できるとき
  • カスタムイテラブル — 自作データ構造を for...of やスプレッド構文で使いたいとき

配列で十分な場面

  • データが数百件以下で、全件メモリに載っても問題ない
  • .map() / .filter() / .reduce() のチェーンで十分に読みやすい
  • 同じデータを複数回参照する(ジェネレータは一度消費すると再利用できない)
// ジェネレータは一度しか消費できない
function* nums() {
  yield 1;
  yield 2;
  yield 3;
}
 
const gen = nums();
console.log([...gen]); // [1, 2, 3]
console.log([...gen]); // [] — 2回目は空になる
js
学習者学習者

普段のコードでジェネレータを書く機会ってあるの?

先生先生

自分でジェネレータを書く機会は正直そこまで多くはない。でも、ライブラリやフレームワークの中では使われているし、for...of やスプレッド構文がなぜ動くかを理解するのが大事なんだ。「仕組みを知っていて選ばない」と「知らないから使えない」は全然違う。


まとめ

  • イテラブル[Symbol.iterator]() メソッドを持つオブジェクト。for...of、スプレッド構文、分割代入などで使える
  • イテレータnext() メソッドで { value, done } を順に返すオブジェクト
  • 配列・文字列・Map・Set・NodeListなどは標準でイテラブル
  • ジェネレータ関数(function* を使うと、yield で値を1つずつ返すイテレータを簡潔に書ける
  • ジェネレータは遅延評価されるため、無限シーケンスや大量データの逐次処理に向いている
  • for...of は値を回し、for...in はキーを回す——配列には for...of を使う
  • 非同期イテレータ(async function* + for await...of)で、非同期データの逐次処理もできる
普段の開発ではイテレータを直接書く機会は少なくても、for...of やスプレッド構文の裏側にあるプロトコルを知っておくと、ライブラリの動作理解やデバッグに役立ちます。

参考リンク

JavaScriptクイズに挑戦するこの章で学んだJavaScriptの知識を、4択クイズでアウトプットして定着させよう