PHPのarray_merge_recursiveで設定が配列だらけになる理由とarray_replace_recursiveの使い分け

array_merge_recursiveは同じ文字列キーの値を上書きせず配列にまとめる関数で、array_replace_recursiveは同じキーの値を再帰的に上書きする関数です。デフォルト設定にユーザー設定を重ねたい用途なら、選ぶべきは後者です。

名前から「mergeの再帰版だから、深い階層まで上書きしてくれるんだろう」と思いたくなるのですが、実際に動かすとそうならないんですよね。私も最初、設定のマージで debug が [false, true] になっていて首をかしげました。

array_merge_recursiveは何をしているのか

まず挙動を確認します。デフォルト設定とユーザー設定を重ねるだけの、よくある場面です。

<?php
$default = [
    'db'    => ['host' => 'localhost', 'port' => 3306],
    'debug' => false,
    'hosts' => ['a', 'b'],
];
$user = [
    'db'    => ['host' => 'example.com'],
    'debug' => true,
    'hosts' => ['c'],
];

echo json_encode(array_merge_recursive($default, $user));
// {"db":{"host":["localhost","example.com"],"port":3306},"debug":[false,true],"hosts":["a","b","c"]}

文字列キーが衝突すると、後の値で上書きするのではなく、両方を配列に並べます。host が配列になり、debug も [false, true] になっています。設定値として使うには、まず意図と違う形です。

一方で、数値キーの配列(hosts)は連番で振り直されて連結されます。リストを足し合わせたいときにはこれが効きますが、設定の上書きとは目的が別物ですね。

上書きしたいならarray_replace_recursiveを使う

同じ構造で array_replace_recursive を使うと、期待どおりの上書きになります。「なぜ上書きの意図ならreplaceなのか」は、名前の動詞をそのまま読めば済む話でした。

<?php
echo json_encode(array_replace_recursive($default, $user));
// {"db":{"host":"example.com","port":3306},"debug":true,"hosts":["c","b"]}

db の port は残り、host だけが差し替わっています。ここは嬉しいところです。ただし hosts が [“c”,”b”] になっている点には注意が必要です。

リストはキー単位で上書きされる

array_replace_recursive は数値キーも「キー単位」で置き換えます。[‘a’,’b’] に [‘c’] を重ねると、キー0だけが c になり、キー1の b が残ります。リスト全体を差し替えたつもりでも、古い要素が混ざるわけです。

<?php
echo json_encode(array_replace_recursive(['a' => ['x', 'y']], ['a' => [1 => 'Z']]));
// {"a":["x","Z"]}

これはバグではなく仕様で、キーを指定して一部だけ差し替える使い方には向いています。設定で「リストは丸ごと置き換えたい」なら、後述の自前関数のほうが安全です。

片方が配列でないときはどうなるか

型が食い違うキーも要注意です。array_replace_recursive は、基本的に後ろの値が勝ちます。

<?php
echo json_encode(array_replace_recursive(['a' => ['x']], ['a' => 's']));
// {"a":"s"}
echo json_encode(array_replace_recursive(['db' => ['host' => 'l', 'port' => 1]], ['db' => null]));
// {"db":null}

ユーザー設定で db を null にしてしまうと、デフォルトの階層ごと消えます。一方 array_merge_recursive は、文字列と配列が衝突すると文字列を配列に包んで結合します([‘a’=>’x’] と [‘a’=>[‘y’]] で {“a”:[“x”,”y”]})。どちらも「何も起きないはず」とは言えない挙動です。

リストは丸ごと置換したいときの自前関数

実務の設定マージでは、連想配列は再帰的に重ね、リストは丸ごと置き換える、という動きが欲しいことが多いです。array_is_list(PHP 8.1以降)を使うと短く書けます。

<?php
function deepMerge(array $base, array $override): array
{
    foreach ($override as $key => $value) {
        if (is_array($value) && isset($base[$key]) && is_array($base[$key]) && !array_is_list($value)) {
            $base[$key] = deepMerge($base[$key], $value);
        } else {
            $base[$key] = $value;
        }
    }
    return $base;
}

echo json_encode(deepMerge($default, $user));
// {"db":{"host":"example.com","port":3306},"debug":true,"hosts":["c"]}

これなら hosts は [“c”] に置き換わり、db は階層ごとに上書きされます。空配列 [] を上書き値にすると array_is_list が true になるので、「空にして上書き」も意図どおり動きます。

まとめ

設定の上書きが目的なら array_replace_recursive、配列を寄せ集めたいなら array_merge_recursive、と目的で分けるのが基本かなと思います。ただリストの扱いまで気にするなら、十数行の自前関数のほうが、結局あとで悩まずに済む気がしています。

よくある質問

Q. array_mergeでは再帰的な上書きはできませんか。
A. array_merge は第1階層だけを見て、同じ文字列キーなら後の値で丸ごと置き換えます。上の例だと db が [‘host’ => ‘example.com’] だけになり、port が消えます。

Q. array_merge_recursiveが向いているのはどんな場面ですか。
A. 複数の配列から同じキーの値を全部集めたい場面です。たとえばエラーメッセージをキーごとに溜めていくような用途では、値が配列にまとまる挙動がそのまま役に立ちます。

Q. 引数に配列を3つ以上渡せますか。
A. 渡せます。どちらの関数も可変長引数で、左から順に重ねていきます。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です