Menu
日本語
Playgroundで試す

JavaScriptのイテレータとジェネレータ入門(function*/yield)

JavaScriptのイテレータプロトコルの仕組みから、自作オブジェクトをイテラブルにする方法、そしてジェネレータ関数でそれをラクに書くコツまで、実例ベースでまとめました。

イテレータプロトコルとは

JavaScript には for...of、スプレッド構文(...)、分割代入、Array.fromPromise.all など、一見バラバラに見える機能がたくさんありますが、実はこれらは共通の仕組みの上に成り立っています。それが イテレータプロトコル です。この仕組みさえ押さえておけば、上に挙げた機能はすべて同じ発想の応用だと気づけるはずです。

イテレータとは、next() メソッドを持ち、そのメソッドが { value, done } を返すオブジェクトのことです。

index.js
Output
Click Run to see the output here.

next() を繰り返し呼び出すだけ。呼ぶたびに次の値と done フラグが返ってきて、donetrue になったら終わり。プロトコルはこれだけです。メソッド名4文字とブール値ひとつ、ほんとにそれだけ。

iterable と iterator の違い

もうひとつ、セットで覚えておきたい概念があります。イテラブル (iterable) とは、イテレータを生成できるオブジェクトのこと。具体的には、Symbol.iterator という特別なキーに登録されたメソッドを通じてイテレータを返します。

index.js
Output
Click Run to see the output here.

配列はイテラブルです。numbers[Symbol.iterator]() を呼ぶと、新しいイテレータが返ってきます。文字列や MapSetarguments もすべてイテラブル。だからこそ for...of がどれに対しても動くわけです。

ここで押さえておきたいのが、「iterable と iterator の違い」。イテラブル がコレクション本体で、イテレータ はそれをたどるカーソル、というイメージです。1つのイテラブルからは、独立したカーソルをいくつでも取り出せます。

for...of が動く仕組み

for...of は、イテレータプロトコルの糖衣構文にすぎません。内部的には Symbol.iterator を呼び出し、donetrue になるまで next() を呼び続けています。

index.js
Output
Click Run to see the output here.

スプレッドと分割代入は、結局やっていることは同じです。どちらもイテレータを最後まで回しているだけなんです。

index.js
Output
Click Run to see the output here.

Symbol.iterator を実装したオブジェクトを自作すれば、こうした機能の恩恵をそのまま受けられます。

カスタムイテラブルを作ってみる

ためしに、start から end までの数値を返す range オブジェクトを作ってみましょう。

index.js
Output
Click Run to see the output here.

いくつか注目すべきポイントがあります。

  • [Symbol.iterator]()計算されたメソッド名(computed method name) を使っています。キーになっているのは "Symbol.iterator" という文字列ではなく、シンボルそのものです。
  • [Symbol.iterator]() を呼ぶたびに、独自の current を持った真新しいイテレータが返ってきます。だからこそ range を何度ループしても「使い切られる」ことがないわけです。
  • 返すイテレータに必要なのは next() だけ。それ以外は不要です。

動くには動くのですが、いかんせん記述量が多い。もっとスマートな書き方があります。

ジェネレータ関数の登場

ジェネレータ関数(generator function)function*(アスタリスクに注目)で宣言します。普通の関数のように最後まで一気に走るのではなく、yield 式でいったん停止し、あとから再開できるのが特徴です。呼び出した時点では関数の中身は実行されず、代わりにイテレータであり同時にイテラブルでもあるジェネレータオブジェクトが返ってきます。

index.js
Output
Click Run to see the output here.

next() を呼ぶたびに、関数の中身が yield に到達するまで実行されて、そこで一時停止しながら { value, done: false } を返します。関数が最後まで走り切ると { value: undefined, done: true } が返ってきます。

しかもジェネレータはイテラブルでもあるので、前のセクションで扱った構文すべてにそのまま使えます。

index.js
Output
Click Run to see the output here.

ジェネレータで range を書き直す

先ほどの冗長な実装と、こちらを見比べてみてください。

index.js
Output
Click Run to see the output here.

はい、それだけです。[Symbol.iterator] の前の * が付くことで、これがジェネレータメソッドになります。yield i の一行だけで、自前で書いていたイテレータオブジェクトをまるごと置き換えられるわけです。nextdone も書かなくていいし、インデックスがズレる心配もなし。push の代わりに yield を使っただけの、ごく普通のループです。

ジェネレータが存在する理由はまさにここにあります。「イテレータを書く」という作業を、「yield する関数を書く」だけに落とし込んでくれるんです。

yieldreturn の違い

yield は処理を一時停止し、return は処理を終了します。yield は何度でも呼び出せて、ジェネレータは中断した位置から処理を再開します。

index.js
Output
Click Run to see the output here.

ジェネレータ内の return は、ジェネレータを終了させる呼び出しで { value: "done", done: true } として返ってきます。ただし for...of やスプレッド構文はこの戻り値を無視し、donefalse の値だけを取り出します。なので、return value で最後の要素をこっそりループに流し込もうとしても、スキップされてしまうので注意してください。

遅延評価と無限シーケンス

ジェネレータは値を必要になったタイミングで1つずつ生成します。つまり、配列では表現できないようなシーケンスも扱えるということです。

index.js
Output
Click Run to see the output here.

ループはまさに while (true) と書かれているのに、プログラムはちゃんと終了します。というのも、ジェネレータは「次の値をくれ」と呼ばれたときだけ先に進むからです。最初の N 個だけ取り出して止めれば、残りの処理は一切実行されません。

index.js
Output
Click Run to see the output here.

take 自体もジェネレータで、別のジェネレータをラップしています。こうやってジェネレータを組み合わせられるのが、大きな魅力のひとつです。小さな部品をそれぞれ1つの役割に集中させて、つなげていく感覚ですね。

yield* で処理を委譲する

あるジェネレータから別のイテラブルの値をまとめて yield したいときは、yield* を使って委譲できます。

index.js
Output
Click Run to see the output here.

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() } オブジェクトを作るより、ほぼ確実に短く書けます。
  • 中間配列を作らずに、ストリーミング的な変換処理(takefiltermap など)を組み合わせたいとき。

データがすでにメモリ上にあって、しかもサイズが小さいなら、普通の配列で十分です。ジェネレータはタダではありません。関数を中断・再開する仕組みにはそれなりのオーバーヘッドがありますし、ジェネレータを経由したスタックトレースは読みにくくなることもあります。

次回:Symbol について

Symbol.iterator はほとんどの人が最初に出会うシンボルですが、シンボルはこれだけではありません。Symbol はまさにこうした拡張ポイントのために設計されたプリミティブ型で、通常のプロパティ名とぶつからずに、言語自身や自分のコードからオブジェクトにフックを差し込めるユニークなキーです。次のページでは、その Symbol を見ていきます。

よくある質問

iterable(イテラブル)とiterator(イテレータ)の違いは?

iterableは Symbol.iterator メソッドを持っていて、呼び出すとイテレータを返すオブジェクトのこと。イテレータの方は next() を呼ぶと { value, done } を返す、実際に値を1つずつ取り出す側のオブジェクトです。配列・文字列・MapSet はすべてiterableなので、その Symbol.iterator を呼べば中身を1つずつ取り出せるイテレータが手に入ります。

ジェネレータ関数って何ですか?

function* で宣言する、値を遅延評価で作り出すための関数です。呼び出してもすぐには中身が実行されず、代わりにジェネレータオブジェクト(これはイテレータであり、同時にイテラブルでもあります)が返ってきます。next() を呼ぶたびに次の yield までコードが進んで、そこで一時停止して値を返す、という動きになります。

ジェネレータ内の yield と return はどう違う?

yield は「値を返して一時停止」するだけなので、次に next() が呼ばれると続きから再開できます。一方 return はジェネレータを完全に終わらせるもので、done: true になってそれ以降の値は出てきません。yield は何回でも書けますが、意味のある return は実質1回だけです。

配列じゃなくてジェネレータを使うべき場面は?

無限に続くシーケンス、計算コストが高いシーケンス、あるいは全部は要らず一部だけ欲しいときですね。ジェネレータはオンデマンドで1つずつ値を作るので、終わりのないIDストリームやページング付きAPIの結果を、全部メモリに展開せずに表現できます。逆にすでに小さくて固定の配列を持っているなら、素直に配列でOKです。

Coddyでコードを学ぼう

始める