PHPの配列変数の奇妙なふるまい

【注意】非常に今さらなお話です。
【注意】💩な言語の💩なコードでも、マネーを生んでるプロダクトには敬意を払うべきです。

copy on write

まず、PHPの配列変数の代入文 (や引数渡し) はシャローコピーなのかディープコピーなのか?
C言語をはじめ、JavaC#などたいていの言語では配列変数の代入文はシャローコピーである。
これは当然に思える。代入や引数渡しのたびに実体を複製するのは効率が悪い。ではPHPはどうか?

<?php
$a = array(111, 222, 333);
$b = $a;
$b[1] = 666;
echo("a = ");var_dump($a);
echo("b = ");var_dump($b);
?>

a = array(3) { [0]=> int(111) [1]=> int(222) [2]=> int(333) }
b = array(3) { [0]=> int(111) [1]=> int(666) [2]=> int(333) }

この結果はディープコピーのように見える。だが本当にそうか?

<?php
$a = array(111, 222, 333);
$b = $a;
echo "変更前:a と b の実体は";
if ($a === $b) { echo "同じ\n"; } else { echo "異なる\n"; }
$b[1] = 666;
echo "変更後:a と b の実体は";
if ($a === $b) { echo "同じ\n"; } else { echo "異なる\n"; }
echo("a = ");var_dump($a);
echo("b = ");var_dump($b);
?>

変更前:a と b の実体は同じ
変更後:a と b の実体は異なる
a = array(3) { [0]=> int(111) [1]=> int(222) [2]=> int(333) }
b = array(3) { [0]=> int(111) [1]=> int(666) [2]=> int(333) }

つまり、代入した時点ではシャローコピーであり実体は同じものである。しかし $b の要素に対して変更が発生する際にはディープコピーが行われ、この時点から $a と $b は別物になる。これを copy on write と言う。たしかに上手い仕組みかもしれない。見かけ上は配列変数を通常の変数と同じように扱えて、かつ必要なとき以外は無駄な実体のコピーはしない。そのようにPHPのランタイムが上手くやってくれるのでプログラマーは余計なことを意識しないでいい。ここまでは問題ない。

参照

ところで、PHPにも参照というのがある。

<?php
$a = array(111, 222, 333);
$c = &$a[1];
$c = 777;
echo("a = ");var_dump($a);
?>

a = array(3) { [0]=> int(111) [1]=> &int(777) [2]=> int(333) }

しかしこれはちょっと奇妙な挙動だ。参照された側の $a[1] に参照マークが付いている。
これをさきほどの配列の代入文と組み合わせるとさらに奇妙なことが起こる。

<?php
$a = array(111, 222, 333);
$c = &$a[1];
$b = $a;
echo "変更前:a と b の実体は";
if ($a === $b) { echo "同じ\n"; } else { echo "異なる\n"; }
$b[1] = 666;
echo "変更後:a と b の実体は";
if ($a === $b) { echo "同じ\n"; } else { echo "異なる\n"; }
echo("a = ");var_dump($a);
echo("b = ");var_dump($b);
?>

変更前:a と b の実体は同じ
変更後:a と b の実体は同じ
a = array(3) { [0]=> int(111) [1]=> &int(666) [2]=> int(333) }
b = array(3) { [0]=> int(111) [1]=> &int(666) [2]=> int(333) }

$c は $a[1]を参照したが、値の変更はしていない。そして $b = $a と代入したあと $b[1] を変更しようとしても、copy on writeは起こらず $a と $b の実体は同じまま。そのため $a[1] まで変更されてしまう。

いや、なんでやねん!?

$c = &$a[1] とすることで、なぜか参照された側の $a[1] まで参照型になってしまう。 $b[1] = 666 という操作は参照先の値の変更であって参照を変更するわけではないので、$a に対する変更とは見なされず copy on write は起こらない。

いや、なんでやねん!?

なんで配列の要素を他の変数が参照すると参照された要素まで参照型に変化するの?

このことは公式のドキュメントにも書かれている。

「しかし、配列の内部のリファレンスは危険もあるということに気をつけましょう。」
「Note, however, that references inside arrays are potentially dangerous.」

…気を付けましょうじゃねえよ。まあ、そんなことふつうはしないだろうけどさ。

unset()

このような厄介なことが起こるので、参照は使い終わったら unset() で解除する。

<?php
$a = array(111, 222, 333);
$c = &$a[1];
$b = $a;
unset($c);
echo "変更前:a と b の実体は";
if ($a === $b) { echo "同じ\n"; } else { echo "異なる\n"; }
$b[1] = 666;
echo "変更後:a と b の実体は";
if ($a === $b) { echo "同じ\n"; } else { echo "異なる\n"; }
echo("a = ");var_dump($a);
echo("b = ");var_dump($b);
?>

変更前:a と b の実体は同じ
変更後:a と b の実体は異なる
a = array(3) { [0]=> int(111) [1]=> int(222) [2]=> int(333) }
b = array(3) { [0]=> int(111) [1]=> int(666) [2]=> int(333) }

unset($c) することで $a[1] は参照型から元の値型に戻る。
これでよし。そういう言語仕様なのだと飲み込むしかない。

蛇足

代入文と同様に、関数への引数渡しも copy on write であるから、関数内で配列の要素を変更しても呼び出し元の配列は変化しない。C言語のように引数で渡した配列に変更を反映させたいのであれば、参照で渡す。

<?php
function hoge($arr) {
  $arr[1] = 666;
}
function piyo(&$arr) {
  $arr[1] = 777;
}
$a = array(111, 222, 333);
echo("a = ");var_dump($a);
hoge($a);
echo("a = ");var_dump($a);
piyo($a);
echo("a = ");var_dump($a);
?>

a = array(3) { [0]=> int(111) [1]=> int(222) [2]=> int(333) }
a = array(3) { [0]=> int(111) [1]=> int(222) [2]=> int(333) }
a = array(3) { [0]=> int(111) [1]=> int(777) [2]=> int(333) }