JavaScriptのyield*は移譲先ジェネレーターがreturnした値はyieldしない

本記事は https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/yield*#%E4%BE%8Byield* 式自体の値 に例示されているものを改めて考えつつ説明したものです。

https://github.com/susisu/tskaigi2025 のコードリーディングをしていて、ここがわからなくてしばらく詰まっていた。

it("ジェネレータから yield された Promise が fulfill されたらその位置から再開する", async () => {
  function* myFunc(): Comp<number> {
    const a = yield* waitFor(Promise.resolve(1));
    const b = yield* waitFor(Promise.resolve(2));
    return a + b;
  }
  const promise = run(myFunc());
  await expect(promise).resolves.toEqual(3);
});

JavaScriptには yield* という構文がある。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/yield*

これはジェネレーターの中で使い、別のiterableに制御を移譲するために使う。とてもわかりやすい例があったので引用させてもらう。
https://stackoverflow.com/questions/17491779/delegated-yield-yield-star-yield-in-generator-functions

function* someGenerator() {
    yield 0;
    yield [1, 2, 3];
    yield* [4, 5, 6];
}

for (v of someGenerator()) {
    console.log(v);
}
// 0, [1, 2, 3], 4, 5, 6

yield [1, 2, 3];[1, 2, 3] をそのままyieldする。一方で yield* [4, 5, 6];[4, 5, 6] がiterableなオブジェクトならば、その生成値を1つずつyieldする。移譲すると書いたが、someGeneratorの呼び出し側から見ると、内側の内側にあるiterable([4, 5, 6] のこと)を透過的に扱えるとも言えそうだ。

ところでジェネレーターの中ではyieldの他にreturnも使える。returnに到達すると値を返しつつジェネレーターは終了する。

const gen = function* () {
  yield 1;
  return 2;
}()

console.log(gen.next()); // { done: false, value: 1 }
console.log(gen.next()); // { done: true, value: 2 }
console.log(gen.next()); // { done: true, value: undefined }

では yield* で移譲されたジェネレーターがreturnしたとき何が起きるのか。僕はなんとなくreturnされた値も透過的に外側から触れると思っていたが、触れない。

const g_inner = function* () {
    yield 1;
    return 2;
}();

const g_outer = function* () {
    const a = yield* g_inner;
    yield 3;
    return 4;
}()

console.log(g_outer.next()); // { done: false, value: 1 }
console.log(g_outer.next()); // { done: false, value: 3 }
console.log(g_outer.next()); // { done: true, value: 4 }

なんとなく出てくる値は 1, 2, 3, 4 になりそうな気がしたが(俺だけか?)、正解は1, 3, 4だ。MDNに

yield* は式であり、文ではありません。そのため、値に評価されます。

とサラリと書かれている通り、g_innerのreturnで返される値はg_outerのaに束縛される。それだけで、g_outerの呼び出し側には返されない。

ここで逆に考えてみる。移譲先のreturnで停止すると仮定すると、どのような問題が起きるだろうか?

まず、yieldの機能として、呼び出し側からnextに引数を与えることで、値を受け渡すことができる。

const gen = (function* () {
  const a = yield 1;
  const b = yield 2;
  return a + b;
})();

console.log(gen.next());     // { value: 1, done: false }
console.log(gen.next(10)); // { value: 2, done: false }
console.log(gen.next(20)); // { value, 30, done: true}

初回呼び出し後にyield 1;で停止しているジェネレーターに対して10を与えて再開させ、次にyield 2;で停止しているジェネレーターに20を与えて再開させている。3回目のnext呼び出しの後はreturn a+bに到達して30を返して終了する。

これは yield* で移譲された内側のジェネレーターにも機能するのだが、returnで停止すると仮定してシミュレーションしてみる。

const g_inner = function* () {
    const a = yield 1; // aに10が入る
    return 2; // ここで停止すると仮定
}();

const g_outer = function* () {
    const x = yield* g_inner;
    yield 3;
    return 4;
}()

console.log(g_outer.next());     // { done: false, value: 1 }
console.log(g_outer.next(10)); // { done: false, value: 2 } が返ると仮定する
console.log(g_outer.next(20)); // { done: false, value: 3 } このnextに渡した20はどこへ…?
console.log(g_outer.next());     // { done: true, value: 4 }

return 2で停止すると仮定すると、そのときnextに渡された値はどこに行くのだろう?xにはg_innerから返された値が入ることになっているので行き場がない。捨てるしかない。そうなると、g_outerを利用する側は中でg_innerに処理を移譲しているなんて本来知る必要がないのに、それを知らないと何回目かのnextに渡した値が勝手に捨てられてしまうことになる。インターフェースとしての安定性が失われている。

そういう意味で、returnでは停止しないのは筋が通っている。割と非直感的だと思ったんだけど、MDNだとサラッと1行説明されて例が1つあるだけで終わるので難しかった。普段からジェネレーターを使ってる人(いますか?)には自明な動作なのかもしれない。

一般のジェネレーターにおいてreturnは停止して値を返すという点でyieldと同じように見えるが、その見方は間違っているのだろう。yieldが明確に停止を意図した機能である一方で、returnは終了だから結果的に停止に見えてしまっているだけで、その本質はただ値とともに処理を返すことだけだ。yield* のようにその後も外側のジェネレーターに制御を戻して続行できるのであれば停止しないように見えるのは必然なのだろう。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です