PHPのarray_multisortで複数キーソートする、そのハマりどころ
array_multisortは、複数の配列や配列内の複数キーを基準にして、まとめて並べ替えるPHPの標準関数です。80字くらいで言うとそういう関数です。
DBから取ってきた行データを「まず売上降順、同額なら日付昇順」みたいに複数条件でソートしたい、という場面、実務では結構出てきますよね。usortでコールバックを書いてもいいんですが、array_multisortを使うと比較関数なしで書けて、地味に楽です。ただし挙動にクセがあって、私は一度これで本番のデータ並び順を壊しかけたことがあります。今日はその話です。
array_multisortは何をしてくれるのか
基本形はこうです。配列を複数渡すと、1つ目の配列を主キーにして、同値のときだけ2つ目の配列を見て決着をつける、という「行としてのソート」をしてくれます。
$ar1 = [10, 100, 100, 0];
$ar2 = [1, 3, 2, 4];
array_multisort($ar1, $ar2);
var_dump($ar1); // [0, 10, 100, 100]
var_dump($ar2); // [4, 1, 2, 3] ← $ar1の100,100に対応する3,2は順番維持
$ar1が主キーとして並び替わり、$ar1の値が同じ(100と100)だった位置だけ、$ar2の値でさらに順番が決まります。$ar1・$ar2は参照渡しで直接書き換わるので、戻り値を受け取る必要はありません。
DBの行データをソートするならどう書く?
連想配列の集合(いわゆる行の配列)をソートしたいときは、array_columnで列ごとに取り出してから渡すのが定番です。
$data = [
['volume' => 67, 'edition' => 2],
['volume' => 86, 'edition' => 1],
['volume' => 85, 'edition' => 6],
['volume' => 98, 'edition' => 2],
['volume' => 86, 'edition' => 6],
];
$volume = array_column($data, 'volume');
$edition = array_column($data, 'edition');
// volume降順、同じvolumeならedition昇順
array_multisort($volume, SORT_DESC, $edition, SORT_ASC, $data);
foreach ($data as $row) {
echo $row['volume'], ' - ', $row['edition'], "\n";
}
// 98 - 2
// 86 - 1
// 86 - 6
// 85 - 6
// 67 - 2
ポイントは、最後に$data自体も引数に渡していることです。こうしておくと、$volumeと$editionの並び替えに連動して、元の行データである$dataも同じ順番に並び替わってくれます。array_columnで作った一時配列だけソートして満足していると、肝心の$dataは元のままなので注意です。
なぜ末尾に渡した配列まで並び替わってしまうのか
ここが一番ハマったところです。array_multisortに渡した配列は「ソート条件を指定する配列」と「単にソート順に追従させたいだけの配列」を区別してくれません。渡した配列は全部、同じ基準で一緒に並び替えの対象になります。
たとえば「nameでソートしたいけど、idは元の順番のまま添えておきたい」と思って以下のように書くと、期待は裏切られます。
$data = [
['id' => 5, 'name' => 'Marie'],
['id' => 7, 'name' => 'Lisa'],
['id' => 4, 'name' => 'Marie'],
['id' => 3, 'name' => 'Jean'],
['id' => 1, 'name' => 'Marie'],
['id' => 2, 'name' => 'Jean'],
];
array_multisort(
array_column($data, 'name'), SORT_ASC, SORT_REGULAR,
$data
);
foreach ($data as $row) {
echo $row['id'], ' ', $row['name'], "\n";
}
// 2 Jean ← idも一緒にソートされ、3,2の順は入れ替わっている
// 3 Jean
// 7 Lisa
// 1 Marie ← 同じMarie同士もid順に並び替わっている
// 4 Marie
// 5 Marie
nameが同じ「Marie」の3件は、元の$dataでは5, 4, 1の順で並んでいましたが、結果は1, 4, 5になっています。array_multisortは「同値のときは次の配列を基準に順番を決める」という仕様なので、$data自体が2番目の配列として渡された時点で、その中身(連想配列同士の比較)も同値判定のタイブレークに使われてしまうんですね。array自体の比較結果は直感的に予測しづらいので、思わぬ順番になりがちです。
これを避けたいなら、同値のときの順番まで自分で固定したい旨を明示するしかありません。元のキー順を保証したいだけなら、キー番号そのものを追加の判定材料として渡す方法があります。
$name = array_column($data, 'name');
$originalOrder = array_keys($data); // [0,1,2,3,4,5]
array_multisort($name, SORT_ASC, $originalOrder, SORT_ASC, $data);
// 同名の要素は必ず元の並び順(id 5,4,1 の順)で並ぶ
$originalOrderは値がすべて一意なので、これ以上タイブレークが起きる余地がなくなり、「name昇順、同名なら元の順番」という意図した結果になります。
キーの再インデックスにも気をつける
もう一つ実務で刺さりやすいのが、ソート後に数値キーが振り直される点です。公式マニュアルにも明記されていますが、文字列キー(連想配列のキー)はそのまま維持される一方、数値キーは0からの連番に振り直されます。
$arr = [10 => 'c', 20 => 'a', 5 => 'b'];
array_multisort($arr);
print_r($arr);
// [0 => 'a', 1 => 'b', 2 => 'c'] ← 元のキー20,5,10は失われる
元のキーをIDとして後で使うつもりだった場合、ソート後に「あれ、キーが違う」と気づいて慌てることになります。IDを保持したいなら、ソート前にidを値として持たせておくか、array_columnで作った補助配列にキーごと保持しておくのが安全です。
まとめ
array_multisortは、比較関数を書かずに複数条件のソートができる便利な関数ですが、「渡した配列は全部ソートの巻き添えになる」という前提を忘れると、思っていた順番と違う結果になりがちです。タイブレークで意図しない基準が紛れ込んでいないか、そして数値キーが再インデックスされても困らないか、この2点は使うたびに一呼吸置いて確認するようにしています。複数条件のソートが必要になるたびにusortで比較関数を書くのも悪くないですが、条件が単純な列ソートなら、array_multisortの方が読みやすいコードになることが多いという印象です。
よくある質問
Q. array_multisortとusortはどちらを使うべきですか?
A. 単純に複数のカラムを昇順・降順で並べたいだけならarray_multisortの方が短く書けます。比較ロジックが複雑(独自の優先順位や条件分岐がある)ならusortでコールバックを書いた方が意図が読み取りやすくなります。
Q. array_multisortに渡した配列のうち、ソート順に追従させたいだけの配列も条件に使われてしまうのを防ぐ方法は?
A. 完全に防ぐことはできません。同値になったときのタイブレークとして使われてしまうので、意図しない基準で決着してほしくない場合は、array_keys()などで一意な値の配列を追加の判定材料として挟み、タイブレークの余地をなくすのが実用的な対処です。
Q. ソート後に元の数値キーが必要になる場合はどうすればいいですか?
A. ソート前にid相当の値をデータの一部として持たせておくか、キーを保持したまま処理したい場合はarray_multisortではなくuasortなど、キーを保持するソート関数を検討してください。