iterable型で配列とジェネレータを両対応する

iterable は配列と Traversable のどちらも受け取れる型で、PHP 7.1から使えます。配列でもジェネレータでも動く関数を書くための型宣言です。

ただ、型を iterable にしただけで両対応になるかというと、そんなことはありませんでした。中身の書き方を変えないと、配列を渡したときだけ動いてジェネレータで落ちる関数ができあがります。私も何度かやりました。

まず、なぜ array ではなく iterable なのか

引数を array で固定すると、呼び出し側は必ず全件をメモリに載せてから渡すことになります。iterable にしておけば、同じ関数が配列にもジェネレータにも使えます。

function totalOf(iterable $rows): int
{
    $sum = 0;
    foreach ($rows as $row) {
        $sum += $row['amount'];
    }
    return $sum;
}

echo totalOf([['amount' => 100], ['amount' => 200]]); // 300
echo totalOf(fetchRowsAsGenerator());                   // これも通る

受け口が広がるだけでなく、呼び出し側がメモリ戦略を選べるようになるのが嬉しいところです。関数側は「1件ずつ回せるもの」としか約束していない、という状態が作れます。

count() を書いた瞬間に両対応は壊れる

一番よく踏むのがこれです。iterable で受けたのに、中で件数を数えてしまうパターン。

function summarize(iterable $rows): string
{
    $count = count($rows); // 配列なら動く。ジェネレータだと TypeError
    // ...
}

PHP 8以降、count() に配列でも Countable でもないものを渡すと TypeError が投げられます。GeneratorCountable を実装していないので、ここで止まります。PHP 7.2〜7.4では警告で済んでいたぶん、8に上げたときに初めて表面化することもあります。

直し方は単純で、数えるのをやめてループの中で数えることです。

function summarize(iterable $rows): string
{
    $count = 0;
    $sum = 0;
    foreach ($rows as $row) {
        $count++;
        $sum += $row['amount'];
    }
    return "{$count}件 / 合計{$sum}";
}

iterator_count() という関数もありますが、これは数えるために最後まで回してしまいます。ジェネレータは回し切ったら終わりなので、数えたあとに本処理でもう一度回すことはできません。件数が要るなら、ループの中で数えるのが結局いちばん安全です。

二度目の foreach はどうなる?

配列は何度でも回せます。ジェネレータは回せません。

function process(iterable $rows): void
{
    foreach ($rows as $row) { /* 検証 */ }
    foreach ($rows as $row) { /* 保存 */ }
    // 配列なら動く。ジェネレータだと2周目で
    // Exception: Cannot traverse an already closed generator
}

「検証してから保存」のような二段構えの処理は、配列前提のコードだと自然に書けてしまいます。iterable を受ける関数では、1回のループで済ませるか、どうしても複数回必要なら関数の入口で明示的に配列化するか、どちらかに決めておくのがいいと思います。

配列化するなら iterator_to_array、ただしバージョンに注意

どうしても配列が必要なときは iterator_to_array() です。ここに二つ罠があります。

ひとつめはバージョン。PHP 8.1以前の iterator_to_array()Traversable しか受け付けず、配列を渡すと TypeError になりました。第1引数が iterable になって配列をそのまま通すようになったのはPHP 8.2からです。

// PHP 8.2以降ならこれでよい
$array = iterator_to_array($rows);

// PHP 8.1以前も動かすなら
$array = is_array($rows) ? $rows : iterator_to_array($rows);

ふたつめはキーです。第2引数 $preserve_keys のデフォルトは true で、キーが重複すると後勝ちで上書きされます。yield from で複数のジェネレータをつなぐと、内側のキーがそのまま引き継がれるので0から振り直しになり、これが正面衝突します。

function first(): Generator
{
    yield 'a';
    yield 'b';
}

function second(): Generator
{
    yield 'c';
    yield 'd';
}

function merged(): Generator
{
    yield from first();  // キーは 0, 1
    yield from second(); // キーもまた 0, 1
}

var_dump(iterator_to_array(merged()));
// ['c', 'd'] — 4件のはずが2件になる

var_dump(iterator_to_array(merged(), false));
// ['a', 'b', 'c', 'd']

キーに意味がないなら第2引数に false を渡す、という習慣にしておくと事故が減ります。値が黙って消えるタイプのバグなので、エラーにもならず、テストデータが少ないと気づけません。

返り値の型はどうする

引数は iterable で広く受けるとして、返り値は少し話が違います。返り値を iterable にすると、呼び出し側は「配列かもしれないしジェネレータかもしれない」ものを受け取ることになり、さっきの制約が全部そちらに移動します。

// 呼び出し側に選択肢を残したいとき
function readRows(string $path): iterable
{
    $handle = fopen($path, 'r');
    try {
        while (($row = fgetcsv($handle)) !== false) {
            yield $row;
        }
    } finally {
        fclose($handle);
    }
}

// 件数が確定していて、使い回される前提のとき
function activeUsers(): array
{
    // ...
}

個人的には、遅延評価に意味がある関数だけ iterable(または Generator)で返して、それ以外は素直に array で返すのが扱いやすいと思っています。「とりあえず iterable」で返すと、呼び出し側が毎回 iterator_to_array() を書くはめになりがちでした。

なお、渡ってきたものがどちらか判定したいときは is_iterable() ではなく is_array() を使います。is_iterable() は「配列かTraversableか」を見る関数なので、両対応の関数の中では常に true です。

まとめ

iterable は型宣言を書き換えるだけで済む話ではなくて、関数の中身を「1件ずつ、1回だけ回す」に寄せる作業とセットです。count() を呼ばない、二度 foreach しない、この二つを守れているかを見るだけでもだいぶ違います。配列化するときは iterator_to_array() の第2引数だけ思い出してください。ここは静かに壊れるので。

よくある質問

Q. iterable と Traversable はどう違いますか?
A. TraversableIteratorIteratorAggregate の親にあたるインターフェースで、配列は含みません。iterablearray|Traversable に相当する型なので、配列も受け取れます。

Q. iterable を受ける関数で件数を先に知る方法はありますか?
A. 汎用的な方法はありません。iterator_count() はジェネレータを消費してしまいます。件数が必須なら引数を arrayCountable に限定するか、ループしながら数える設計に変えるのが現実的です。

Q. ジェネレータを二回以上回したい場合は?
A. ジェネレータ関数をもう一度呼んで新しいインスタンスを作るのが基本です。同じデータを何度も使うなら、そもそも配列にしてしまったほうが素直だと思います。

類似投稿

コメントを残す

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