PHPのiterator_to_arrayでジェネレータの値が消える理由 ― yield fromとキー衝突の落とし穴

iterator_to_arrayは、ジェネレータなどのTraversableを配列に変換する関数です。第2引数のpreserve_keysがデフォルトtrueのため、キーが重複すると後の値が先の値を黙って上書きし、要素が消えます。

ジェネレータで組み立てた結果を配列にしたら、件数が合わなかった。しかも例外も警告も出ない。私がこの現象に出会ったのは、yield fromで複数のジェネレータをつないだときでした。原因を知ってしまえば単純なのですが、知らないとかなり気持ち悪いバグです。

yield fromを挟むと値が消える

まず再現してみます。外側のジェネレータが自動採番するキーと、内側のジェネレータが採番するキーは別々に管理されます。

<?php
function inner() { yield 1; yield 2; yield 3; }

function gen() {
    yield 0;
    yield from inner();
    yield 4;
}

var_dump(iterator_to_array(gen()));
// array(3) {
//   [0]=> int(1)
//   [1]=> int(4)
//   [2]=> int(3)
// }

yieldした値は0, 1, 2, 3, 4の5個なのに、配列は3要素です。外側の最初の0はキー0、内側の1はキー0で上書き、内側の2はキー1、外側の最後の4もキー1で上書き、という流れです。yield fromは内側のキーをそのまま通すので、外側の採番が続きから始まることはありません。

キーが要らないならpreserve_keysはfalseにする

値を順番に並べたいだけなら、第2引数にfalseを渡すのが確実です。「キーなど意識していない」場面ほど、このフラグを明示しておくと事故が減ります。

<?php
echo json_encode(iterator_to_array(gen(), false));
// [0,1,2,3,4]

これで連番の配列になり、値も欠けません。ジェネレータの出力を別の関数にリストとして渡す、といった用途ならこちらが基本だと思います。

連想配列のキーが重複するとどうなるのか

キーを意味のある文字列で返している場合も同じ仕組みです。配列をyield fromしたときも、元の配列のキーが使われます。

<?php
function fromArr() {
    yield from ['a' => 1, 'b' => 2];
    yield from ['a' => 3];
}

echo json_encode(iterator_to_array(fromArr()));
// {"a":3,"b":2}

echo json_encode(iterator_to_array(fromArr(), false));
// [1,2,3]

設定を段階的に上書きしたいときなど、後勝ちのマージとしてはむしろ便利です。ただ、意図しない上書きなのか、意図した上書きなのかをコード上で区別できないのが怖いところです。キーが重複しうるなら、falseで全件を取るか、foreachで自分で処理するほうが安全かもしれません。

ジェネレータは一度しか回せない

もう一つのハマりどころが、巻き戻せないことです。すでに最後まで進んだジェネレータにiterator_to_arrayをかけると、例外になります。

<?php
function g2() { yield 'x'; yield 'y'; }

$gen = g2();
foreach ($gen as $v) {}

try {
    iterator_to_array($gen);
} catch (Throwable $e) {
    echo get_class($e), ': ', $e->getMessage();
    // Exception: Cannot traverse an already closed generator
}

デバッグ目的でforeachやvar_dumpしたあとに配列化すると、ここで落ちます。何度も使う結果は、最初に配列へ変換して変数に持っておくのが無難です。

配列化すると省メモリの利点はなくなる

ジェネレータを使う理由の多くは、大きなデータを一件ずつ処理してメモリを抑えることでした。iterator_to_arrayで全件を配列に載せると、その利点は消えます。件数が多いデータに使うときは、本当に配列が必要かをいったん考えたほうがいいと思います。

なお、PHP 8.2以降は引数にTraversableだけでなく配列も渡せます。PHP 8.1以前では配列を渡すとTypeErrorになるので、古い環境も見ているなら注意してください。

まとめ

iterator_to_arrayは手軽ですが、preserve_keysがデフォルトtrueであることと、yield fromがキーを引き継ぐことが重なると、値が静かに消えます。単なる値の一覧が欲しいならfalse、キーに意味があるなら重複の有無を意識する、というくらいの線引きで十分かなという気がしています。

よくある質問

Q. iterator_to_arrayで要素数が減るのはなぜですか?
A. preserve_keysがtrueのとき、同じキーが複数回yieldされると後の値が前の値を上書きするためです。yield fromで別のジェネレータをつないだときにも起きます。

Q. 連番の配列にしたいときはどう書きますか?
A. iterator_to_array($gen, false) と第2引数にfalseを渡します。キーは0から振り直され、値は欠けません。

Q. ジェネレータに対して2回iterator_to_arrayを呼べますか?
A. できません。最後まで進んだジェネレータは巻き戻せず、2回目は「Cannot traverse an already closed generator」の例外になります。結果は変数に保持してください。

類似投稿

コメントを残す

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