読者です 読者をやめる 読者になる 読者になる

Intelligent Technology's Technical Blog

株式会社インテリジェントテクノロジーの技術情報ブログです。

PHPの「参照」と配列の組み合わせに潜む罠

櫻です。

PHPの癖のある挙動を知ったので、記事にしてみました。
以下のバージョンで確認してあります。

PHP OS インストール方法
5.3.3 CentOS 6.5 yum
5.3.28 Mac OS X 10.9.1 homebrew
5.5.8 Mac OS X 10.9.1 homebrew


元ネタは以下のサイトです。

なかなか直感を裏切ってくれる挙動なため是非、結果を推測して、実際に動作させてみると面白いと思います。
↓を使うと簡単に確認できます。

1 配列の代入
<?php
$a = array(1, 2, 3); // 配列を作成
$b = $a;             // 配列を代入
$b[1] = 0;           // 代入先の配列を変更
// $aの値は?
var_dump($a);

これは次の様になります。

array(3) {
  [0] =>
  int(1)
  [1] =>
  int(2)  // $a[1]の値は2のまま、変更されていない。
  [2] =>
  int(3)
}

PHPでは配列の代入や関数での受け渡し時に値のコピーが行われるため、$bへの操作で$aの中身は影響をうけません。
ただし、Copy-on-Write(COW)な戦略を取っているため、実際に配列のコピーが直に実行されることにはなりません。
配列が代入時にコピーされる挙動は、メジャーな言語では珍しいですが、Goでも同様の動作になります。

2 参照を利用した代入
<?php
$a = array(1, 2, 3);
$c = &$a[1];  // 配列の要素への参照
$c = 0;       // ↑へ代入
// $aの値は?
var_dump($a);

これは次の様になります。

array(3) {
  [0] =>
  int(1)
  [1] =>
  int(0)    // $a[1]の値が0に変更されている
  [2] =>
  int(3)
}

$cは$a[1]への参照なので、$cへの代入は$a[1]の値を変更します。

3 1+2の合体
<?php
$a = array(1, 2, 3);
$c = &$a[1];          // 配列の要素への参照 (2)
$b = $a;              // 配列のコピー
$b[1] = 0;            // コピーした配列を書き換える。
// $aの値は?
var_dump($a);

配列の代入はコピーが行われるのでarray(1, 2, 3)と思いきや、そうはなりません。

array(3) {
  [0] =>
  int(1)
  [1] =>
  int(0)     // 直接触っていない$a[1]の値が0に変更されている
  [2] =>
  int(3)
}

$a[1] の値は0になっています。
これはPHPの変数管理の仕様?で(2)の行を実行すると、$a[1]の値が参照として利用されている状態に代わり、その後$bへ配列のコピーを行ったため、$b[1]も同じ$a[1]の値への参照を保持する状態になっているためです。
この内部状態はxdebug_debug_zval()という関数を利用する事で確認できます。(要xdebugのインストール)

<?php
$a = array(1, 2, 3);
xdebug_debug_zval('a');
$c = &$a[1];
xdebug_debug_zval('c');
xdebug_debug_zval('a');

これを実行すると、次の様になります。

a: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=1, is_ref=0)=2, 2 => (refcount=1, is_ref=0)=3)
c: (refcount=2, is_ref=1)=2
a: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=2, is_ref=1)=2, 2 => (refcount=1, is_ref=0)=3)

$cへの代入後、$a[1]のrefcountが2、is_refが1になっている事が確認できます。
refcountはその値が何カ所から参照されているか、is_refは「参照かどうか」を表します。詳細はPHP: 参照カウント法の原理 - Manualを参照してください。

この動作はPHPのドキュメントにも記述されています(PHP: リファレンスが行うことは何ですか? - Manual)が、注意が必要な動作ですね。

4 unset()

ところで、PHPには変数の割り当てを破棄するunset()関数があります。参照の解説を行っているページ等では参照は使い終わったらunset()しましょうという記述をよく見ます。unset()を行うとどうなるのでしょうか?

<?php
$a = array(1, 2, 3); // 配列を準備
$c = &$a[1];         // 配列の要素への参照
$b = $a;             // 配列をコピー
unset($c);           // 「配列の要素への参照」をunset()
$b[1] = 0;
// $aの値は?
var_dump($a);

結果は次の様になり、$b[1]への代入で$a[1]が変更される事はなくなりました。

array(3) {
  [0] =>
  int(1)
  [1] =>
  int(2)    // $a[1]は2のまま変更されていない
  [2] =>
  int(3)
}

内部状態はどうなっていたかというと、

<?php
$a = array(1, 2, 3);
xdebug_debug_zval('a'); // (1)
$c = &$a[1];
xdebug_debug_zval('a'); // (2)
$b = $a;
xdebug_debug_zval('a'); // (3)
unset($c);
xdebug_debug_zval('a'); // (4)

(1)の時点では全てがrefcount=1, is_ref=0の状態
(2)で$a[1]の値がrefcount=2, is_ref=1に代わる(参照されている状態)
(3)では$a[1]に変化無し
(4)で$cを破棄したので、$a[1]のrefcount=1に減り、is_ref=0に戻っている。*1

a: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=1, is_ref=0)=2, 2 => (refcount=1, is_ref=0)=3)
a: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=2, is_ref=1)=2, 2 => (refcount=1, is_ref=0)=3)
a: (refcount=2, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=2, is_ref=1)=2, 2 => (refcount=1, is_ref=0)=3)
a: (refcount=2, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=1, is_ref=0)=2, 2 => (refcount=1, is_ref=0)=3)

つまり、不要な参照はunset()しておくと、参照として動作する値を元に戻す事ができる=分かりにくい動作を引き起こしにくくなる。という事でしょうか。

5 おさらい

最後に、ここまでをまとめて…

<?php
$a = array(1, 2, 3); // 配列を準備
$c = &$a[1];         // 配列の要素への参照
$b = $a;             // 配列をコピー
$b[0] = 9;           // コピーした配列に変更を加える
unset($c);           // 「配列の要素への参照」をunset()
$b[1] = 0;
// $aの値は?
var_dump($a);

結果は次の様になります。

array(3) {
  [0] =>
  int(1)
  [1] =>
  int(0)    // $a[1]が0に変更されている
  [2] =>
  int(3)
}

$a[1]は0です。$b[1]への代入で値が変更されています。
想像していたものとあっていたでしょうか?
順に$aの状態を確認してみましょう。

<?php
$a = array(1, 2, 3);
echo '$a = array(1, 2, 3);' . PHP_EOL;
xdebug_debug_zval('a');
$c = &$a[1];
echo '$c = &$a[1];' . PHP_EOL;
xdebug_debug_zval('a');
$b = $a;                    // (1)
echo '$b = $a;' . PHP_EOL;
xdebug_debug_zval('a');
xdebug_debug_zval('b');
$b[0] = 9;                  // (2)
echo '$b[0] = 9;' . PHP_EOL;
xdebug_debug_zval('a');
xdebug_debug_zval('b');
unset($c);                  // (3)
echo 'unset($c);' . PHP_EOL;
xdebug_debug_zval('a');
xdebug_debug_zval('b');

こうなります。

$a = array(1, 2, 3);
a: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=1, is_ref=0)=2, 2 => (refcount=1, is_ref=0)=3)
$c = &$a[1];
a: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=2, is_ref=1)=2, 2 => (refcount=1, is_ref=0)=3)
$b = $a;
a: (refcount=2, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=2, is_ref=1)=2, 2 => (refcount=1, is_ref=0)=3)
b: (refcount=2, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=2, is_ref=1)=2, 2 => (refcount=1, is_ref=0)=3)
$b[0] = 9;
a: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=3, is_ref=1)=2, 2 => (refcount=2, is_ref=0)=3)
b: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=9, 1 => (refcount=3, is_ref=1)=2, 2 => (refcount=2, is_ref=0)=3)
unset($c);
a: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=1, 1 => (refcount=2, is_ref=1)=2, 2 => (refcount=2, is_ref=0)=3)
b: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=9, 1 => (refcount=2, is_ref=1)=2, 2 => (refcount=2, is_ref=0)=3)

(1)の$bへの代入($aのコピー)までは「4 unset()」の例と同じですが、注目すべきは配列全体のrefcountです。
$b = $aの時点で$a、$bそれぞれの配列全体のrefcountが2に増えています。配列のコピーはCOWで行われるため、この時点ではまだ実体のコピーは行われておらず、配列は共有されたままの状態です。
そして(2)の「$b[0] = 9;」です。
ここで初めて$aの配列がコピーされて$bがコピーを参照する事になります。$a、$bそれぞれのrefcountが1に減って、$a[1]、$b[1]のrefcountが3に増えています。*2$a[1]、$b[1]、$cが3カ所から全て同じ値を参照している事になります。
そしてここから(3)で$cをunset()しても$a[1]、$b[1]はrefcount=2、is_ref=1のままとなり、同じ値を参照した状態のままです。
この状態で$a[1]または$b[1]に何かを代入すると、もう一方も(同じ値を参照しているので当然)変更されます。

まとめ

3、4、5のコードを比べると(1)、(2)の行があるかないかの差があるだけです。

<?php
$a = array(1, 2, 3);
$c = &$a[1];
$b = $a;
$b[0] = 9;    // (1)
unset($c);    // (2)
$b[1] = 0;
// $aの値は?
var_dump($a);
(1) (2)
3 無し 無し
4 無し 有り
5 有り 有り

PHPの参照は慎重に利用しなければ、非常に分かりにくいバグの元になりそうです。*3またarrayはコピーされるので、外部から変更されないと思っていると罠にはまりそうです。
元ネタのサイトでも主張されていますが、複雑な内部状態はクラスを作成してオブジェクトのインスタンスに記憶させた方が分かりやすそうです。

参照以外にも、三項演算子(条件演算子)の結合規則が他の言語と異なるなど、PHPは何かと油断できない言語ですね。

<?php
echo true ? 'a' : false ? 'b' : 'c';
// ↑が
echo (true ? 'a' : false) ? 'b' : 'c';
// ↑と解釈されて'b'が出力される。

*1:参照元が一つになった=参照として動作する必要がなくなった?

*2:$a[0]のrefcountが1のままで$a[2]のrefcountが2になっているのは何故だろう…

*3:配列のCOWが他に影響を与えるとか、想像できませんでした…