Open5

async function* で return するときの注意点

ryokkkkeryokkkke

概要

  • async function* xxx() { ... } という記法で AsyncGenerator を返す関数を定義できる。
  • yield だけしている場合は問題ないが、その関数内で return する場合は注意が必要。
  • return xxx; すると for await...of でその値が完全に無視される。
    最初から .next() を明示的に呼び出すと取得できる。
ryokkkkeryokkkke

仕様

まず、async function* で定義した関数内で return すると、処理は通常の関数内の return と同様にそこで終了し、返り値は { value: 返り値, done: true } として返される。
返り値の値は yield と同じく返されるのは一度だけ。

async function* hoge() {
  yield 123;
  yield 456;
  return 789; // この関数の処理はここで終了し、これ以降は実行されない
  yield 0;
}

const generator = hoge();
console.log(await generator.next()); // { value: 123, done: false }
console.log(await generator.next()); // { value: 456, done: false }
console.log(await generator.next()); // { value: 789, done: true }
console.log(await generator.next()); // { value: undefined, done: true }
console.log(await generator.next()); // { value: undefined, done: true }
console.log(await generator.next()); // { value: undefined, done: true }
ryokkkkeryokkkke

問題

これだけ見ると別に問題ないように見えるけど、問題になるのは for await...of を使ったとき。

const generator = hoge();
for await (const value of generator) {
  console.log(value);
}
// 123
// 456
// undefined
console.log(await generator.next()); // { value: undefined, done: true }
console.log(await generator.next()); // { value: undefined, done: true }
console.log(await generator.next()); // { value: undefined, done: true }

この時、return 789;789 という値は for await...of のループ内で取得できませんし、その後 next() を実行しても取得できません。
これは、for await...of の仕様上、done: true だったらその時点でループを終了するということになっているため、done: true の時の値を渡したイテレーションが存在しないからです。

ループ後に next() で取得できないのもそれもそのはず、for await...of のループが done: true を確認したら終了するということは、ループ終了時点ですでに { value: 789, done: true } というオブジェクトはジェネレータから取り出されているということです。
つまり、非同期ジェネレータを for await...of で回すと、内部で return された値は永久に失われるということになります。

これはバグではなくfor await...of の仕様ですが、自分は意図がよくわかっていません。
ChatGPTに聞くと「ループの性質上、return されるものは yield でイテレーションされているものとは意味が異なるから意図的にそうなっている」と返ってきますが、実際にそういう文献を見つけられていないのでファクトチェックができていません。
ChatGPT は「for await...of の後に next() でとればいい」と言いますが、実際にはループ後に next() を実行しても上記の通り値は取得できません。永遠に失われます。

ryokkkkeryokkkke

for await...of の仕様

https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html#sec-runtime-semantics-forin-div-ofbodyevaluation-lhs-stmt-iterator-lhskind-labelset

for 文の仕様はここにあって、確かにこの仕様でいくと done の値を見て true だったらその場で早期リターンしているので、その時の value は参照すらされていないよう。

だったら async function* 内では return した時に { value: 返り値, done: false } を返すようにしたらダメだったの?と言いたくなりつつ、そうすると今度は { value: undefined, done: true } を返すタイミングがなくなるという問題はある。じゃあ、AsyncGeneratornext() メソッドがその辺を自前の特殊な実装でなんとかしたとしたら、これはハッピーになれたんだろうか。
(例えば内部的に状態を持っていて、return した後に next() を呼び出した時の返り値は done: true になる、とか...?)

までもなんか色々そうはならない思想があるんでしょうね。
ちょっとこれ以上調べる気になれなかったのでここまでで留めておきます。

ryokkkkeryokkkke

個人的な結論

実際問題、自分で async function* を定義するなら、値を返す return xxx; は極力しない方が安全。
(単なる早期リターン return; は問題ない。)

わざわざ値を返しているということはそれを使うことを想定しているわけだけど、使う側が for await...of を使用しているとその値は虚無に捨てられることになるから。

基本的には処理を途中で終わらせつつ値も返したい、という場合は return で返すのではなく、以下のように yield で返して return で終了させるのが一番安全だとは思う。
これなら for await...of でも値を取得できるから。

yield xxx;
return;

その他で yield してる値と return で返す値の型が違う場合などは若干めんどい。
それを織り込んで yield 側の型も変えられるならその方が良い。

けどフラットに考えると、return したとしても AsyncGenerator 側としては AsyncIterable の仕様に違反してるわけじゃないし、それを扱う側(for awit...of)の問題なのでは?という気はしてくるのだけど...w

実際、TypeScript で AsyncIterator の型を見ると以下のようになっている。

interface AsyncIterator<T, TReturn = any, TNext = any> {
    // NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
    next(...[value]: [] | [TNext]): Promise<IteratorResult<T, TReturn>>;
    return?(value?: TReturn | PromiseLike<TReturn>): Promise<IteratorResult<T, TReturn>>;
    throw?(e?: any): Promise<IteratorResult<T, TReturn>>;
}

interface IteratorYieldResult<TYield> {
    done?: false;
    value: TYield;
}

interface IteratorReturnResult<TReturn> {
    done: true;
    value: TReturn;
}

type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>;

つまり、yield した時の値と return した時の値というのは done が false か true かで明確に分けられている。この型定義自体はすごく真っ当に見えるし、これを見ると yield と return をいい感じに使い分けましょう!みたいな気分になってくるんだけど、実際問題としては AsyncGenerator の一番綺麗な扱い方である for await...of が TReturn を無視するという状況。
うーん...