PHPのリファレンス(参照&)の傾向と対策、あるいはさよなら

  • 36
    Like
  • 0
    Comment

利用しなくていいんだよ、&からは逃げて、全力で。

0. リファレンスとは何か

リファレンス (references, 参照とも呼ばれる)はPHPの言語仕様の中では珍しく、説明がめんどくさい方の文法です。用語がいっぱい出てきてめんどくさいってことは、 必要に迫られなければ利用しなくて良い 文法だってことです。

そのめんどくささは、PHPマニュアルにおけるリファレンスの説明の説明が(2017年2月20日時点において)七章に亘ることからお察しください。 (もっとも分量は多くないといふか、むしろ超短いので読んでください)

本来であれば「マニュアル嫁」で済むはずで、わざわざQiitaなんかで再説明をするまでもないことなのですが、リファレンスにみなさま興味があるようなので一度書きます。

私の持論ですが、 リファレンス(参照)を利用しないで済むなら避けた方が賢明 です。

この記事の目的は「リファレンス(参照)を有効活用する」ことではなく、会社で「ねー先輩、どうしてこのコードは配列を操作できるの?」「ねえねえ先輩、どうして & 使っちゃいけないのー」と質問されたときに窮しないように理論武装することです。

(この記事の見出しは言語機能としての意味論ではなく、利用例ごとの分類であることに気をつけてください)

見出しに 🙆 (OK) とか 🙅 (No Good) とか付けてあるのは完全に主観なので、それぞれ意味や動作を把握して自分で判断してくださいね ヾ(〃><)ノ゙☆

1. 🙆 配列操作函数

配列から値を取り出したり、値を追加したり、配列の中身を操作する函数はリファレンスをとります。筆者の認識では、これらの機能は 安全なので使って大丈夫 です。

リファレンスの動作を説明するためにarray_shift()を再実装してみます。

<?php

function my_array_shift(&$ary)
{
    $rest_values = [];

    foreach ($ary as $a) {
        if (!isset($first)) {
            $first = $a;
        } else {
            $rest_values[] = $a;
        }
    }

    // 値を取り出した残りをセット
    $ary = $rest_values;

    // 先頭の値を返す
    return $first;
}
// この実装にはバグがあるよ。探してみてね。
// あと、わざと読みにくいコードにしてあるのでお手本にしないでね


$input = range(0, 9);
$f = my_array_shift($input);
var_dump($f, $input);

$s = my_array_shift($input);
var_dump($s, $input);

2. 🙆 ソート函数

PHPの標準函数にあるソートは、リファレンスをとります。配列操作函数の一種ですが、ここでは区別して更に説明します。これは安全なコードなので 使って大丈夫 なものです。

<?php

$a = ["orange", "apple", "strawberry", "banana"];

echo "before: ";
print_r($a);

echo PHP_EOL;

sort($a);
echo "after: ";
print_r($a);

配列$asort()に渡されると、別の配列になってしまひました。この状態の変化は、換言すれば「変数が破壊的に変更された」とも表現することもできます。

では変数を破壊しない——つまり、ソート前の配列を残しておく方法はあるのでせうか? それは簡単です。sort()に渡してやる前に別の変数にコピーしてやればいいだけです。

<?php

$a = ["orange", "apple", "strawberry", "banana"];
$copy = $a;

sort($a);
echo "sorted: ";
print_r($a);

echo PHP_EOL;

echo "before: ";
print_r($copy);

このとき、$aにはソート後の配列になり、$copyはソート前の状態の配列が温存されます。

これは、Ruby的に表現するとArray#sortArray#sort!、Python的に表現するとlist.sortsortedの差のようなものです。 (Pythonはちょっと事情が異りますが…)

以下はソートアルゴリズム(?)を自作(?)してみる例です。

<?php

/** @see https://ja.wikipedia.org/wiki/%E3%83%9C%E3%82%B4%E3%82%BD%E3%83%BC%E3%83%88 */
function my_sort(array &$ary)
{
    begin:
    $tmp = $ary;
    shuffle($tmp);
    $result = $tmp;

    $a = array_shift($tmp);
    while (true) {
        $b = array_shift($tmp);

        if ($a > $b) goto begin;
        if (count($tmp) == 0) goto finish;

        $a = $b;
    }

    finish:
    // ソート結果は $ary に代入すればいい
    $ary = $result;
    return true;
}
// この実装の一時変数は減らせるよ。やってみてね。

3. 🤔 配列操作とソート函数の組み合せ

「配列の中身の配列をそれぞれソート」したいとき、次のように書くことはできません。この種類のコードは使っても害はないですが、 foreachを使った方が簡単 でもあります。

<?php

$a = [
    [2, 7, 4, 3, 1, 5, 6],
    ['c', 'a', 'b', 'd'],
];

$x = array_map('sort', $a);
var_dump($x);

なぜこのように書けないかはそれぞれ試していただくとして、次のように書くことは可能です。

いままで説明した配列操作とソートの二種類の機能を以下のように組み合せることができます。

<?php

$a = [
    [2, 7, 4, 3, 1, 5, 6],
    ['c', 'a', 'b', 'd'],
];

$x = $a;

array_walk($x, function(&$v) { sort($v); });
var_dump($x);

ここまでの説明を読めば、なぜarray_map()がだめでarray_walk()なら動くのか、おわかりいただける気がします。

ただ、わざわざarray_walk()など利用しなくても、以下のようにforeachを使って書くことができます。

<?php

$a = [
    [2, 7, 4, 3, 1, 5, 6],
    ['c', 'a', 'b', 'd'],
];

$x = [];
foreach ($a as $i => $v) {
    sort($v);
    $x[$i] = $v;
}

var_dump($x);

どのパターンで書くのが読みやすいかはひとの趣味によりますので、好きにしてください。array_map()で書くこともできるので、やってみると楽しいですよ ヾ(〃><)ノ゙

4. 🤔 オプショナルな値を返す

preg_match()みたいなやつ。

<?php

$s = "foooooooooobar";

if (preg_match('/fo+b.r/i', $s)) {
    echo "マッチしたよ";
} else {
    echo "マッチしないよ";
}

if (preg_match('/f(o+)b.r/i', $s, $matches)) {
    echo "マッチしたよ{$matches[1]}";
} else {
    echo "マッチしないよ";
}

preg_match()は文字列を正規表現検索して、マッチすれば1を、マッチしなければ0を、エラーが発生したらfalseを返す函数です。引数に$matchesと書くと、その結果が$matchesにセットされます。

この設計のメリットは、追加情報の要不要は呼び出し側が決めることができ、追加で $matches と書くだけで足りる点です。以下のように実装できます。

<?php

/**
 * 配列に含まれる各要素をファイルに書き込む
 *
 * なんかそれっぽいものをでっちあげたけど、特に実用性はない
 *
 * @param  string[] $values
 * @param  resource $file
 * @param  array    $info 書き込まれた文字列の文字数とバイト数を持った配列
 * @return bool     ファイルに文字列が書き込まれたか
 */
function writeStrings($values, $file, &$info = [])
{
    $bytes = 0;
    $chars = 0;

    foreach ($values as $v) {
        if (is_string($v) || method_exists($v, '__toString') || is_int($v)) {
            $s = (string)$v;
            $bytes += strlen($v);
            $chars += mb_strlen($v, 'UTF-8');
            fwrite($file, $s . PHP_EOL);
        }
    }

    $info = [
        'bytes' => $bytes,
        'chars' => $chars,
    ];

    return $bytes !== 0;
}

このコードを利用するコードは以下のようになります。

<?php

// 書き込まれたデータがないので false が返る
var_dump(writeStrings([], STDOUT)); //=> false

// 書き込まれたデータがあるので true が返る
var_dump(writeStrings(['apple', 'banana', 'orange'], STDOUT)); //=> true

// 追加情報を $info で受け取る
$f = fopen('php://temp', 'rw');
var_dump(writeStrings(['りんご', 'ばなな', 'みかん'], $f, $info)); //=> true
var_dump($info);
var_dump(stream_get_contents($f));

私がこの手法を業務コードで書きたくなったことは、あんまりないです。ただ、追加情報を $obj->getLastResult() みたいなメソッドを別途呼び出させるよりも良いのかな、と感じないこともないです。

5. 🙅 (非推奨) foreachとリファレンス

「配列の中身をループで加工したい」ケースがあったとします。これは、リファレンスを使って以下のように書くことができます。

<?php

$speach = ["こんにちは", "ありがと", "さよなら"];

// 全部の文字列を 「」 で括る
foreach ($speach as &$s) {
    $s = "「{$s}」";
}
unset($s);

ここでは、unset($s)が重要です。unset()は絶対に書かなければいけません。バグの温床です。

unset()を怠ることでバグが発生するケースはエッジケースなどではなく、カンタンに再現できます。

<?php

$speach = ["こんにちは", "ありがと", "さよなら"];

// 全部の文字列を 「」 で括る
foreach ($speach as &$s) {
    $s = "「{$s}」";
}

foreach ($speach as $s) {
    echo $s, PHP_EOL;
}
// 「こんにちは」
// 「ありがと」
// 「ありがと」

「さよなら」が消えてなくなるのか、次の節でもうすこし説明しますが、このバグが起こらなくなるようにするのは簡単です。

以下のパターンなら、上記よりは事故が発生しなくなります。

<?php

$speach = ["こんにちは", "ありがと", "さよなら"];

// 全部の文字列を 「」 で括る
foreach ($speach as $i => $s) {
    $speach[$i] = "「{$s}」";
}

foreach ($speach as $s) {
    echo $s, PHP_EOL;
}
// 「こんにちは」
// 「ありがと」
// 「さよなら」

map()が好きなひとはarray_map()で書いても大丈夫です。

さて、foreachにおいて、パフォーマンスチューニングの観点から&を利用すべきと主張されるパターンもあります。不安なら処理時間やメモリ使用量などを自分で計測してみると良いですね。

(「とりあえず巨大なデータをつっこんでみる」とか「10000000000回ループして比較してみる」みたいな計画性のない雑なベンチマークなら、意味が極めて薄弱なのでやらない方がましです。実データと同質のデータを、実コードと同程度の回数だけ実行して比較してみてください。変更回数を減らす方向にロジック/アルゴリズムを改善した方が効果がありそうな予感がしますし、そもそもからしてボトルネックではないところいくらチューニングしても無為です)

6. 🙅 (強く非推奨) 変数の別名をつける

変数名$variableの前に&を前置することで、変数のリファレンスを取得することができます。これを別の変数に代入する($alias = &$variable;)ことで、変数に別名を付けたのと同じように動作します。

ぱっと見でわかりにくいコードになるし頭がこんがらがるので、絶対におすすめしないです

<?php

$long_variable_name = "LONG";

$l = &$long_variable_name;

var_dump($l); //=> "LONG"
$l = "SHORT";

var_dump($long_variable_name); // "SHORT"

短い文字数の別名を使ってショートコーディングをしたい気持ちはわかりますが… 私はコードレビューを通しません。

さて、「5. (非推奨) foreachとリファレンス」のforeachを使ったコードは、foreachを抜いて以下のように擬似的に分解できます。

<?php

$speach = ["こんにちは", "ありがと", "さよなら"];

$s = &$speach[0]; // $s が $speach[0] の別名になる
$s = "「{$s}」";
$s = &$speach[1];
$s = "「{$s}」";
$s = &$speach[2];
$s = "「{$s}」";

// unset($s); # ここに unset があれば、問題なし

$s = $speach[0]; // unset を忘れると $speach[2] = $speach[0] と同じ意味
echo $s, PHP_EOL;
// 「こんにちは」

$s = $speach[1]; // unset を忘れると $speach[2] = $speach[1] と同じ意味
echo $s, PHP_EOL;
// 「ありがと」

$s = $speach[2]; // unset を忘れると… 茶番
echo $s, PHP_EOL;
// 「ありがと」

7. 🙅 (意義が皆無) リファレンス返し

私は最近のPHPでしか開発した経験がないので本来の正しい活用方法はよくわかりませんが、現代においては、もはや有意義な機能ではありません。 (この記事ではPHPのリファレンスについて網羅しようと決めたので書きます)

構文上は以下のようなコードが許されます。詳しくはPHP: リファレンスを返す - Manualを読んでください。

<?php

$o = new stdClass;
$foo = &ref_foo($o);

$foo = "FOO";

var_dump($o);
// object(stdClass)#1 (1) {
//   ["foo"]=>
//   &string(3) "FOO"
// }
exit;

function &ref_foo($obj)
{
    $obj->foo = null;
    return $obj->foo;
}

私から多くを語ることはありませんが、PHPマニュアルには パフォーマンスを向上させるためだけの目的で この機能を用いることはやめてください。 そのようなことをしなくても、PHP エンジンが自動的に最適化を行います。 とあります。世の中にPHP4時代のコードがどの程度残ってるのかわかりませんが、PHP7とか7.1で削除されなかったのが結構ふしぎ。

8. 🙅 (廃止済) 呼び出し時リファレンス

これも歴史です。この機能についてはPHP: リファレンス渡し - Manualに言及があります。

PHP 5.3系までは、たしかにこのコードが機能しました。

<?php

ref_var_5(&$v);

var_dump($v);
// Deprecated: Call-time pass-by-reference has been deprecated in /in/WD0CD on line 3
// int(5)
exit;

function ref_var_5($v)
{
    $v = 5;
}

ここでも特に語ることはなく、バグの温床だから順当に消されましたよね、みたいな感じです。

9. 🤔 変数のデフォルト値

PHP7で追加された??(Null合体演算子)に似た機能を部分的に再現できます。 isset($v) ? $v : 2$v ?? 2 に縮めて書けるのがNull合体演算子の特徴です。

PHP5でも動作しますが、??とは動作に差異はあります(未定義変数にデフォルト値またはnullが代入されます)。「ひょっとしてこれは便利っぽい機能なのでは」などとぬかよろこびされたら申しわけないのですが、筆者はおすすめしません

<?php

$v = 1;

// $v が未定義なら 2 が表示
echo isset($v) ? $v : 2;

// PHP7の $v ?? 2 と同じ
echo default_var($v, 2), PHP_EOL; //=> 1 
set_default_var($v, 2); //(変化しない)
var_dump($v); //=> 1    // 元と同じ

// PHP7の $x ?? 2 と同じ
echo default_var($x, 2), PHP_EOL; //=> 2
set_default_var($x, 2); // $x に 2 が代入される
var_dump($x); //=> 2 (代入されてる)

exit;

/**
 * PHPの $v ?? $default と同じような感じ
 */
function default_var(&$v, $default)
{
    return ($v === null) ? $default : $v;
}

/**
 * PHPの $v = $v ?? $default と同じような感じ
 */
function set_default_var(&$v, $default)
{
    if ($v === null) {
        $v = $default;
    }
}

そもそもの話、「変数が定義済みかどうか」で条件分岐するのは変数の定義位置がわかりにくくなる(静的解析しにくくなる)のでアンチパターンです。ただ、そのアンチパターンまみれのコードをリファクタリングしたいときには利用できるかもしれません。新規のプロジェクトに入れる理由も特にないです。あと筆者は実用したことないです。よって推奨できません

まとめ

その昔はパフォーマンスチューニングと称して&を付けるような時代もあったらしいですが現代においては(たいてい)意味がありませんし、PHPが糞言語なのはどう考えても参照をポインタだと思っているお前らが悪い - なんたらノート第三期ベータをお読みください。

  • 「1」と「2」は、覚えておくと配列操作が理解できてオススメです
  • それ以外の難しい機能は理解できなくても(&から逃げれば)支障ありません
    • 「3」と「4」は、好みの問題です (筆者はforeach推奨)
    • 「5」は、unset()を怠ったときにどんな現象が発生するのか説明できるなら使ってもいいです (が、めんどくさくないですか…?)
    • 「6」は、静的解析がやりにくくなったりするので、 まったく推奨しません
    • 「7」「8」は、おそらく大多数の人には無縁な歴史的要素です (私も業務コードで見た経験がありません)
    • 「9」は、手強いレガシーコードをリファクタリングするときに利用できないことはないかもしれません (評価不能)

会社でのコードレビューのときに&を見付けてしまったら、その意味についてきちんと説明してあげてくださいね。


最後に繰り返しますが、この記事で言及する「リファレンス」とは、公式リファレンスマニュアルである「PHPマニュアル」ではなく、日本語で「参照」とも呼ばれることがあるプログラミング言語機能のことです。言語リファレンス関数リファレンスは、穴が開くまで読むと良いです。