PHPの参照渡し(&)とforeach参照の落とし穴

PHPの参照(&)とは、変数名と値の実体を結びつけ直す仕組みです。コピーではなく同じ実体を指すため、片方を書き換えるともう片方も変わります。

この挙動自体は単純なのですが、foreach と組み合わせた瞬間に、初見ではまず原因が分からないバグを生みます。私も昔、配列の最後の要素だけが二重になるという現象を半日追いかけたことがあります。今日はその話を中心に、参照が絡む実務のハマりどころを整理しておきます。

なぜ最後の要素だけが壊れるのか

まず、有名なやつから。次のコードは何を出力すると思いますか。

<?php
$items = ['a', 'b', 'c'];

foreach ($items as &$item) {
    $item = strtoupper($item);
}

foreach ($items as $item) {
    // 何もしない
}

print_r($items);

期待するのは ['A', 'B', 'C'] ですが、実際に出るのは ['A', 'B', 'B'] です。

理由はこうです。1つ目のループを抜けた時点で、$item$items[2] への参照として生き残っています。2つ目のループは $item に値を代入していく処理なので、代入するたびに $items[2] が書き換わる。最後の代入で $items[2] に入るのは、そのとき読み込んでいた要素、つまり $items[1] の値です。結果として末尾が1つ手前のコピーになる、というわけですね。

厄介なのは、2つ目のループが数十行離れた場所にあったり、別の変数名だと思い込んでいたりすると、まず気づけないところです。エラーも警告も出ません。

対策はunsetを一行足すだけ

参照のループを書いたら、その直後に参照を切る。これが唯一にして確実な対処です。

<?php
foreach ($items as &$item) {
    $item = strtoupper($item);
}
unset($item); // 参照を切る

「忘れそう」と思うなら、そもそも参照を使わないほうが早いです。

<?php
$items = array_map('strtoupper', $items);

// キーを保ったまま書き換えたいならこちら
foreach ($items as $key => $item) {
    $items[$key] = strtoupper($item);
}

後者は「参照を使わないとループ内で書き換えられない」という思い込みを外してくれます。キー経由で元の配列に代入すれば済む場面がほとんどで、参照が本当に必要なケースは実務ではそう多くありません。個人的には、参照付き foreach を見かけたらまず「これ、キー代入で書けないか」を疑うようにしています。

ネストした配列を参照で書き換えるときは

多次元配列の中身をまとめて加工する場面では、参照が素直に効きます。

<?php
$orders = [
    ['id' => 1, 'lines' => [['qty' => 2, 'price' => 500]]],
    ['id' => 2, 'lines' => [['qty' => 1, 'price' => 300]]],
];

foreach ($orders as &$order) {
    foreach ($order['lines'] as &$line) {
        $line['subtotal'] = $line['qty'] * $line['price'];
    }
    unset($line);   // 内側も忘れずに
}
unset($order);

unset() が二重に必要なのが地味に落とし穴です。内側を消し忘れると、次の外側ループで $line が前の注文の行を指したまま動きます。ネストが深くなるほど事故率が上がるので、この構造になった時点で「配列をそのまま加工する」設計自体を見直したほうがいいかもしれません。値オブジェクトなり、専用のクラスなりに逃がすと、参照の話は最初から発生しません。

関数の引数で参照を受けるのはどこまで許容するか

参照渡しの引数は、呼び出し側から見て挙動が読めなくなるのが最大の欠点です。

<?php
function normalize(array &$row): void
{
    $row['name'] = trim($row['name']);
}

normalize($row); // 見た目からは $row が変わると分からない

呼び出し行だけを読んでも副作用の有無が判別できません。戻り値で返す形にすれば、その曖昧さは消えます。

<?php
function normalize(array $row): array
{
    $row['name'] = trim($row['name']);
    return $row;
}

$row = normalize($row);

とはいえ、参照渡しが正解の場面もあります。巨大な配列を何度も加工していくバッチ処理や、preg_match() の第3引数のように「結果を受け取る箱」を渡す設計は、参照のほうが自然です。判断基準としては、関数が値を返すのが主目的なら戻り値、状態を書き換えるのが主目的なら参照くらいに考えておくと迷いません。

オブジェクトに & は基本的に不要

ここは誤解が多いところです。PHP5以降、オブジェクトを変数に代入すると渡るのは「オブジェクトハンドル」であって、オブジェクトの実体はコピーされません。

<?php
function rename(User $user): void
{
    $user->name = '新しい名前'; // & なしでも呼び出し元に反映される
}

なので &$user と書く必要はありません。& が意味を持つのは、変数そのものを別のオブジェクトに差し替えたいときだけです。

<?php
function replace(User &$user): void
{
    $user = new User('別人'); // & がないと呼び出し元は変わらない
}

この違いを押さえておくと、既存コードで &$obj を見たときに「これは差し替え目的なのか、それとも書いた人が誤解しているのか」を判断できます。だいたいは後者です。

参照が入った配列はコピーされない

もう一つ、パフォーマンス方面の話も。PHPの配列はコピーオンライトで動くので、代入しただけではメモリは増えません。ただし配列の要素に参照が含まれていると、この最適化が効かなくなります。

<?php
$a = range(1, 100000);
$b = $a;        // 実体は共有されたまま(コピーオンライト)

$ref = &$a[0];  // ここで $a は参照を含む配列になる
$c = $a;        // 以降、代入のたびに実コピーが走る

大量データを扱う処理でうっかり参照を作ると、想定外のメモリ増加につながることがあります。memory_get_usage() の値が説明できない伸び方をしているときは、参照の混入を疑ってみる価値があります。

まとめ

参照は「同じ実体を指す」というだけの単純な機能ですが、PHPの場合はループを抜けても参照が生き残るという仕様が加わることで、途端に事故が増えます。参照付きの foreach を書いたら直後に unset()、オブジェクトには基本的に & を付けない、関数の副作用は戻り値で表現する。この3つを守っておけば、参照絡みのバグはほとんど踏まないはずです。

それでも踏むときは踏むので、そのときは末尾の要素から疑ってみてください。だいたいそこにいます。

よくある質問

Q. foreachのあとのunset()は毎回必要ですか?
A. 参照付き(as &$var)で書いた場合は毎回必要です。値渡しのループでは不要です。ネストしている場合は内側・外側それぞれで unset() してください。

Q. オブジェクトを関数に渡すときに & を付けるとどうなりますか?
A. プロパティを書き換えるだけなら、付けても付けなくても結果は同じです。違いが出るのは関数内で変数自体に別のオブジェクトを再代入する場合だけで、そのときは & がないと呼び出し元に反映されません。

Q. 参照を使ったほうが速いのでは?
A. たいていは逆です。PHPの配列はコピーオンライトで管理されているため、書き換えなければコピーは発生しません。参照を作ると逆にその最適化が無効になり、メモリ使用量が増えるケースがあります。

類似投稿

コメントを残す

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