PHP foreach ループ後の参照残し

コード

[PHP]よくやるミス foreach ループ後の参照にハマる!

PHP を使えば、Web ベースのシステムが比較的簡単に構築できます。そのため、PHP は人気ですが、その使いやすさにもかかわらず、多くのフレームワーク、ニュアンス、および開発者を悩ませがちな細かな機能を備えてもいて、非常に洗練された言語に進してしまいました。そのため、何時間にもわたるデバッグが時折要求されつつあります。

今回は、ずいぶん大昔に、私的に悩んだ PHP デバッグのネタを紹介します。
PHP は Perl ベースの言語ですが、現在では文法が多少似ている程度で、細かいルールなどは別物に進化していると意識しておくと、頭が私のような Perl 前提で考える人には有益かもしれません。

foreach ループの後に配列へのぶら下がり参照を残してしまった

PHP で foreach ループを使ってしまうときにやりがちなミスです。
foreach を使うときは、慣れてくると参照を当たり前のように使います。特に、反復する配列の各要素を直接操作する場合には必須です。下の例では、配列を直接書き換えています。

PHP で foreach で配列の中味をさわる例

 php
$arr = array(1, 2, 3, 4);
foreach ($arr as &$val) {
    $val = $val * 2;
}
// $arr の中味は (2, 4, 6, 8) に書き換わりました。

注意してこのようなコードを書きながらチェックしないと、望んでいない結果が生じさせてしまう可能性があることです。具体的には、上の例で、コードが実行された後も、変数 $val はスコープ内に残り、$arr 配列の最後の要素への参照を保持します。したがって、その後の $val の中味を操作すれば、もともとの配列の最後の要素が誤って変更される可能性があります(わざとやる場合を除く)。

忘れてはいけないのは foreach はスコープを作らないことです。上の例では、変数 $val は、スクリプト全体で参照できる変数になります。foreach で繰り返し処理の各ステップでは、$arr の次の要素への参照が設定されます。したがって、ループを抜けた後も、$val は依然として $arr の最後の要素を指していて、スコープ内に残り続けます。

ポイント

foreach はスコープを作らない

PHP で foreach をヤバく使ってしまう例

下の例は、これがバグを引き起こす可能性のある例です。意図してこのようなコードにすることもよくありますが、コードを書いた本人ぐらいしか瞬間的に意図を把握できなかったりしますので、ひと工夫必要してやる必要があります。他人が速攻解釈したら、違う意図を持ったコード、間違ったコードとして悩ませてしまうかもしれません。

 php
$ary = ['A', 'B', 'C'];
echo implode(',', $ary), "\n";
// 参照で処理
foreach ($ary as &$val) {}
echo implode(',', $ary), "\n";
// コピーで処理
foreach ($ary as $val) {}
echo implode(',', $ary), "\n";

上のコードは以下のような結果になります。

 出力
A,B,C
A,B,C
A,B,B

一番最後の行の処理結果を見てください。最終行の最後の値は、A,B,B となり、この結果を望んでいたのなら、それでいいのですが、もし、A,B,C という結果が欲しかったのであれば、このコードには欠陥があるということになります。

ボック
多くのケースでは、この手のコードはバグ扱いされることになります。特に誰かにメンテを引き継いでもらい、追加のコードを少し足したりすれば意図しない結果を連発するかもしれません。

なぜ、そうなるのか、望んだ結果が得られたか?

最初の foreach ループでは、配変数 $ary は変更されずに参照されるだけで終了しますが、ループを抜けた後も、参照変数の $val そのものは $ary の最後の要素へのぶら下がり参照として残ります(foreach ループで $val は参照としてアクセスされるからです)。

その結果、2番目の foreach ループ「foreach ($ary as $val) {}」を通過すると、一見意図していなかったことが起きたように感じるかもしれません。このループでは、$val に値(コピーされた値)としてアクセスされます。アクセス中、foreach ループでは連続作業で配列 $ary の要素を $val に収めて(コピーして)いきます。つまり、2番目の foreach ループでは次のようなことが起きています。

ぶら下がり参照はどう影響するか?(2番目の foreach ループ)

1番目の foreach ループを抜けても、その段階で $val は $ary[2] への参照として残っていることに注意する必要があります。また、配列の中身も念のため書き出してみます。

 php
$ary[0] = 'A';
$ary[1] = 'B';
$ary[2] = 'C';

1巡目: 1巡目のループを丁寧に書くと、こうなります。

 php
$val = $ary[0];

$ary[0](つまり、「A」)が $val にコピーされます。この段階で $val というのは先ほどの処理の残り、$ary[2] への参照になっています。
つまり、結局はこういうことです。

 php
$ary[2] = $ary[0]; // $ary[2] に 'A' を代入した

このケースでは、$val に値を代入するということは、$ary[2] に値を代入(コピー)するということになります。
$ary[2] の中身は A に書き換えられます。したがって $ary の中身は ['A', 'B', 'A'] になります。

2巡目:同様に、一つずつかみ砕いていきます

 php
$val = $ary[1]; // 'B' を入れる

$ary[1](中身は現在「B」)が $val(中身は $ary[2] への参照になっている)にコピーされます。要するに、

 php
$ary[2] = $ary[1]; // $ary[2] に 'B' を代入した

ということですので、$ary[2] の中身は B になります。この段階で $ary は ['A', 'B', 'B'] になります。

3巡目:ループの最後もしっかりみてみます。次の処理が行われます。

 php
$val = $ary[2]; // 'B' を入れる

$ary[2](中味は「B」になっています)が $val($ary[2]への参照です)にコピーされます。

 php
$ary[2] = $ary[2]; // $ary[2] に 'B' を代入した

$ary[2] には B が入っていますので、処理後の $ary の中味は ['A', 'B', 'B'] になります。ここでループが終了です。
配列参照
このプロセスがさっと頭で流せるのであれば、$ary の中味が書き換わったことに抵抗を感じないはずですが、$ary を書き直す気がなかったにもかかわらず、このコードを書いてしまってはバグとりに後から時間を割かれることになります。

解決策とまとめ

foreach ループを使うときに、この種のリスクのあるコードはわかって書いていたとしても好ましいものではありません。他者が見たときに、わからないというより、少し複雑に組み合わせると、書いた本人もこんがらがることがあります。

リスクを冒さず、foreach ループ内で参照を使う場合は、unset() を忘れずに活用します。foreach ループの直後に呼び出して参照を削除してしまうのがエチケットみたいなものです。
今までの例を書き換えると、

 php
$ary = ['A', 'B', 'C'];
echo implode(',', $ary), "\n";
// 参照で処理
foreach ($ary as &$val) {}
echo implode(',', $ary), "\n";
// 参照させなくする処理
unset($val);
// コピーで処理
foreach ($ary as $val) {}
echo implode(',', $ary), "\n";

処理結果は、

 出力
A,B,C
A,B,C
A,B,C

となって、狙い通りです。

-コード
-, , , , ,

© 2020 ネーテルス