JavaScriptのイテレータ, イテラブル, ジェネレータを理解する!

お久しぶりです。GMOインサイトの天河です。

ついこの間、JavaScriptのジェネレータについて社内勉強会で発表したのでその内容をまとめます。

※ 注意
本記事で言及している「ジェネレータ」はJavaScriptでの言語仕様です。一部通ずる箇所もあると思いますが、Python や C# など他の言語での使われ方についてはしかるべき文献を見てください。

目的

  • ジェネレータ が何かわかるようになる
  • ジェネレータについて面接で聞かれても余裕で答えられるようになる
  • 実装時にジェネレータを選択肢として持てるようになる

対象読者

  • ジェネレータ が何か全くわかっていない人
  • ジェネレータ について認知はしているものの、どういうものかは把握していない人
  • ジェネレータ を知ってはいるものの、使い所がわからない人

はじめに

  • ジェネレータを理解するためには、「イテレータ」と「イテラブル」について知る必要があります。なので、この二つの概念から説明していきます。
  • 「イテレータ」「イテラブル」「プロトコル」など難しい単語が出てきますが、付いて来てください。
  • プロトコルとは簡単に言うと「〇〇 する時は △△ しましょう」というお約束ごとです。何かをするにあたり定められている手順です。
  • プロトコルを満たしていることを「プロトコルに準拠している」と言います。

例1:インターネット上で電子メールを送り合う時は 〇〇 という通信手順を踏みましょう
→ SMTP(Send Mail Transfer Protocol)

例2:インターネット上でファイルの送受信をするときは 〇〇 という手順を守りましょう
→ FTP(File Transfer Protocol)

目次

イテーレタとは?

有限個 or 無限個の値を一つずつ生成 / 抽出することができるオブジェクトです。
正確には「イテレータプロトコルに準拠しているオブジェクト」です。

イテレータプロトコルとは「有限個 or 無限個の値を生成する方法を定義した」プロトコルです。反復子プロトコルとも言います。
しつこいですが、プロトコルとは「約束事や手順」のことなので、超噛み砕いて説明すると、

「有限個、または無限個の値を生成するオブジェクトを定義する時はこの手順に従ってね!」

というお約束ごとです。

そしてそのプロトコルの内容は以下です。

  • 0個または1個の引数を持つ next メソッドがあること
  • next メソッドが IteratorResult を返すこと

これらを満たしているオブジェクトは「イテレータプロトコルに準拠している」ため、イテレータオブジェクト(略してイテレータ)になります。
IteratorResult は、done+value というプロパティを持つオブジェクトです。

done イテレータが一連の値を全て生成した or 取り出したか。まだなら false、完了なら true
value イテレータの値。nextを呼ぶ度に次の値を生成する or 取り出す。
donetrue の場合 undefined になる。 

IteratorResult によって、値だけでなく生成 / 取り出しが完了したかどうかの情報も取得することができます。

以下はイテレータが一連の値を「生成する」サンプルになります。

next メソッドが呼ばれることで一連の値が一つずつ生成されて、全て生成されたら done を true にしてイテレータを終了させます。

下の例は、値を生成するのではなくあらかじめ用意されてある要素から一つずつ取り出す例です(巷で紹介されているイテレータの例で多いのはこのパターン)。

ただし、こちらの例ではイテレータの良さが出ていません。記事後半でこの件について説明しているので見てみてください。

まとめると、イテレータは next メソッドを使って一連の値を生成する / 取り出すとともに、その一連の値を使い切ったかの情報を取得することができます。

※ イテレータプロトコル「だけ」を満たしているケースは(自分で作らない限り)ほぼ存在しません。同時にイテラブルプロトコルを満たしていることがほとんどです。

でも生成する要素が大量にあった場合、全部の要素を next で呼ぶとなると面倒ですね。そこでイテラブルの話に移ります。

イテラブルとは?

イテラブルとは、イテラブルプロトコルを満たすオブジェクトです。イテラブルプロトコルは「反復可能プロコトル」とも言い、名前通り

「オブジェクトを反復処理できるようにしたかったらこれを守ってね!」

というお約束ごとです。そのプロトコルの内容は、

  • 0個の引数を持つ [Symbol.iterator] メソッドを持っていること
  • [Symbol.iterator] メソッドがイテレータオブジェクトを返すこと

です。先程作ったイテレータを返す [Symbol.iterator] メソッドを作ると、イテラブルの完成です。

この例のように、イテラブルであることで for...of などの反復処理のインターフェースが使えるようになります。
反復処理を行うインターフェースは反復処理する際に [Symbol.iterator] メソッドを呼び出して得られるイテレータを利用するからです。

このようにイチから自作するイテラブルなオブジェクトもあれば、元から用意されているイテラブルなオブジェクトもあります。「組み込み反復可能オブジェクト」と言います。

組み込み反復可能な型 呼び出し例
String
Array
TypedArray
Map
Set
Segments
arguments 
NodeList
ReadableStream

ここでイテレータとイテラブルについてまとめます。

イテレータ 引数を0または1個持ち、 IteratorResult を返すnext メソッドを持つオブジェクト。値の [生成 / 抽出] の [停止 / 再開] が next メソッドによって可能。
イテラブル 引数が0個を持つ、イテレータを返す [Symbol.iterator] メソッドを持つオブジェクト。
反復処理のインターフェースが使用できる。
IteratorResult イテレータの next メソッドから返る、value+doneのペアを持つオブジェクト。
valueはイテレータの値、done は全てのイテレータの値の生成/取り出しが完了したかの情報を示す。

そしていよいよ本題に入ります。

ジェネレータとは?

ジェネレータとは、イテレータかつイテラブルであるオブジェクトのことです。IteratorResult を返す next メソッドを持ち、[Symbol.iterator]メソッドがイテレータを返すオブジェクトです。
イテレータであることにより、値の生成の開始 / 中断 をコントロールでき、イテラブルであることにより反復処理が可能になっています。

作り方は超簡単です。イテラブルなイテレータを作れば良いので、[Symbol.iterator] メソッドでイテレータである自分自身を返すだけです。

ただ、ジェネレータを作るのに next メソッドと[Symbol.iterator]メソッドを定義したオブジェクトをいちいち作るのはめんどくさいですよね。
そこでジェネレータ関数というジェネレータを一発で作ってくれる便利な関数が用意されています。

ジェネレータ関数

ジェネレータ関数とは、返り値がジェネレータである関数です。
以下のように function* () と記述します。yield とセットで使用します。yield については後ほど説明します。

ジェネレータ関数を呼び出した段階ではコードは実行されません。ジェネレータオブジェクトが返却されます。それを操作する形でジェネレータの中身を実行していきます。

ジェネレータはイテレータなので next メソッドを使うことができます。試しに4回連続で next メソッドを使ってみましょう。

yield に続く値を value に持つ IteratorResult が順番に出力され、最後は value が undefinedIteratorResult が表示されれ、イテレーションが終了しました。

また、ジェネレータはイテラブルなので反復処理を簡単に書けます。

このようにジェネレータは next メソッドを呼ぶことで yield に続く値を value に持つ IteratorResult を順に出力することができて、反復処理もできます。

さて、この yield は何者でしょう。

yieldとは?

yield 式はジェネレータ関数の実行を一時停止するものです。そして、ジェネレータの呼び出し元に yield キーワードに続く値を返します。

ジェネレータの next メソッドが呼ばれると yield 式までジェネレータ関数が実行され、そこでコードの実行が一時停止します。
再度 nextメソッドを実行すると、停止した yield の位置から処理が再開し、次の yield まで処理が進み、yield に続く値を返してまた一時停止します。だから next を呼ぶ度に値が変わっていたわけです。
そして yield はジェネレータ関数の中でしか使えません。

また、next メソッドに引数を入れると再開時に」その値から処理を再開することができます。
以下のジェネレータ関数を実行すると、g.next(1) はどういう値になるでしょうか。

{value: 1, done: false} になるのでは?と思ったそこのあなた。ありがとうございます。違います。
正解は {value: null, done: false} です。

next メソッドの引数は「再開値」を指定するものです。

next メソッドの引数の値は、「処理の再開時に」「左辺の値を」書き換えるものです。つまりnextメソッドの初回呼び出し時に引数を入れるのは全く意味がない行為です。

もう一回 nextメソッドに引数を指定して呼び出すと、出力はどうなるでしょう。

yield の位置から再開し、左辺の value が2になって「2」が出力されます。

そして IteratorResult は常に {value: null, done: false} です。

また、ジェネレータには return メソッドが存在しており、IteratorResult の値をreturn メソッドの引数で指定したものでジェネレータを終了させることができます。

yield の処理の流れをまとめると、以下のシーケンス図のようになります。

 

イメージ的に「戻って来られるreturn 文みたいなやつ」ですね。

yield* 式

yield* 式というものもあります。yield* にイテラブルなオブジェクトを渡すと、その渡したオブジェクトに反復処理を移すことができます。

yield* はまず、渡されたイテラブルなオブジェクトの[Symbol.iterator]メソッドを呼び出して、イテレータを取得します。そしてジェネレータのnextメソッドが呼び出されるたびに、yield* は取得したイテレータの next メソッドを呼び出して、IteratorResult を呼び出し元に返します。

yield* の処理中に、呼び出し元でジェネレータの return メソッドを呼び出すとどうなるでしょうか。
結論、yield* に渡したイテレータからもreturn メソッドが呼ばれ、donetrue のIteratorResult を返してイテレータは終了します。そして呼び出し元にもその IteratorResult が返り、ジェネレータも終了します。

引数を付けて return メソッドを呼ぶと  { value: 引数の値, done: true } の IteratorResult が返って来ます。付けなかったら valueundefined で返されます。

yield* の処理の流れは以下のようになります。

ちなみに yield* 先で return が呼ばれたらどうなるかと言うと、return した値が yield* 式の評価値として、yield* 先のジェネレータは終了します。

続いて、非同期の処理に対応している非同期ジェネレータ関数というのもあります。

非同期ジェネレータ関数

非同期ジェネレータ関数はそのままの意味で、非同期処理を扱うためのジェネレータ関数です。
「非同期反復可能プロトコル」と「非同期イテレータプロトコル」に準拠したオブジェクトを返す関数です。

非同期反復可能プロトコル 非同期イテレータを返す引数ゼロの [Symbol.asyncIterator] メソッドを持つ
非同期イテレータープロトコル 0 または 1 個の引数を受け入れ、プロミスを返すnext メソッドを持つ。プロミスはIteratorResult インターフェイスに準拠したオブジェクトとして解決される

色々書いていますが、非同期ジェネレータ関数は以下のように定義します。

ジェネレータ関数の記法の先頭に async を付けるだけです。注意として反復処理をするときは for...of の代わりに for await ... of を使います。

お粗末な例ですが、以下に「数秒経ったらvalue の値でresolve される関数」の非同期ジェネレータを書きます。

await を使っているので async 関数内に処理を定義して、そのasync関数を呼び出す必要があります。
出力は以下のようになります。

といった自在度の高い処理も書くことができます。

ジェネレータの遅延評価について

ジェネレータは必要なときに必要な分だけメモリを消費するので、メモリ使用量を節約できます。10,000個、100,000個、1,000,000個、10,000,000個の連続するデータを配列とジェネレータで定義したときで比較してみましょう。

結果は以下のグラフになります。

ジェネレータは必要になったタイミングでのみメモリ確保するため、メモリ使用量はほぼ一定です。要素が多くなるほど配列とのメモリ使用量の差が顕著に現れます。上記グラフで言うと、100,000個以上からその差が如実に現れ始めますね。
少ない要素数ではそこまで顕著な差が現れないので、普段使いでメモリ使用量を意識してyield を選択するというケースはほとんどないかと思われます。

配列 データが一度にメモリにロードされるため、要素数が多いと大量にメモリを消費する
ジェネレータ 必要なタイミングでデータを「逐次」生成するので、必要な分だけメモリに読み込む

AtCoderなどのプログラミングコンテストでJavaScriptを使っている人は、メモリを考慮して解く問題の時のために頭の片隅に置いておくと幸せになるかもしれません。
僕はたまに yield に助けられるケースがあります。

自分的にジェネレータの大きなメリットはメモリよりも「処理の途中で必要なデータを取り出す処理が簡単に書ける」ことかと思っています。

このメリットを活かしたジェネレータのユースケースを紹介します。

ジェネレータのユースケース

注意(言い訳):結構無理やり使っているかつ最低限のUI実装しかしていないため、ブラッシュアップは各自でお願いします。ここまで書くのに10時間くらいかかったので疲れました。

ChatGPTの出力のようなリアルタイムレスポンスを表示する

昨今、生成AIの発展に従ってリアルタイムにレスポンスを受け取る技術が注目されています。それに従って、SSEの処理が簡単に書けるインターフェースがいろんなプログラミング言語で実装されています。

※ SSE(サーバサイドイベント)とは、サーバがクライアントに対してリアルタイムでイベントを送信することができるインターフェースのことです。

HonoOpenAIのパッケージをインストールしたバックエンドと、React を使ったフロントエンド間でやりとりして、ChatGPTのUIを実装してみましょう。yield をフル活用します。

バックエンド

フロントエンド

成果物

プロンプトを投げると回答がリアルタイムで出力されるUIができました。

データの処理進捗をサーバから受け取るUI

バックエンド

フロントエンド

成果物

データ取得の進捗を表現するUIができました。

まとめ

  • ジェネレータを一言で表すなら「処理の中断・再開と反復処理が簡単にできるオブジェクト」
  • yield によってジェネレータ関数の処理の中断と再開が可能

補足

Iterator 抽象クラス

Iterator 抽象クラスというイテレータを作るインターフェースがJavaScriptには存在していますが、2024年10月現在 Safari、Node.js と Denoでは未対応、firefoxではまたプレビューの状態です。
このIterator 抽象クラスにはすでにプロトタイプに this を返す [Symbol.iterator] メソッドが搭載しており、Iterator 抽象クラスを継承したクラスから作られたインスタンスはすでにジェネレータになっています。

@@Iterator ってなんだ?

よくイテレータ関連の情報を調べてくる @@iterator は、Symbol.iterator に置き換えても同じ意味です。このアットマーク2つで書くシンボルを「ウェルノウンシンボル(well-known symbol)」と言い、あらかじめJavaScript側で定義されているシンボル名です。(例: @@iteratorSymbol.iterator )

yield ← なんて呼ぶの

イールドです。イェールドではありません。

参考文献

mdn web docs:iterator#解説
mdn web docs:反復処理プロトコル/言語と反復処理プロトコルの対話
mdn web docs:Generator
mdn web docs:Generator/next
mdn web docs(en-US版):yield*
mdn web docs:AsyncGenerator