ジェネレータとイテレータプロトコルの内部動作
for-of やスプレッドが内部で何を呼んでいるかが分かると、独自オブジェクトを自在に反復させられる。yield の中断・再開とイテレータプロトコルの仕組みを内部動作から解説します。
- 1.イテレータは next() を呼ぶたびに { value, done } を返すオブジェクト。for-of・スプレッド・分割代入は Symbol.iterator でこのイテレータを取り出し、done が true になるまで next() を回す。
- 2.ジェネレータ関数は呼んでも本体を実行せず、停止状態のジェネレータ(イテレータ兼イテラブル)を返す。yield で実行を中断し、ローカル変数とプログラムカウンタを保持したまま、次の next() で同じ位置から再開する。
- 3.next(v) の引数は直前の yield 式の評価結果になり、return() は途中終了、throw() は yield 位置で例外を注入する。for-of は中断時に必ず return() を呼ぶため、ジェネレータの finally でクリーンアップが走る。
反復を支える2つの約束事
JavaScript で配列・文字列・Map・Set を for-of で回せたり、スプレッド([...x])で展開できたりするのは、それらが共通のプロトコルに従っているからです。この約束事は2層に分かれます。
- イテレータプロトコル:
next()メソッドを持ち、呼ぶたびに{value, done}という形のオブジェクトを返すこと。doneがtrueになったら反復終了の合図です。 - イテラブルプロトコル:
Symbol.iteratorという名前のメソッドを持ち、それを呼ぶとイテレータを返すこと。
この2つは仕様(ECMAScript)で厳密に定義された手続きで、for-of もスプレッドも分割代入も、すべて内部でこの手続きを呼び出します。基礎は JavaScript を前提に、本記事はその内部動作を解きほぐします。
| プロトコル | 必須メソッド | 返すもの | 役割 |
|---|---|---|---|
| イテラブル | [Symbol.iterator]() | イテレータ | 反復の開始点を提供する |
| イテレータ | next() | { value, done } | 1要素ずつ前進する |
| イテレータ(任意) | return() / throw() | { value, done } | 途中終了・例外注入 |
イテレータを手で書いてみる
イテレータの実体は、ただのオブジェクトです。状態を閉じ込めて next() で1歩ずつ進めれば、それだけで for-of の対象になります。
function range(start, end) {
let i = start;
return {
// 自分自身をイテレータとして返す=イテラブルでもある
[Symbol.iterator]() { return this; },
next() {
if (i < end) return { value: i++, done: false };
return { value: undefined, done: true };
},
};
}
for (const n of range(0, 3)) console.log(n); // 0, 1, 2
ポイントは done: false のときの value が「今回の要素」で、done: true になった瞬間の value は反復値に含まれないことです。for-of は done が true になった時点でループを抜け、そのときの value は無視します(後述の return 文の戻り値だけは別扱い)。
Map や配列は「イテラブル」で、Symbol.iterator を呼ぶと毎回新しいイテレータを返します。だから同じ配列を for-of で何度でも回せます。一方、イテレータ自身は使い切ると done: true を返し続ける1回限りの存在です。上の range のように [Symbol.iterator]() return this とすると「イテレータ兼イテラブル」になりますが、これは一度しか回せない点に注意します。
ジェネレータ:中断と再開のからくり
イテレータを手書きすると状態管理が面倒です。ジェネレータ関数(function*)はこの定型を言語機能に押し込みます。最大の特徴は、関数の途中で実行を止め、後から同じ位置から再開できることです。
function* gen() {
console.log('A');
const x = yield 1; // ここで中断。next() が呼ばれるまで止まる
console.log('B', x);
yield 2;
console.log('C');
}
const it = gen(); // 本体はまだ1行も実行されない
it.next(); // 'A' → { value: 1, done: false } で停止
it.next(10); // 'B 10' → { value: 2, done: false } で停止
it.next(); // 'C' → { value: undefined, done: true }
ここで起きていることを内部レベルで言うと、ジェネレータ関数の呼び出しは本体を実行せず、停止状態のジェネレータオブジェクトを生成して即座に返すだけです。next() を呼んで初めて本体が動き、yield 式に到達すると、エンジンはそのジェネレータの実行コンテキスト(ローカル変数・どこまで進んだかのプログラムカウンタ)をまるごと退避して呼び出し側に戻ります。次の next() で退避したコンテキストを復元し、yield の直後から続きを実行します。コールスタックを丸ごと巻き取って後で巻き戻す、いわば中断可能な関数です。
const x = yield 1 の x には、次の next(10) に渡した 10 が入ります。yield 1 自体は「1 を外へ渡して中断する」式で、その評価結果は再開時に外から注入される値です。だから最初の next() の引数は捨てられます(本体がまだ最初の yield に達していないため)。この双方向のやり取りが、ジェネレータを単なる値の列以上のもの(協調的なコルーチン)にしています。
return() と throw():途中終了と例外注入
イテレータには next() のほかに、任意の2つのメソッドがあります。ジェネレータはこれらを自動で実装します。
return(v):反復を途中で打ち切ります。ジェネレータ内では、現在のyield位置にreturn v文があったかのように振る舞い、finallyブロックがあれば実行してから{value: v, done: true}を返します。throw(e):現在のyield位置で例外eを投げます。ジェネレータ内のtry/catchで捕捉でき、捕まえなければ呼び出し側まで伝播します。
function* withCleanup() {
try {
yield 'open';
yield 'work';
} finally {
console.log('cleanup'); // 途中終了でも必ず走る
}
}
const it = withCleanup();
it.next(); // { value: 'open', done: false }
it.return('stop'); // 'cleanup' → { value: 'stop', done: true }
it.next(); // { value: undefined, done: true }(以降は終了済み)
この finally 保証が実務上きわめて重要です。ファイルハンドルや購読の解放を finally に置けば、反復が最後まで回ろうと途中で打ち切られようと確実にクリーンアップされます。次節の通り、for-of はこの return() を自動で呼んでくれます。
for-of・スプレッドが内部で呼ぶ手続き
for-of は糖衣構文で、脱糖(desugar)すると次の手続きそのものです。仕様の用語では、開始時に GetIterator([Symbol.iterator]() の呼び出し)、各周回で IteratorStep(next() を呼んで done を判定)、そして中断時に IteratorClose(return() の呼び出し)を実行します。
// for (const v of iterable) { body } は概ねこう展開される
const it = iterable[Symbol.iterator](); // GetIterator
try {
while (true) {
const r = it.next(); // IteratorStep
if (r.done) break;
const v = r.value;
/* body */
}
} finally {
// break / return / throw で抜けたときだけ呼ばれる
if (!正常完了 && it.return) it.return(); // IteratorClose
}
重要なのは、ループを break したり例外で抜けたりした場合、for-of が return() を自動で呼ぶ点です。これがジェネレータの finally を発火させます。同じ仕組みはスプレッド([...it]・f(...it))・分割代入([a, b] = it)・yield*・Array.from・Promise.all などすべての反復で共通です。たとえば分割代入 [a, b] = it は2要素を取り出した時点で残りを使わないので、return() を呼んでイテレータを閉じます。
自分で while を書いて手動 next() するコードは、途中で抜けても return() を呼びません。ジェネレータの finally が走らず、リソースが解放されない可能性があります。中断しうる反復を手書きするなら、抜けるパスで明示的に it.return && it.return() を呼ぶか、return() を自動で呼んでくれる for-of を使うのが安全です。
yield* による委譲
yield* は「別のイテラブルへ反復を丸ごと委譲する」演算子です。単なるループの省略ではなく、next()・return()・throw() の透過的な転送まで含みます。
function* inner() { yield 1; yield 2; }
function* outer() {
yield 0;
const result = yield* inner(); // inner を全消化。inner の return 値が result に入る
yield 3;
}
[...outer()]; // [0, 1, 2, 3]
yield* inner() は内部で inner() のイテレータを取り出し、外から来た next(v) の値を内側へ中継し、内側の yield をそのまま外へ通します。さらに外側に対する return()/throw() も内側へ転送するため、ネストしたジェネレータでもクリーンアップと例外の連鎖が崩れません。
押さえる4点。❶ for-of は [Symbol.iterator]() でイテレータを取り、done が true まで next() を回す。❷ ジェネレータ関数は呼んだだけでは本体を実行せず、停止状態のオブジェクトを返す。❸ next(v) の引数は直前の yield 式の評価値になる(双方向通信)。❹ for-of・スプレッド・分割代入は中断時に return() を呼ぶので、ジェネレータの finally が走る。この4点でほとんどの挙動を説明できます。
遅延評価と無限列
ジェネレータの真価は、値を要求された瞬間に1個ずつ計算する遅延評価にあります。配列のように全要素を先に作る必要がないため、無限に続く列も表現できます。
function* naturals() {
let n = 0;
while (true) yield n++; // 無限。next() のたびに1個だけ計算
}
const it = naturals();
it.next().value; // 0
it.next().value; // 1(必要な分だけ評価される)
for-of でこのまま回すと止まらないので、take(n) のような上限つきの反復子と組み合わせて使います。メモリに全列を載せずに済むのが利点で、大きなデータのストリーミング処理やパイプライン構築に向きます。エンジンが yield ごとに実行コンテキストを退避・復元する仕組みは JavaScript エンジンの内部(JIT とインラインキャッシュ) で扱う最適化とも関わり、for-await-of を支える非同期イテレータは イベントループの内部構造 のマイクロタスクと連動します。
まとめ
反復はイテラブル(Symbol.iterator を持つ)とイテレータ(next() で {value, done} を返す)の2層プロトコルで成り立ち、for-of・スプレッド・分割代入は内部でこの手続きを呼びます。ジェネレータ関数は呼んでも本体を実行せず停止状態のオブジェクトを返し、yield で実行コンテキストを退避して中断、次の next() で同じ位置から再開します。next(v) の引数は yield 式の評価値となり、return()/throw() で途中終了や例外注入ができます。for-of が中断時に return() を呼ぶため finally が確実に走る点を押さえれば、安全な反復処理と遅延評価を自在に組めます。
Web/フロントエンド Article
ジェネレータとイテレータプロトコルの内部動作を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
ジェネレータ関数は呼んでも本体を実行せず、停止状態のジェネレータ(イテレータ兼イテラブル)を返す。yield で実行を中断し、ローカル変数とプログラムカウンタを保持したまま、次の next() で同じ位置から再開する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / ジェネレータ」に近いか確認する。
- 強みである「イテレータは next() を呼ぶたびに { value, done } を返すオブジェクト。for-of・スプレッド・分割代入は Symbol.iterator でこのイテレータを取り出し、done が true になるまで next() を回す。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。