PHP には配列の差分を取るための関数がいくつかあります。
array_diff_assoc — 追加された添字の確認を含めて配列の差を計算する
array_diff_key — キーを基準にして配列の差を計算する
array_diff_uassoc — ユーザーが指定したコールバック関数を利用し、追加された添字の確認を含めて配列の差を計算する
array_diff_ukey — キーを基準にし、コールバック関数を用いて配列の差を計算する
array_diff — 配列の差を計算する
array_udiff_assoc — データの比較にコールバック関数を用い、追加された添字の確認を含めて配列の差を計算する
array_udiff_uassoc — データと添字の比較にコールバック関数を用い、追加された添字の確認を含めて配列の差を計算する
array_udiff — データの比較にコールバック関数を用い、配列の差を計算する
これらは便利ですが、いずれも一次元配列を対象にしており多次元配列(ネストした配列)については期待通りの結果を返してくれません。そういった中では多次元配列対応のために再帰的にネストした配列を処理できる関数を実装することがしばしばあります。この PHP の組み込みにありそうでない関数を実装する際、多くの方が命名規則を共通認識として持っており、その名前は往々にして array_diff_xxxx_recursive となります。この記事では自分なりの array_diff_key_recursive の実装と GitHub で見つかったいくつかの array_diff_key_recursive のパターンを紹介します。
自分の実装した array_diff_key_recursive は次です。
/**
* array_diff_key をネストした配列にも使える様にしたもの。
* a にあって b にないものを残す。
* @param mixed $a
* @param mixed $b
* @return array
*/
function array_diff_key_recursive($a, $b): array
{
// 返り値
$ret = [];
// a を基準に配列全体を回す
foreach($a as $ak => $av) {
if(!is_array($b) || !array_key_exists($ak, $b)) {
// a にあって b にないキーであるか、転じて返すべき部分であるか否か
// ここに入るため if の条件を書き換えることで自由に様々な array_diff_hoge_recursive にできます
$ret[$ak] = $av;
} elseif(is_array($av)) {
// 今使っている値が配列ならば更に奥まで見に行く必要があるので再帰
$subRet = array_diff_key_recursive($av, $b[$ak]);
if(!empty($subRet)) {
// 再帰した結果、返すべき部分が存在するならば返り値に追加
$ret[$ak] = $subRet;
}
}
}
return $ret;
}
これを使うと次の折り畳みのテストが通る様な配列の差分を再帰的に求める処理ができます。
array_diff_key_recursive テスト
<?php
namespace Tests\Unit\Helper;
use Tests\TestCase;
class ArrayDiffKeyRecursiveTest extends TestCase
{
public function testSuccess(): void
{
$a = [
0 => 0,
1 => 0,
2 => 0,
3 => [],
4 => [],
5 => [],
'a' => [
'x' => 24,
'y' => '25',
'z' => [0, 1],
],
'b' => [
'x' => 24,
'y' => '25',
'z' => [0, 1],
],
'c' => [
'x' => 24,
'y' => '25',
'z' => [0, 1],
],
'd' => [
'x' => 24,
'y' => '25',
'z' => [0, 1],
],
'e' => [
'x' => 24,
'y' => '25',
'z' => [0, 1],
],
];
$b = [
// 0が存在しない
1 => 10,// 1 が数値で異なる値
2 => [],// 2 が配列で異なる値
// 3 が存在しない
4 => 10,// 4 が数値で異なる値
5 => [],// 5 が配列で異なる値
// a が存在しない
'b' => 10,// b が数値で異なる値
'c' => [],// c が配列で異なる値
'd' => [// d.y が存在しない
'x' => 124,
'z' => [0, 1],
],
'e' => [// e.x, e.z[1] が存在しない
'y' => '125',
'z' => [0],
],
];
$r = array_diff_key_recursive($a, $b);
self::assertEquals([
0 => 0,
3 => [],
'a' => [
'x' => 24,
'y' => '25',
'z' => [0, 1],
],
'b' => [
'x' => 24,
'y' => '25',
'z' => [0, 1],
],
'c' => [
'x' => 24,
'y' => '25',
'z' => [0, 1],
],
'd' => [
'y' => '25',
],
'e' => [
'x' => 24,
'z' => [1 => 1],
],
], $r);
}
}
array_diff_key_recursive で GitHub の中をググった結果が次です。いくつかパターンがあり、それを紹介します。
Search · array_diff_key_recursive
一つ目は次の array_diff_key をそのまま実行する再帰パターンです。
function array_diff_key_recursive ($a, $b): ?array
{
// 返り値
$ret = [];
// a を基準に配列全体を回す
foreach($a as $ak => $av) {
if (is_array($av) && isset($b[$ak])) {
// 今使っている値が配列ならば更に奥まで見に行く必要があるので再帰
$ret[$ak] = array_diff_key_recursive($av, $b[$ak]);
} else {
// しばしば @array_diff_key とするパターンがあります。
// @ をつけることでエラーをいくらか黙らせあれますが PHP8 以降無視できないエラーとなり落ちます
if(is_array($b)){
$ret = array_diff_key($a, $b);
}else{
$ret = [];// @ の場合、ここが null のコードと同等になり、後述の型の違うキー被りの際、謎の 'key' => null が残ります。
}
}
// array_diff_key の結果が異常であったり、再帰結果が空だった場合は返り値を unset
// この unset をしないと無用な空配列が差分として返り値に残ります
if (isset($ret[$ak]) && is_array($ret[$ak]) && empty($ret[$ak])) {
unset($ret[$ak]);
}
}
return $ret;
}
これと自分の作ったコードの最大の相違点は片方ではネストした配列であるがもう片方では空でない値が入っているプロパティについての処理です。具体的には次の様になります
$arg1 = ['a' => [0, 1], 'b' => [0]];
$arg2 = ['a' => 1];
// array_diff_key を呼び出す実装
$a = array_diff_key_recursive($arg1, $arg2);
var_dump($a);
/**
array(2) {
["b"]=>
array(1) {
[0]=>
int(0)
}
}
*/
// 自作
$b = array_diff_key_recursive_mine($arg1, $arg2);
var_dump($b);
/**
array(2) {
["a"]=>
array(2) {
[0]=>
int(0)
[1]=>
int(1)
}
["b"]=>
array(1) {
[0]=>
int(0)
}
}
*/
array_diff_key をそのまま実行するパターンのコードでは、配列で追えるところまでのキーについて差分なしなので、差分なしとして出力しません。一方で自分の場合はより a に寄せる形で差分ありとして出力しています。どちらの結果が望ましいかは使う状況次第ですが大きな違いです。自分がこの様な結果になる実装を行ったのは、差分を取るべき対象が yaml ファイルを元にした配列であり、以前は文字列だったが現在は配列型に拡張されている、といった状態が起こりうるフォーマットだったためです。
GitHub の検索結果中の古いコード(十中八九PHP8より前に書かれたコード)では@array_diff_keyとしているコードがしばしばありますが、これは PHP8 では Fatal Error でストップするコードなので上述の例の様に条件分岐であらかじめエラーパターンをカバーしておく方が良いです(PHP8より前は@で Fatal Error も黙らせられました)。
他には次の様に array_diff_key, array_intersect_key を使ってループする必要のある部分だけ分離するパターンがあります。
function array_diff_key_recursive (array $a, array $b): array
{
// 配列のネストしていない部分についての差分を取る
$ret = array_diff_key($a, $b);
// 配列のネストしていない部分について共通のキーを持つ部分をとる
$intersect = array_intersect_key($a, $b);
// ネストしていない部分の差分を格納した変数にネストしている部分の差分を追加するためのループ
foreach ($intersect as $k => $v) {
// 共通のキーを持つ部分をループし、
if (is_array($a[$k]) && is_array($b[$k])) {
// ネストしているならば再帰
$subRet = array_diff_key_recursive($a[$k], $b[$k]);
if ($subRet) {
// ネストした先の差分を再帰で探索した結果、差分が存在したならば
// ネストしていない部分の差分を格納した変数にネストしている部分の差分を追加
$ret[$k] = $subRet;
}
}
}
return $ret;
}
こちらも配列で追えるところまでのキーについて差分なしなら終了なので、型が違うが値の存在する部分は差分なしとして扱っています。