考えなしにforeachでリファレンスを使うと事故が発生します。
$arr = [1, 2, 3];
foreach($arr as &$value){}
$value = 'x';
var_dump($arr); // [1, 2, 'x']
foreachを抜けた後も最後のリファレンスが残り続けるので、うっかり削除し忘れると思ってもなかった変数値が変わってしまいます。
ところで先日Unwrap reference after foreachというRFCが提出されました。
提案者はいつものNikitaです。
Unwrap reference after foreach
Introduction
foreachでリファレンスを使うと、ループを抜けた後も変数は最後の配列要素へのリファレンスを保ったままです。
そのため、後でその変数を再利用した際に予期しない動作になることがあります。
このRFCでは、foreachを抜けた後にリファレンスを外すことを提案します。
非常によく知られている落とし穴を取り上げます。
大きく警告されているにも関わらず、この現象に関するバグレポートが定期的に上がってきます。
原因は以下のように解説できます。
$array = [1, 2, 3];
foreach ($array as &$value) { /* ... */ }
foreach ($array as $value) { /* ... */ }
var_dump($array);
// [1, 2, 2] 現在の動作
// [1, 2, 3] このRFCの提案
現在の動作は一見バグのように見えますが、実際は完全に正しい動作です。
具体的には以下のようになっています。
$array = [1, 2, 3];
// 1回目のforeach
$value =& $array[0];
$value =& $array[1];
$value =& $array[2];
// 2回目のforeach $valueは$array[2]へのリファレンスのまま。
$value = $array[0]; // $array は [1, 2, 1] になる。$array[2]は1になる。
$value = $array[1]; // $array は [1, 2, 2] になる。$array[2]は2になる。
$value = $array[2]; // $array は [1, 2, 2] のまま。
正しい動作ですが、思ってもない動作であることも確かです。
Proposal
このRFCでは、foreachを抜けたときにリファレンスをアンラップすることを提案します。
すなわち、$value
は最後の値の要素は保持したままですが、リファレンスではなくなるということです。
なおbreak
、continue
、goto
でループを抜けた場合も同じです。
PHPにはリファレンスのアンラップを行う言語構造はありませんが、論理的には以下と同じ動作になります。
$tmp =& $value;
unset($value);
$value = $tmp;
最初の例では、一回目のループが終わったところで$value
はリファレンスでなくなり、2回目のループでは元の配列に影響を与えないことが保証されます。
$array = [1, 2, 3];
foreach ($array as &$value) { /* ... */ }
// ここで$value はリファレンスではなくただの値になる。
foreach ($array as $value) { /* ... */ }
var_dump($array); // [0=>int(1), 1=>int(2), 2=>int(3)]
考慮すべきエッジケースとして、値が単純な変数ではない場合があります。
意外なことに以下は全て合法な書式です。
foreach ($array as &$info['value']) {}
foreach ($array as &$arrayCopy[]) {}
foreach ($array as &getInfo()['value']) {}
このRFCでは、値が単純な変数である場合にのみアンラップを行い、複雑な変数に対しては行わないことを提案します。
理由としては、複雑な変数には副作用がある可能性があるからです。
最も明らかなケースとして、&$arrayCopy[]
ではアンラップ時に$arrayCopy
にnull要素が増えます。
また&getInfo()['value']
においてはgetInfo()
が呼び出されるため任意の副作用が発生します。
foreachのターゲットに複雑な変数を使用することは非常に珍しく、このような使い方をしているループ変数でリファレンスの問題が発生する可能性は相当に低いと思われます。
常にアンラップを行うことは可能ですが、この場合においては何もしない方がよいでしょう。
foreachを分割代入と併用した場合は、単純な変数に対してのみアンラップされます。
foreach ($array as [&$var, &$complex->var]) {}
この場合、$var
はアンラップされますが、&$complex->var
はそのままです。
Backward Incompatible Changes
この変更は後方互換性がなく、ループを抜けた後で配列要素を変更することができなくなります。
foreach ($array as &$value) { /* ... */ }
$value = 'Modify the last element'; // 今後は$arrayに影響しない
とはいえこのような使用方法は非常に稀であり、ビギナー開発者が陥りがちな罠を消すことの方がより価値があるでしょう。
今後も同じことを行いた場合は、ループ内で明示的にリファレンス代入することで再現可能です。
$lastRef = null;
foreach ($array as &$value) {
/* ... */
$lastRef =& $value;
}
$lastRef = 'Modify the last element'; // 今後も動く
メーリングリスト
メーリングリストでの議論。
Nikita「バグじゃないんだけどバグトラッカーによく上がって来るから対応した。予想外に簡単に回避できた。」
Hossein Baghayi「$value
をブロック内変数にすればいいんじゃね。」
Hans Henrik Bergan「ていうかforeach($it as let &$v){}
ってしたいよね。」
Trevor Rowbotham「もうforeachを新しいスコープにしようぜ。」
Claude Pache「単純な変数と複雑な変数で処理が分かれているのが一貫性がなくて気に食わない。」
laviohbatista「投票権はないけど+1で。」
全面的賛成というほどではありませんが、明確に反対という人はいませんでした。
感想
リファレンスは使うな!
これだけで全てが解決します。
まあ言語仕様上使用可能なので、リファレンス機能自体を削除しないかぎりどうにもならないですが。
そして、そんなことはさすがに不可能でしょう。
ということで、現在の仕様をなるべく保ちつつ事故を防ぐ方法としては、それなりに妥当な提案ではないかと思います。
ただ、面倒な適用条件を入れているせいで、逆に見通しが悪くなっているような気がします。
場合によって動作が異なるというのはやっぱり筋がよくないですよね。
適用するなら適用するで、あらゆる状況で一律に適用してほしいところです。