🎄

ENCA 5日目: AsyncFromSyncIterator で yield リジェクト時に元のイテレーターを閉じる(進行中)

2024/12/05に公開

非同期イテラブル(非同期反復可能)とは

ES2018 から仕様の中に非同期イテラブルインターフェース非同期イテレーターインターフェースが定義されています。非同期イテラブルインターフェースを実装したオブジェクト[1]のことを単に非同期イテラブル(非同期反復可能)と呼びます。

ざっくり TypeScript の型で表現すると以下のようになります(実際の TypeScript での型はジェネリクスになっています)。

interface AsyncIterable {
  [Symbol.asyncIerator](): AsyncIterator;
}

interface AsyncIterator {
  next(value?: any): Promise<IteratorResult>;
  return?(value?: any): Promise<IteratorResult>;
  throw?(error?: any): Promise<IteratorResult>;
}

interface IteratorResult {
  done: boolean;
  value: any;
}

非同期イテラブルに対しては for await...of や非同期ジェネレーター函数内で yield * などのシンタックスを使うことが出来ます。

詳しくは過去の記事を参考にしてください。

https://zenn.dev/pixiv/articles/d1650ae332798c

非同期イテラブルを使った例はあまりありませんが、例えば Web 標準の ReadableStream が非同期イテラブルです(2024年12月現在、JavaScriptCore で実装されていないようです)。

https://caniuse.com/mdn-api_readablestream_--asynciterator

ジェネレーター函数と非同期ジェネレーター函数の yield の違い

ジェネレーター函数と非同期ジェネレーター函数では yield の扱いが異なります。Promiseyield する場合、ジェネレーター函数の場合はそのままの Promise が渡りますが、非同期ジェネレーター函数の場合はその中身が渡ります。

function* generatorFunction() {
  yield Promise.resolve(1);
  yield Promise.resolve(2);
  yield Promise.resolve(3);
}

const generator = generatorFunction();
for (const value of generator) {
  console.log(value); // Promise { 1 }, Promise { 2 }, Promise { 3 }
}
async function* asyncGeneratorFunction() {
  yield Promise.resolve(1);
  yield Promise.resolve(2);
  yield Promise.resolve(3);
}

const asyncGenerator = asyncGeneratorFunction();
for await (const value of asyncGenerator) {
  console.log(value); // 1, 2, 3
}

さてここで拒否された(rejected)Promiseyield してみましょう。非同期ジェネレーター函数の方では例外が発生していることがわかります。

function* generatorFunction() {
  yield Promise.reject(1);
  yield Promise.reject(2);
  yield Promise.reject(3);
}

const generator = generatorFunction();
for (const value of generator) {
  console.log(value); // Promise { <rejected> 1 }, Promise { <rejected> 2 }, Promise { <rejected> 3 }
}
async function* asyncGeneratorFunction() {
  yield Promise.reject(1);
  yield Promise.reject(2);
  yield Promise.reject(3);
}

const asyncGenerator = asyncGeneratorFunction();
try {
  // 例外が発生!
  for await (const value of asyncGenerator);
} catch (e) {
  console.log(e); // 1
}

AsyncFromSyncIteratoryield リジェクト時に元のイテレーターを閉じる

イテラブルに対して非同期イテラブル用のシンタックスである for await...of や非同期ジェネレーター函数内で yield * を使うと、内部的には AsyncFromSyncIterator でラップされることとなります。そして非同期ジェネレーター函数の yield の挙動と同じように、拒否された Promise に対して例外が発生するようになります。

function* generatorFunction() {
  yield Promise.reject(1);
  yield Promise.reject(2);
  yield Promise.reject(3);
}

const generator = generatorFunction();
try {
  // 例外が発生!
  for await (const value of generator);
} catch (e) {
  console.log(e); // 1
}

さてこのケースで元のイテレーターが閉じられていない問題が発覚しました。

function* generatorFunction() {
  yield Promise.reject(1);
  yield Promise.reject(2);
  yield Promise.reject(3);
}

const generator = generatorFunction();
try {
  // 例外が発生!
  for await (const value of generator);
} catch (e) {
  console.log(e); // 1
}
for (const value of generator) {
  // イテレーターが閉じられていない!
  console.log(value); // Promise { <rejected> 2 }, Promise { <rejected> 3 }
}

この問題は2021年1月の会議でちゃんと元のイテレーターを閉じる修正が承認されましたが、SpiderMonkey でまだ実装が完了しておらず、仕様への反映もまだのようです。

https://github.com/tc39/ecma262/pull/2600

脚注
  1. イテラブル同様、プリミティブもプロトタイプ汚染で無理矢理非同期イテラブルにすることは出来ますが、そんなことをする方はいないですよね。 ↩︎

Discussion