最近初めてPHPを書きました。
その際に遭遇した、「同一スコープ内の2つのforeachにて、ループ変数として &$value$value をこの順で使うと、Arrayの中身が上書きされる」という話です。
この仕様は様々な記事になっているため今更感はありますが、自分なりに考えたことをまとめておきます。

概要

以下の一見なにもしないように見える2つのループは、実はArrayの中身を書き換えてしまいます。PHP初心者としては驚きでした。

$arr = [1, 2, 3];
foreach ($arr as &$value) {} // 意味のないループ(?)
foreach ($arr as $value) {}  // 意味のないループ(?)
print_r($arr); // [1, 2, 3] が出力されそうだが...?

出力

Array
(
    [0] => 1
    [1] => 2
    [2] => 2
)

何もしないループがあるだけなのに、$arr[2]3から2 に変わってしまいました。

何が起きたか

この現象は、phpのドキュメント foreach に記載があります(”警告”の部分)

ざっくりまとめると

  • ループ変数のスコープはループの外
    • つまり、ループ後もループ変数は存在し続ける
  • ループ変数に & をつけると参照渡し、つけないと値渡しになる

という2つの仕様の組み合わせでした。

$arr = [1, 2, 3];
foreach ($arr as &$value) {}
// 1つ目のループを抜けた時点で、$value は $arr[2] を差し続ける
foreach ($arr as $value) { // $value つまり $arr[2] に対し、$arrの要素を順に入れる
    print_r($arr);
}
Array
(
    [0] => 1
    [1] => 2
    [2] => 1
)
Array
(
    [0] => 1
    [1] => 2
    [2] => 2
)
Array
(
    [0] => 1
    [1] => 2
    [2] => 2
)

2つ目のループでは

  1. $value つまり $arr[2]$arr[0] を代入する
    • [1, 2, 1] となる
  2. $value つまり $arr[2]$arr[1] を代入する
    • [1, 2, 2] となる
  3. $value つまり $arr[2]$arr[2] を代入する
    • [1, 2, 2] となる

のような書き換えが起きています。

特に実際の開発シーンでは、長い関数に処理を追加する際に注意が必要そうです。

function long_func($arr) {
    foreach ($arr as &$value) {
    }
    // ...
    // いろいろな処理
    // ...

+   // 新機能開発で、以下を追加!
+   foreach ($arr as $value) {
+
+   }

    // ...
    another_func($arr); // 壊れる箇所の1つ
    // ...
}

対策案

unsetで参照を解除する

PHPドキュメントに記載されている方法です。
ループを抜けたらすぐにunsetして参照を解除しておけば、書き換えは回避できます。

$arr = [1, 2, 3];
foreach ($arr as &$value) {}
unset($value); // 参照解除
foreach ($arr as $value) {}
print_r($arr); 
Array
(
    [0] => 1
    [1] => 2
    [2] => 3
)

とはいえ、書き忘れますよね。既存コードも毎回unsetされているとは限りません。

foreach+参照の組み合わせを避ける

array_keys を使うなどして、キーをループする方針なら、事故は減りそうです。

$arr = [1, 2, 3];
foreach ($arr as &$value) {} // 既存コード

foreach (array_keys($arr) as $key) {
    $arr[$key]; // に対する操作をする
}

array_keys はArrayを返すため、巨大な配列を扱うケースではそれはそれで注意が必要になります。

根本対策は難しい

unset無しの既存コードがある という環境では、

  • ループ変数名が重複しないか確認する
  • unset無しの参照変数を見つけたら、unsetをつける
  • 参照を使う場合は、必ずunsetする
  • foreach+参照の組み合わせを避ける(array_keys を使う)

を徹底するしか無さそうです。

とはいえ “気を付ける” だけではどうしようもないので、せめてCI等で検出できるようにしておきたいところです。

Tags:

Categories:

Updated: