PHPのnatsortとSORT_NATURALでimg10がimg2より前に来る問題を直す
自然順ソートとは、文字列中の数字を数値のまとまりとして比べ、img2がimg10より前に来るように並べる方法です。PHPではnatsort、SORT_NATURAL、strnatcmpで実現できます。
ファイル名の一覧を並べ替えたら、img10.pngがimg2.pngより前に出てきてちょっと固まった、という経験はないでしょうか。私はあります。バグではなく、sortが文字列を一文字ずつ比べているだけなのですが、画面で見ると明らかに変な順番なんですよね。
なぜimg10がimg2より前に来るのか
sortのデフォルトは文字列を先頭から順に比較します。「img1」と「img2」の次の文字を比べる場面で、「1」(img10の1文字目)と「2」を比べるので、img10のほうが小さいと判定されます。
$files = ['img12.png', 'img10.png', 'img2.png', 'img1.png'];
sort($files);
echo implode(',', $files);
// img1.png,img10.png,img12.png,img2.png
連番のファイル名、章番号、バージョン文字列あたりで頻繁に出くわします。見た目の順に並べたいなら、数字を数値として読む比較に切り替える必要があります。
sortにSORT_NATURALを渡すとどうなるのか
一番手軽なのは、sortの第2引数にSORT_NATURALを渡すやり方です。既存のsortを書き換えるだけで済むので、まずこれを検討するといいと思います。
$files = ['img12.png', 'img10.png', 'img2.png', 'img1.png'];
sort($files, SORT_NATURAL);
echo implode(',', $files);
// img1.png,img2.png,img10.png,img12.png
sortなのでキーは振り直されて0から始まる連番になります。配列を並べて表示するだけの用途なら、これで十分です。
natsortはキーを保つので何が起きるのか
natsortも同じ自然順で並べますが、キーとの対応を保ったまま並べ替えます。ここを知らずに使うと、あとで少し戸惑います。
$files = ['img12.png', 'img10.png', 'img2.png', 'img1.png'];
natsort($files);
echo json_encode($files);
// {"3":"img1.png","2":"img2.png","1":"img10.png","0":"img12.png"}
キーが飛び飛びなので、json_encodeすると配列ではなくオブジェクトとして出力されます。APIのレスポンスに使うと、フロント側で期待した配列が来なくて悩むことになります。リストとして扱いたいなら、natsortのあとにarray_valuesを通すか、最初からsortにSORT_NATURALを渡すほうが安全です。
大文字小文字はどう扱われるのか
natsortは大文字小文字を区別します。大文字が先に並ぶので、「Img2.png」が「img1.png」より前に来てしまいます。区別したくないときはnatcasesortを使うか、sortのフラグにSORT_FLAG_CASEを足します。
$m = ['Img2.png', 'img10.png', 'img1.png'];
natsort($m);
echo json_encode($m);
// {"0":"Img2.png","2":"img1.png","1":"img10.png"}
natcasesort($m);
echo json_encode($m);
// {"2":"img1.png","0":"Img2.png","1":"img10.png"}
sort($m, SORT_NATURAL | SORT_FLAG_CASE);
echo json_encode($m);
// ["img1.png","Img2.png","img10.png"]
コメントの結果は、3つとも元の配列に対して単独で実行した場合のものです。natsortとnatcasesortはキーを保ち、sortは振り直す、という違いだけ押さえておけば十分です。
usortやuksortで自然順を使うにはどう書くのか
連想配列の行を、あるキーの値で自然順に並べたいことも多いです。その場合は比較関数にstrnatcmpを使います。大文字小文字を無視したければstrnatcasecmpです。
$rows = [['name' => 'ch10'], ['name' => 'ch2'], ['name' => 'ch1']];
usort($rows, fn($x, $y) => strnatcmp($x['name'], $y['name']));
echo json_encode($rows);
// [{"name":"ch1"},{"name":"ch2"},{"name":"ch10"}]
$map = ['ch10' => 1, 'ch2' => 2, 'ch1' => 3];
uksort($map, 'strnatcmp');
echo json_encode($map);
// {"ch1":3,"ch2":2,"ch10":1}
比較関数にstrnatcmpをそのまま渡せるので、自前で数字を切り出す正規表現を書かなくて済みます。これが地味にありがたいところです。
全角数字は自然順で数値扱いされるのか
手元で試した限り、全角数字は数字として扱われませんでした。半角の「第10話」は「第2話」の後ろに来ますが、全角の「第10話」は「第2話」より前に来ます。
$a = ['第10話', '第2話', '第1話'];
natsort($a);
echo json_encode($a, JSON_UNESCAPED_UNICODE);
// {"2":"第1話","1":"第2話","0":"第10話"}
$b = ['第10話', '第2話', '第1話'];
natsort($b);
echo json_encode($b, JSON_UNESCAPED_UNICODE);
// {"2":"第1話","0":"第10話","1":"第2話"}
日本語のデータを扱うなら、並べ替える前にmb_convert_kanaの’n’オプションなどで半角に揃えておくのが無難だと思います。
まとめ
img10がimg2より前に来るのは、sortが文字列を一文字ずつ比べているからでした。キーを気にしないならsortにSORT_NATURAL、キーを保ちたいならnatsort、行や連想配列ならusortやuksortにstrnatcmp、という整理で当面は困らない気がしています。ファイル名の一覧を扱うたびに、またこの話を思い出しそうです。
よくある質問
Q. natsortとsort($a, SORT_NATURAL)の違いは何ですか?
A. 並び順は同じ自然順です。違いはキーの扱いで、natsortはキーと値の対応を保ち、sortはキーを0からの連番に振り直します。
Q. 自然順で大文字小文字を無視するにはどうしますか?
A. natcasesortを使うか、sortにSORT_NATURAL | SORT_FLAG_CASEを渡します。比較関数なら、strnatcasecmpが使えます。
Q. バージョン番号の比較にもstrnatcmpを使っていいですか?
A. 「v1.10」が「v1.9」より大きいという単純な比較なら動きます。ただ、バージョン番号の比較に特化したversion_compareという関数がPHPにあるので、プレリリース表記なども含めて扱うならそちらのほうが適しています。