PHP

PHP: array_uniqueについて

はじめに

こんにちは。

グレンジ Advent Calendar 2017の12月8日の記事を書かせていただきます、1年目でサーバサイドのエンジニアをしているy-encoreです。
本日は開発で使っているPHPのarray_uniqueについての話をしたいと思います。
(仮タイトルの内容から変えてしまってすみません)

array_uniqueとは?

内容についてはこちら。
http://php.net/manual/ja/function.array-unique.php

PHPをよく使う方はご存知かと思いますが、配列の値が重複するものを削除する関数になっています。

実際に例を出してみると、

code
$testArray = [
    'one' => 1,
    'two' => 1,
    'three' => 2,
    'four' => 1,
    'five' => 3,
];

print_r(array_unique($testArray));
result
Array
(
    [one] => 1
    [three] => 2
    [five] => 3
)

keyが無くてもOK。数値・文字列の混在でもstring変換後===で一致するものを削除できます。

code
$testArray = [
    1,
    '1',
    2,
    1,
    '3',
];

print_r(array_unique($testArray));
result
Array
(
    [0] => 1
    [2] => 2
    [4] => 3
)

多次元の配列に適用したい

データを連想配列の形で取ってきてそれをまとめた配列を作り、その中から重複データを削除したいと言うときがあります。

先程のphp.netの説明では、

注意: array_unique() は、 多次元配列での使用を想定したものではないことに注意しましょう。

とありましたが、「array_unique 多次元配列」などと検索してみると、
array_uniqueの第2引数sort_flagsSORT_REGULARを指定すれば上手く行きそうです。

code
$testArray = [
    'one' => [
        'first' => 1,
        'second' => 2,
    ],
    'two' => [
        'first' => 1,
        'second' => 2,
    ],
    'three' => [
        'first' => 0,
        'second' => 2,
    ],
    'four' => [
        'first' => 1,
        'second' => 2,
    ],
];

print_r(array_unique($testArray, SORT_REGULAR));
result
Array
(
    [one] => Array
        (
            [first] => 1
            [second] => 2
        )
    [three] => Array
        (
            [first] => 0
            [second] => 2
        )
)

なるほど、上手いこと重複データが削除されていそうです。

上手くいかない例もある

php_uniqueの実装は、重複判定のためにまず1回ソートをしてから同一のものを取り除いています。
先程出てきたsort_flagsというのも、このソート時の型を何に合わせるかを指定しているもので、SORT_REGULARの場合には型変換を特にすることなく比較を行ってソートします。
重複判定にソートが必要なため、ソート時の比較が上手くいかない要素が含まれている場合はarray_uniqueの結果が不安定になります。

多次元配列のarray_uniqueをするために用いたSORT_REGULARですが、
こちらの記事で指摘されている通り、数値と文字列が混在する場合には不安定な挙動を示すことがあるようです。
http://d.hatena.ne.jp/hnw/20090227

その他、重複チェックをしたい連想配列の構造がそれぞれ異なる場合にも、要素同士の比較が上手く出来ないためにarray_uniqueは結果が不安定となります。
具体的な例を出してみます。

まずは単純比較ができない連想配列を作成するメソッドを作ります

code
function createTestArray($keyName) {
    return [
        'one' => 1,
        'two' => 2,
        $keyName => 3,
    ];
}

echo '(three < san) ' . (createTestArray('three') < createTestArray('san') ? 'true' : 'false');
echo '(three > san) ' . (createTestArray('three') > createTestArray('san') ? 'true' : 'false');
echo '(three === san) ' . (createTestArray('three') === createTestArray('san') ? 'true' : 'false');

echo '(three === three) ' . (createTestArray('three') === createTestArray('three') ? 'true' : 'false');
echo '(san === san) ' . (createTestArray('san') === createTestArray('san') ? 'true' : 'false');
result
(three < san) : false
(three > san) : false
(three === san) : false

(three === three) : true
(san === san) : true

構造が異なる連想配列同士では大小比較がどちらもfalseになり、単純比較ではソートができないことがわかります。
このような連想配列をリストとして持つ配列に対してarray_uniqueをかけてみます。

code
$keyNameList = [
    'three',
    'san',
];

$testArrayList = [];
for ($i = 0; $i < 30; $i++) {
    $testArrayList[] = createTestArray($keyNameList[rand(0, 1)]);
}

print_r(array_unique($testArrayList, SORT_REGULAR));
result
Array
(
    [0] => Array
        (
            [one] => 1
            [two] => 2
            [san] => 3
        )
    [1] => Array
        (
            [one] => 1
            [two] => 2
            [san] => 3
        )
    [3] => Array
        (
            [one] => 1
            [two] => 2
            [three] => 3
        )
    [18] => Array
        (
            [one] => 1
            [two] => 2
            [three] => 3
        )
)

一部は確かに消えていますが、重複する要素が残っている状態のままであることが確認できます。

array_uniqueを使わずに重複要素を取り除く

構造が異なる連想配列を要素に持つ配列の重複を取り除くときには、array_uniqueを使わずに自前で実装するのが安全かと思います。
計算コストは高くなりますが、foreachとin_arrayを使って比較的簡単に書くことが出来ます。

foreach+in_array
function myArrayUnique($array) {
    $uniqueArray = [];
    foreach($array as $key => $value) {
        if (!in_array($value, $uniqueArray)) {
            $uniqueArray[$key] = $value;
        }
    }
    return $uniqueArray;
}

また、キーが不要であればarray_reduceとin_arrayでも書けます。
重複を取り除く場合はキーが不要なことが多いので、こちらのほうが綺麗かもしれません。

array_reduce+in_array
$uniqueArray = array_reduce($array, function($carry, $item) {
    if (!in_array($item, $carry)) {
        $carry[] = $item;
    }
    return $carry;
}, []);

さいごに

ここまで読んでくださった方、ありがとうございました!
グレンジのアドベントカレンダーはまだまだ続きますのでぜひよろしくお願いします。

[前日]12/7 raitome : Spineの最新版を使ってみた
https://qiita.com/raitome/items/5ae1fce0bb4fc68fc796

[翌日]12/9 flankids : Unityで始める!負荷削減
https://qiita.com/flankids/items/a7b1be3100e4129d1c58