PHPのGenerator::send()で値を送り込む — 双方向ジェネレータの勘所

Generator::send() は、ジェネレータの中で止まっている yield 式に外から値を渡し、その場から処理を再開させるメソッドです。ジェネレータを「値を出すだけの箱」から、双方向にやりとりできる小さなコルーチンに変えます。

ジェネレータというと、大量データを省メモリで回すための道具、という顔で語られることが多い気がします。ただ yield は文ではなく式なので、値を受け取ることもできる。この受け取り側を使い始めると、書けるものの幅が一段変わります。

そして、ここには初見でほぼ必ず踏むハマりどころがあります。今日はそこを中心に。

yield は式である

yield $v; と書くと値を「出す」だけに見えますが、$x = yield $v; と書けば、次に send() された値が $x に入ります。出す側と受け取る側が、同じ一行に同居しているわけですね。

<?php
function collector(): Generator
{
    $sum = 0;
    while (true) {
        $n = yield $sum;      // $sum を出し、send() された値を $n で受ける
        if ($n === null) {
            break;
        }
        $sum += $n;
    }
    return $sum;
}

呼び出し側から見ると、状態(ここでは $sum)をジェネレータの中に閉じ込めたまま、値を投げ込んでは途中経過を受け取る、という形になります。累積の途中状態をクラスのプロパティに持たせなくて済むのが嬉しいところです。

最初の send() で、最初の yield 値は消える

ここが最大のハマりどころです。ジェネレータはまだ一度も動いていない状態で send() を呼ぶと、まず最初の yield まで進んでから値を渡して再開します。つまり最初の yield が出した値は、誰にも返されないまま捨てられます。

<?php
function gen(): Generator
{
    $x = yield 'first';
    yield "got: {$x}";
}

$g = gen();
var_dump($g->send('A'));  // string(6) "got: A"  ← 'first' は受け取れない

Python のように next() で明示的に priming する必要はない、というのが PHP の親切設計なのですが、その親切のせいで最初の1個が黙って消えます。「1件目だけ結果がずれる」系のバグは、だいたいこれです。

回避は単純で、先に current() を呼んで最初の yield 値を受け取っておきます。

<?php
$g = collector();

var_dump($g->current());   // int(0)   ここまで進めて初期値を受け取る
var_dump($g->send(3));     // int(3)
var_dump($g->send(4));     // int(7)
var_dump($g->send(null));  // NULL     break して return に到達
var_dump($g->getReturn()); // int(7)

current() を挟むだけで、送った値と返ってきた値が素直に1対1で対応します。逆に、最初の yield の値に意味がない設計(yield; のように値を出さない)なら、priming は不要です。用途によって使い分ける、というより「値を出すなら current() を先に」と決めてしまったほうが事故が少ないと思います。

getReturn() はいつ呼べるのか

PHP 7 以降、ジェネレータは return で値を返せます。ただしその値を取る getReturn() は、ジェネレータが最後まで走り切ってからでないと使えません。まだ途中の状態で呼ぶと例外が飛びます。

<?php
$g = collector();
$g->current();
$g->send(10);

try {
    $g->getReturn();
} catch (Exception $e) {
    echo $e->getMessage(), PHP_EOL;
    // Cannot get return value of a generator that hasn't returned
}

なので実務では $g->valid() が false になったことを確認してから getReturn() を呼ぶ、という順序を守ります。send() が null を返したからといって終了とは限らない(単に null を yield しただけかもしれない)ので、終了判定は必ず valid() で見るのが安全です。

なぜ foreach では send できないのか

foreach はジェネレータに対して current()next() しか呼びません。next() は「null を send した」のと同じ扱いになるので、値を送り込む余地がないわけですね。双方向にやりとりしたいなら、foreach を諦めて valid() / send() のループを自分で書くことになります。

<?php
$g = collector();
$g->current();

foreach ([1, 2, 3] as $n) {
    if (!$g->valid()) {
        break;
    }
    $running = $g->send($n);
    echo "途中経過: {$running}", PHP_EOL;
}

「ジェネレータ = foreach で回すもの」という思い込みを一度外すと、この形が自然に見えてきます。

yield from との組み合わせ

委譲したジェネレータに対して send() すると、値は一番内側のジェネレータの yield に届きます。そして内側の return 値は、外側の yield from 式の値になります。

<?php
function inner(): Generator
{
    $a = yield 'inner-1';
    $b = yield 'inner-2';
    return $a . $b;
}

function outer(): Generator
{
    $joined = yield from inner();  // inner() の return 値がここに入る
    yield "joined: {$joined}";
}

$g = outer();
$g->current();          // 'inner-1'
$g->send('X');          // 'inner-2'
echo $g->send('Y'), PHP_EOL;  // joined: XY

ジェネレータを部品として分割しても、送受信の経路がそのまま繋がります。パーサやステートマシンを段階ごとに小さく切りたいとき、この性質はかなり効きます。ただし読み手にとっては制御の流れが追いづらいのも事実なので、使いどころは選んだほうがいいかもしれません。

どこで使うと割に合うのか

個人的に send() が素直にハマると感じるのは、入力を1件ずつ流し込みながら内部状態を育てる処理です。CSV を上から読みながら集計する、ログを1行ずつ食わせて状態遷移を進める、といった形。クラスにすると「状態プロパティ + reset()」が増えていく処理が、ローカル変数と while だけで書けます。

逆に、単に配列を変換したいだけなら普通の関数のほうが読みやすい。send() は制御の流れが行ったり来たりするぶん、読む側の負荷が上がります。そこを払ってでも状態管理が単純になるなら採用、という基準で見ています。

まとめ

yield は式なので、ジェネレータは値を出すだけでなく受け取れる。ただし未起動のジェネレータに send() すると最初の yield 値が消えるので、値に意味があるなら current() で priming してから使う。終了判定は valid()、戻り値の取得は走り切ったあとの getReturn()。このあたりを押さえておけば、あとは普通の関数を書く感覚で扱えます。

非同期ライブラリの中身がジェネレータで組まれている理由も、この双方向性を知ってから読むとだいぶ腑に落ちる気がします。

よくある質問

Q. send() を呼ぶ前に current() が必要なのはなぜですか?
A. 未起動のジェネレータに send() すると、まず最初の yield まで進んでから値を渡すため、最初に yield された値がどこにも返らず捨てられるからです。current() で先に受け取っておけば、送った値と返る値が1対1で対応します。

Q. send() が null を返したら終了と判断してよいですか?
A. いいえ。ジェネレータが単に null を yield しただけの可能性があります。終了したかどうかは valid() が false になったかで判定してください。

Q. foreach でジェネレータに値を送ることはできますか?
A. できません。foreach は内部で next() を呼ぶだけで、これは null を send したのと同じ扱いになります。値を送りたい場合は valid() と send() を使った自前のループを書きます。

類似投稿

コメントを残す

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