for await...of
こういう書き方ご存知でしょうか。
for await (const item of asyncIterable) {
...
}
for の直後に await が書かれています。ECMAScriptの正式な記法ですが、使わなければいけないタイミングが少なくあまり認知されていない気がします。自分は最近知りました。
端的に言うとPromiseを直列にループしたい時の記法です。
ただ、重要なのは AsyncIterable
を扱う場合であり、シンプルなPromiseの配列を回す時は通常の for...of の中で await する方法でも特に大きな問題はありません。
結論
使う必要があるタイミングは
-
AsyncIterable
なオブジェクトを回す時
使っても良いタイミングは
- Promiseの配列を直列に回したい時
使ってはいけないタイミングは
- 複数のPromiseを順番関係なく全部待ちたい時
AsyncIterable
基本的に for await...of は AsyncIterable
のためにあると言って良いと思います。
AsyncIterable
は非同期処理を直列に待つためのプロトコルです。
分かりやすい実例を挙げると、OpenAI の ChatCompletion の API には、ストリーミングでレスポンスを受け取る機能があります。
GPTが生成するテキストを、全部生成し終わってから受け取るのではなく、トークン(≒文字、もしくは単語のような区切り)ごとに受け取ることができるので、ユーザーの待機時間を感覚的に短くすることができます。
その API の Node のライブラリの書き方は例えばこんな感じです。
// README からコピペ
const stream = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: 'Say this is a test' }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content || '');
}
// トークンが(まるで人間がタイピングしているかのように)一つ一つ標準出力に表示される
このケースの場合、非同期な処理を順番に待つことが重要です。
なぜならLLMが生成するのは自然言語なので、トークンを生成された順番に並べないと意味不明な文章が出来上がるからです。
そして、上記の stream
は AsyncIterable
なオブジェクトです。
逆 Iterable
でも ReadableStream
でもないので、通常の for...of で回したり stream.getReader()
したりすることはできません。
記法を一つに絞っているところに意思を感じるので個人的には好きです。
LLMのAPIのレスポンスに関しては本当にただただストリーミングされたトークンを受け取って逐次表示する以外の用法は無いはずなので、シンプルで良いですよね。
ReadableStream
JS にはストリームを受け取るための ReadableStream
というクラスが存在します。例えば fetch
のレスポンスの body
が ReadableStream
です。
ReadableStream
は AsyncIterable
プロトコルを実装しているので、 for await...of で直列に回すことができます。
for await...of を使わずに getReader()
を使うと少々プリミティブな書き味になりアプリケーションコードとしては微妙に使い勝手が悪いです。
一方で for await...of を使うと、単なる配列を同期的にループするような感覚でストリーミングのレスポンスを受け取ることができます。
が、現時点では ReadableStream
に AsyncIterable
が実装されているランタイムは結構限られています。
ブラウザでほぼ(?)使えないのがツラいですね。
(MDN見る限りFireFoxは使えるっぽいんですが、試したところ動かず。多分Experimentalフラグとかをいじらないとダメな段階かな)
Nodeではv16.5.0から入っているようなので、サーバアプリケーションのコードでなら問題ないかと思います。
Promiseの配列を扱う場合
AsyncIterable
でなくとも、複数の Promise を直列に await して処理したい場面というのは実際問題あります。
例えば複数のAPIを順番に繋ぐ必要があるケースで考えます。
const apiResponsePromises = [fetch("api1"), fetch("api2"), fetch("api3")];
こういう複数のPromiseを直列に待ちたい場合、最もポピュラーで簡単で可読性が良いのは for...of を使って await する方法だと思います。
for (const responsePromise of apiResponsePromises) {
const response = await responsePromise;
response.xxx
...
}
この書き方なら直列で待つことができ、独自の関数を用意する必要もなく、可読性も良いです。
(直列に待ちたい場合に forEach
などが使えない理由はここでは流石に省きます)
でこの場合に for await...of を使ってもOKです。
for await (const response of apiResponsePromises) {
response.xxx
...
}
MDN が分かりやすいですが、for await...of の処理の一部を抜き出すと以下の感じです。
- 対象のオブジェクトが
AsyncIterable
かどうか判定。 - 対象のオブジェクトが
AsyncIterable
ではない場合、通常の for...of と同様に、対象のオブジェクトをIterable
なオブジェクトとしてループする。 - その Iterable の値が Promise なら、await した上で変数に格納する。
この使い方に関しては通常の for...of + await のシンタックスシュガーと言えるでしょう。
なので、この場合は for await...of を使う方が記述量は減ってスッキリはします。
がしかし、前述の通り認知度の低い記法なので、驚き最小の原則的な意味で個人的には for...of で書いてても全く問題ないと感じます。
そもそも for 文を使っている時点で今のJS/TSの流れだとちょっと抵抗あったりしますし、スッキリすると言っても1行だけで、変数のスコープを狭くできるわけでも無いので...
あるいはチームみんなで共有して使ってこ〜みたいなのが良いかもしれないですね。
順番関係なく全部待つ場合
これはもう色々なところで説明されているので細かい説明は不要かと思います。
Promise.all
か Promise.allSettled
を使いましょう。
おしまい
ということで、直列Promiseにちょっと強くなりましたね。