イテレータプロトコルとは
JavaScript には for...of、スプレッド構文(...)、分割代入、Array.from、Promise.all など、一見バラバラに見える機能がたくさんありますが、実はこれらは共通の仕組みの上に成り立っています。それが イテレータプロトコル です。この仕組みさえ押さえておけば、上に挙げた機能はすべて同じ発想の応用だと気づけるはずです。
イテレータとは、next() メソッドを持ち、そのメソッドが { value, done } を返すオブジェクトのことです。
next() を繰り返し呼び出すだけ。呼ぶたびに次の値と done フラグが返ってきて、done が true になったら終わり。プロトコルはこれだけです。メソッド名4文字とブール値ひとつ、ほんとにそれだけ。
iterable と iterator の違い
もうひとつ、セットで覚えておきたい概念があります。イテラブル (iterable) とは、イテレータを生成できるオブジェクトのこと。具体的には、Symbol.iterator という特別なキーに登録されたメソッドを通じてイテレータを返します。
配列はイテラブルです。numbers[Symbol.iterator]() を呼ぶと、新しいイテレータが返ってきます。文字列や Map、Set、arguments もすべてイテラブル。だからこそ for...of がどれに対しても動くわけです。
ここで押さえておきたいのが、「iterable と iterator の違い」。イテラブル がコレクション本体で、イテレータ はそれをたどるカーソル、というイメージです。1つのイテラブルからは、独立したカーソルをいくつでも取り出せます。
for...of が動く仕組み
for...of は、イテレータプロトコルの糖衣構文にすぎません。内部的には Symbol.iterator を呼び出し、done が true になるまで next() を呼び続けています。
スプレッドと分割代入は、結局やっていることは同じです。どちらもイテレータを最後まで回しているだけなんです。
Symbol.iterator を実装したオブジェクトを自作すれば、こうした機能の恩恵をそのまま受けられます。
カスタムイテラブルを作ってみる
ためしに、start から end までの数値を返す range オブジェクトを作ってみましょう。
いくつか注目すべきポイントがあります。
[Symbol.iterator]()は 計算されたメソッド名(computed method name) を使っています。キーになっているのは"Symbol.iterator"という文字列ではなく、シンボルそのものです。[Symbol.iterator]()を呼ぶたびに、独自のcurrentを持った真新しいイテレータが返ってきます。だからこそrangeを何度ループしても「使い切られる」ことがないわけです。- 返すイテレータに必要なのは
next()だけ。それ以外は不要です。
動くには動くのですが、いかんせん記述量が多い。もっとスマートな書き方があります。
ジェネレータ関数の登場
ジェネレータ関数(generator function) は function*(アスタリスクに注目)で宣言します。普通の関数のように最後まで一気に走るのではなく、yield 式でいったん停止し、あとから再開できるのが特徴です。呼び出した時点では関数の中身は実行されず、代わりにイテレータであり同時にイテラブルでもあるジェネレータオブジェクトが返ってきます。
next() を呼ぶたびに、関数の中身が yield に到達するまで実行されて、そこで一時停止しながら { value, done: false } を返します。関数が最後まで走り切ると { value: undefined, done: true } が返ってきます。
しかもジェネレータはイテラブルでもあるので、前のセクションで扱った構文すべてにそのまま使えます。
ジェネレータで range を書き直す
先ほどの冗長な実装と、こちらを見比べてみてください。
はい、それだけです。[Symbol.iterator] の前の * が付くことで、これがジェネレータメソッドになります。yield i の一行だけで、自前で書いていたイテレータオブジェクトをまるごと置き換えられるわけです。next も done も書かなくていいし、インデックスがズレる心配もなし。push の代わりに yield を使っただけの、ごく普通のループです。
ジェネレータが存在する理由はまさにここにあります。「イテレータを書く」という作業を、「yield する関数を書く」だけに落とし込んでくれるんです。
yield と return の違い
yield は処理を一時停止し、return は処理を終了します。yield は何度でも呼び出せて、ジェネレータは中断した位置から処理を再開します。
ジェネレータ内の return は、ジェネレータを終了させる呼び出しで { value: "done", done: true } として返ってきます。ただし for...of やスプレッド構文はこの戻り値を無視し、done が false の値だけを取り出します。なので、return value で最後の要素をこっそりループに流し込もうとしても、スキップされてしまうので注意してください。
遅延評価と無限シーケンス
ジェネレータは値を必要になったタイミングで1つずつ生成します。つまり、配列では表現できないようなシーケンスも扱えるということです。
ループはまさに while (true) と書かれているのに、プログラムはちゃんと終了します。というのも、ジェネレータは「次の値をくれ」と呼ばれたときだけ先に進むからです。最初の N 個だけ取り出して止めれば、残りの処理は一切実行されません。
take 自体もジェネレータで、別のジェネレータをラップしています。こうやってジェネレータを組み合わせられるのが、大きな魅力のひとつです。小さな部品をそれぞれ1つの役割に集中させて、つなげていく感覚ですね。
yield* で処理を委譲する
あるジェネレータから別のイテラブルの値をまとめて yield したいときは、yield* を使って委譲できます。
yield* は反復可能なもの(配列、Set、ほかのジェネレータなど)ならなんでも受け取れて、中身を一つずつ順番に流してくれます。イテレータ版のスプレッド構文みたいなものですね。
非同期ジェネレータを少しだけ
async function* で宣言したジェネレータは、時間のかかる値も yield できます。API からのストリーミングやファイルをチャンク単位で読むときに便利です。消費する側は for await...of を使います:
async function* paginate(url) {
let next = url;
while (next) {
const res = await fetch(next);
const page = await res.json();
for (const item of page.items) yield item;
next = page.nextUrl;
}
}
for await (const item of paginate("/api/users")) {
console.log(item);
}
このスニペットは実際のエンドポイントが必要なのでここでは動かせませんが、こういう形があるということは知っておいて損はありません。通常のジェネレータを理解してしまえば、非同期ジェネレータは同じ発想に await を散りばめただけです。
ジェネレータを使うべき場面
次のようなときに使うといいでしょう。
- シーケンスが無限、もしくは無限になり得るとき。ID、タイムスタンプ、リトライの待ち時間など。
- すべての値を生成するのがコストが高く、消費側が途中で止める可能性があるとき。
- カスタムオブジェクトに
Symbol.iteratorを実装したいとき。手書きで{ next() }オブジェクトを作るより、ほぼ確実に短く書けます。 - 中間配列を作らずに、ストリーミング的な変換処理(
take、filter、mapなど)を組み合わせたいとき。
データがすでにメモリ上にあって、しかもサイズが小さいなら、普通の配列で十分です。ジェネレータはタダではありません。関数を中断・再開する仕組みにはそれなりのオーバーヘッドがありますし、ジェネレータを経由したスタックトレースは読みにくくなることもあります。
次回:Symbol について
Symbol.iterator はほとんどの人が最初に出会うシンボルですが、シンボルはこれだけではありません。Symbol はまさにこうした拡張ポイントのために設計されたプリミティブ型で、通常のプロパティ名とぶつからずに、言語自身や自分のコードからオブジェクトにフックを差し込めるユニークなキーです。次のページでは、その Symbol を見ていきます。
よくある質問
iterable(イテラブル)とiterator(イテレータ)の違いは?
iterableは Symbol.iterator メソッドを持っていて、呼び出すとイテレータを返すオブジェクトのこと。イテレータの方は next() を呼ぶと { value, done } を返す、実際に値を1つずつ取り出す側のオブジェクトです。配列・文字列・Map・Set はすべてiterableなので、その Symbol.iterator を呼べば中身を1つずつ取り出せるイテレータが手に入ります。
ジェネレータ関数って何ですか?
function* で宣言する、値を遅延評価で作り出すための関数です。呼び出してもすぐには中身が実行されず、代わりにジェネレータオブジェクト(これはイテレータであり、同時にイテラブルでもあります)が返ってきます。next() を呼ぶたびに次の yield までコードが進んで、そこで一時停止して値を返す、という動きになります。
ジェネレータ内の yield と return はどう違う?
yield は「値を返して一時停止」するだけなので、次に next() が呼ばれると続きから再開できます。一方 return はジェネレータを完全に終わらせるもので、done: true になってそれ以降の値は出てきません。yield は何回でも書けますが、意味のある return は実質1回だけです。
配列じゃなくてジェネレータを使うべき場面は?
無限に続くシーケンス、計算コストが高いシーケンス、あるいは全部は要らず一部だけ欲しいときですね。ジェネレータはオンデマンドで1つずつ値を作るので、終わりのないIDストリームやページング付きAPIの結果を、全部メモリに展開せずに表現できます。逆にすでに小さくて固定の配列を持っているなら、素直に配列でOKです。