【朗報】macOS 15.4でsortコマンドのソート順がまともに修正されました！  〜 マーズとマーキュリーの間にジュピターが割り込んでいた理由

## はじめに

以前の macOS は `sort` コマンドで日本語を含む Unicode 文字を正しくソートできませんでした。また `grep` コマンドでも正規表現の文字の範囲に正しくマッチできないという問題もありました。この記事ではその問題と理由を明らかにし、その原因がどこにあるかを確認します。なおこの問題は 2025年3月31日にリリースされた macOS 15.4 で修正されたようで（ただし別のバグがあります。補足2参照）、この記事に明らかにしている問題は macOS 15.3 以前の話です。

:::note
この記事は元々「macOS の sort コマンドのソート順はデタラメだ！」というタイトルだったのですが、書いている途中で macOS 15.4 で修正されてることに気づき、没にしようかと思ったのですが、もったいないので内容を書き換えた記事となります。記事公開時点ですでに少し古い情報ですが、macOS 14 (14.7.6) では問題が発生しサポート期間を考えれば記事の内容はまだ数年は有効でしょう。
:::

## 問題の確認

まず macOS 15.3 以前でどのような問題が発生していたのかを確認してみましょう。例えば「マーズ」「マーキュリー」「ジュピター」の 3 行の文字列が含まれたファイルを `sort` コマンドでソートしてみてください。次のように「マーズ」「ジュピター」「マーキュリー」の順番になるはずです。

```console:macOS 15.3 での実行結果
$ sort data.txt
マーズ
ジュピター
マーキュリー
```

「マーズ」と「マーキュリー」はどちらも「マー」で始まるわけですから、「マーズ」と「マーキュリー」は隣り合わせになるはずです。なのに、なぜ「ジュピター」は間に割り込んでくるのでしょうか？

項目を追加し、内部太陽系惑星[^1]をすべてをソートすると次のようになります。

[^1]: ムーン（月）は惑星ではなく地球の衛星ですが、伝統的な理由によりここでは惑星に含めます。

```console
$ sort data.txt
マーズ
ムーン
ジュピター
ヴィーナス
マーキュリー
```

さらに項目を追加し、外部太陽系惑星[^2]を追加してソートすると次のようになります。

[^2]: プルート（冥王星）は2006年に惑星から準惑星に降格されましたが、仲間外れは嫌なのでここでは惑星に含めます。

```console
$ sort data.txt
マーズ
ムーン
ウラヌス
サターン
プルート
ジュピター
ヴィーナス
ネプチューン
マーキュリー
```

追加された「ウラヌス」「サターン」「プルート」「ネプチューン」だけを見れば順番は正しいようです。しかし「ネプチューン」は1つ離れて「マーキュリー」の近くにあります。「ネプチューン」と「マーキュリー」は一体何が似ているというのでしょうか？

もうおわかりですね？ これは**文字列の長さが短い順に並んでいる**のです。もう少し正確に言うと、（おそらく）日本語文字などは同一文字として扱われ、同じ長さの文字列は同じ順番となり、その場合はバイナリ順で比較されるという仕様だと思われます。この問題はいくつかのページで言及されています。

- [macOS の sort コマンドはマルチバイト文字をきちんと扱えない](https://blog.n-t.jp/post/tech/macos-jajp-sort-is-not-ok/)
- [macOSのソートがアホで困る](https://zenn.dev/tmtms/articles/202412-macos-sort)
- [The macOS LC_COLLATE hunt](https://blog.zhimingwang.org/macos-lc_collate-hunt)

理由についても書かれており、文字の照合順序がおかしいからです。

## 文字の並びを決める照合順序 (Collation)

1969 年に Unix が誕生するよりも前、1950 年代から 1960 年代は当時のコンピュータの都合により 1 バイトの文字コード順でソートされていました。例えば ASCII コードでは全ての大文字はすべての小文字よりも小さくなります（Z < a）。しかし英語の辞書では大文字と小文字を区別せずに並べるのが普通で、この並びは一般的に「辞書順」と呼ばれています。コンピュータが英数字といくつかの記号しか使えなかった時代から、コンピュータが扱いやすい順番と、人に優しい順番は違うものでした。

1970 年代から 1980 年代にかけてコンピュータの国際化対応が行われ始め、さまざまな国の言語に対応するようになりました。このときに文字の順番は世界で一律に決まるようなものではなく、その国の文化の影響を受けることが広く認識されました。例えば（一般的に）英語では「a」と「b」の間に「æ」が来ますが、多くの日本人にとってはこの順番は馴染みの薄い並びでしょう。ちなみに日本語の文字の順番は [JIS X 4061](https://kikakurui.com/x4/X4061-1996-01.html) で定義されています。

```console:多くの環境での実行結果（Ubuntu 24.0などやmacOS 15.3を含む）
$ printf "%s\n" æ b a | LC_ALL=en_US.UTF-8 sort # 英語ロケールでの順番
a
æ
b

$ printf "%s\n" æ b a | LC_ALL=ja_JP.UTF-8 sort # 日本語ロケールでの順番
a
b
æ
```

文字の順番は Unicode のコードポイント順でもバイナリ順でもありません。ロケールごとに定義された「照合順序」の順番です。Unicode の各国の照合順序を含むロケールデータは [CLDR](https://cldr.unicode.org/) （[GitHub リポジトリ](https://github.com/unicode-org/cldr)）で定義されていますが、実際の環境や実装がこの通りに従っているかというと、それは別の話です。

macOS 15.4で修正されるまでの `sort` コマンドのソート順がおかしかったのは照合順序のデータがおかしかったからです。そのことは照合順序のファイル `/usr/share/locale/ja_JP.UTF-8/LC_COLLATE` を調べれば分かります。macOS 15.3 ではこのファイルは `la_LN.US-ASCII/LC_COLLATE` へのシンボリックリンクとなっており、ファイルサイズはわずか 2KB 程度しかありません。Unicode の文字数が 15 万字、漢和辞典に載っている日本の漢字だけでも 5 万字あることを考えると文字の順番を定義するのに 2KB は小さすぎます。これが macOS 15.5 では 526KB に増えています。ちなみに Ubuntu 24.04 の照合順序のデータファイル `/usr/lib/locale/ja_JP.utf8/LC_COLLATE` はおよそ 440KB です。

```txt:macOS 15.3の照合順序ファイル
$ wc -c /usr/share/locale/ja_JP.UTF-8/LC_COLLATE
    2086 /usr/share/locale/ja_JP.UTF-8/LC_COLLATE

$ ls -al /usr/share/locale/ja_JP.UTF-8/LC_COLLATE
lrwxr-xr-x  1 root  wheel  28  3  6 19:06 ↩
    ↪ /usr/share/locale/ja_JP.UTF-8/LC_COLLATE -> ../la_LN.US-ASCII/LC_COLLATE
```

```txt:macOS 15.5（修正済み）の照合順序ファイル
$ wc -c /usr/share/locale/ja_JP.UTF-8/LC_COLLATE
  538904 /usr/share/locale/ja_JP.UTF-8/LC_COLLATE
```

```console:Ubuntu 24.04の照合順序ファイル
$ wc -c /usr/lib/locale/ja_JP.utf8/LC_COLLATE
439686 /usr/lib/locale/ja_JP.utf8/LC_COLLATE
```

ちなみに、末尾が `.UTF-8` で終わる文字コードのうち、ソート順が正しいと思えるような文字コードはあるのかと調べましたが、macOS 15.3 ではすべて `la_LN.US-ASCII` と大差ない大きさのファイルへのシンボリックリンクとなっていました。これらは一部の文字の順番に違いがあると想定されますが大部分の文字の順番は定義されていないと推測できます。

```console:macOS 15.3での実行結果
$ wc -c $(realpath /usr/share/locale/*.UTF-8/LC_COLLATE | sort -u)
    2518 /usr/share/locale/ca_ES.ISO8859-1/LC_COLLATE
    2130 /usr/share/locale/de_DE.ISO8859-1/LC_COLLATE
    2518 /usr/share/locale/es_ES.ISO8859-1/LC_COLLATE
    2086 /usr/share/locale/is_IS.ISO8859-1/LC_COLLATE
    2086 /usr/share/locale/la_LN.ISO8859-1/LC_COLLATE
    2086 /usr/share/locale/la_LN.US-ASCII/LC_COLLATE
   13424 total

$ realpath /usr/share/locale/en_US.UTF-8/LC_COLLATE
/usr/share/locale/la_LN.ISO8859-1/LC_COLLATE
```

これが macOS 15.5 では、多くの言語ごとに別の内容に変更されています。

```console:macOS 15.5での実行結果
$ wc -c $(realpath /usr/share/locale/*.UTF-8/LC_COLLATE | sort -u)
   81088 /usr/share/locale/af_ZA.UTF-8/LC_COLLATE
   78844 /usr/share/locale/am_ET.UTF-8/LC_COLLATE
  124112 /usr/share/locale/ar_SA.UTF-8/LC_COLLATE
   36756 /usr/share/locale/be_BY.UTF-8/LC_COLLATE
   81288 /usr/share/locale/ca_AD.UTF-8/LC_COLLATE
   86212 /usr/share/locale/cs_CZ.UTF-8/LC_COLLATE
   95128 /usr/share/locale/da_DK.UTF-8/LC_COLLATE
   33400 /usr/share/locale/el_GR.UTF-8/LC_COLLATE
   25308 /usr/share/locale/en_US.ISO8859-1/LC_COLLATE
   81288 /usr/share/locale/en_US.UTF-8/LC_COLLATE
   81288 /usr/share/locale/es_MX.UTF-8/LC_COLLATE
   80788 /usr/share/locale/et_EE.UTF-8/LC_COLLATE
  122108 /usr/share/locale/fa_AF.UTF-8/LC_COLLATE
  121648 /usr/share/locale/fa_IR.UTF-8/LC_COLLATE
   82888 /usr/share/locale/fi_FI.UTF-8/LC_COLLATE
   81288 /usr/share/locale/fr_CA.UTF-8/LC_COLLATE
   20560 /usr/share/locale/he_IL.UTF-8/LC_COLLATE
   17840 /usr/share/locale/hi_IN.UTF-8/LC_COLLATE
  158056 /usr/share/locale/hu_HU.UTF-8/LC_COLLATE
   15788 /usr/share/locale/hy_AM.UTF-8/LC_COLLATE
   81488 /usr/share/locale/is_IS.UTF-8/LC_COLLATE
  538904 /usr/share/locale/ja_JP.UTF-8/LC_COLLATE
   36856 /usr/share/locale/kk_KZ.UTF-8/LC_COLLATE
 2247984 /usr/share/locale/ko_KR.UTF-8/LC_COLLATE
   25308 /usr/share/locale/la_LN.US-ASCII/LC_COLLATE
   82188 /usr/share/locale/lt_LT.UTF-8/LC_COLLATE
   82288 /usr/share/locale/lv_LV.UTF-8/LC_COLLATE
   97028 /usr/share/locale/nn_NO.UTF-8/LC_COLLATE
   81188 /usr/share/locale/pl_PL.UTF-8/LC_COLLATE
   81588 /usr/share/locale/ro_RO.UTF-8/LC_COLLATE
   36756 /usr/share/locale/ru_RU.UTF-8/LC_COLLATE
   82188 /usr/share/locale/se_NO.UTF-8/LC_COLLATE
   86212 /usr/share/locale/sk_SK.UTF-8/LC_COLLATE
   81188 /usr/share/locale/sl_SI.UTF-8/LC_COLLATE
   36756 /usr/share/locale/sr_RS.UTF-8/LC_COLLATE
   82088 /usr/share/locale/sv_SE.UTF-8/LC_COLLATE
   81188 /usr/share/locale/tr_TR.UTF-8/LC_COLLATE
   36936 /usr/share/locale/uk_UA.UTF-8/LC_COLLATE
 2025088 /usr/share/locale/zh_CN.UTF-8/LC_COLLATE
  324240 /usr/share/locale/zh_TW.UTF-8/LC_COLLATE
 7733136 total
 ```

補足ですが、全てを 1 バイトのバイナリとして扱う `C` ロケール（別名 `POSIX` ロケール）は POSIX で標準化されておりすべての環境で使えますが、文字を Unicode として扱うが特定の言語に依存しない `C.UTF-8` は POSIX（POSIX.1-2024 時点）で標準化されていません。最近の環境であればほとんどの環境で使えるようになっていますが、いくつかの古い環境では使えません。例えば macOS 15.3 には `C.UTF-8` はありませんが、これも 2025 年 3 月の macOS 15.4 で追加されました。他にも Red Hat 系 Liunx では 7 系にはありませんが 2019 年の 8 系で追加されており、FreeBSD では 11 まではありませんが 2018 年の 12 で追加されています。

## 「ジュピター」と「ヴィーナス」の順番

macOS 15.3 では照合順序のデータがなく、日本語などは文字列の長さが短い順番に並びます。そして長さが同じ場合はおそらくバイナリ順です。例えば 3 文字の「マーズ」「ムーン」、4 文字の「ウラヌス」「サターン」「プルート」、6 文字の「ネプチューン」「マーキュリー」はバイナリ順です。しかし 5 文字の「ジュピター」「ヴィーナス」の順番は、逆のように思えます。その理由は Unicode 表を見れば明らかです。以下の表は Unicode のカタカナの範囲を抜き出したものです。

```txt
       	0	1	2	3	4	5	6	7	8	9	A	B	C	D	E	F
U+30A0	゠	ァ	ア	ィ	イ	ゥ	ウ	ェ	エ	ォ	オ	カ	ガ	キ	ギ	ク
U+30B0	グ	ケ	ゲ	コ	ゴ	サ	ザ	シ	ジ	ス	ズ	セ	ゼ	ソ	ゾ	タ
U+30C0	ダ	チ	ヂ	ッ	ツ	ヅ	テ	デ	ト	ド	ナ	ニ	ヌ	ネ	ノ	ハ
U+30D0	バ	パ	ヒ	ビ	ピ	フ	ブ	プ	ヘ	ベ	ペ	ホ	ボ	ポ	マ	ミ
U+30E0	ム	メ	モ	ャ	ヤ	ュ	ユ	ョ	ヨ	ラ	リ	ル	レ	ロ	ヮ	ワ
U+30F0	ヰ	ヱ	ヲ	ン	ヴ	ヵ	ヶ	ヷ	ヸ	ヹ	ヺ	・	ー	ヽ	ヾ	ヿ
```

文字の並びは照合順序で定義されるものですが、ある程度はコードポイントもそれらしい順番で定義されています。一般的にカタカナは「ハ」「バ」「パ」のように清音、濁音、半濁音の順番、小文字は大文字の直前で定義されますが、いくつか例外があり、その一つが「ヴィーナス」の「ヴ」です。本来なら「ウ」の次に「ヴ」が来そうなものですが「ン」の後に定義されています。以下の変換規則により Unicode のコードポイントは UTF-8 で表現しても順番は同じになるのでバイナリ順でソートすると「ジュピター」よりも後に「ヴィーナス」が来てしまいます。照合順序はこのような問題を回避するために必要なわけです。

| コードポイントの範囲 | UTF-8 表現 |
| - | - |
| U+0000 - U+007F     | 0yyyzzzz |
| U+0080 - U+07FF     | 110xxxyy 10yyzzzz |
| U+0800 - U+FFFF     | 1110wwww 10xxxxyy 10yyzzzz |
| U+010000 - U+10FFFF | 11110uvv 10vvwwww 10xxxxyy 10yyzzzz |

補足ですが「ヴ」が変なところにある理由は、JIS X 0208:1978 (JIS C 6228)では、ひらがなの「ゔ」が存在しなかったためだと考えられます。ひらがなとカタカナの順番を完全に同じにするために（順番が同じだとひらがなとカタカナの変換の計算が簡単）、カタカナにしかない「ヴ」は例外の文字として後ろに追加されたのでしょう。その順番が Unicode にそのまま採用されています。

ちなみにカタカナはこれで全てというわけではなく、Unicode ではアイヌ語を表記するための追加の[片仮名拡張](https://ja.wikipedia.org/wiki/%E7%89%87%E4%BB%AE%E5%90%8D%E6%8B%A1%E5%BC%B5)（小文字のカタカナ）や、Unicode 12.0 で追加された小文字の「ヰ」「ヱ」「ヲ」「ン」などがあります（下記参照）。Unicode の改訂で文字が追加されるという事実は、文字の順番も環境ごとに微妙に異なっており、OS のアップデートなどで変化し続けることを意味しています。

https://www.publickey1.jp/blog/19/unicode_120121.html

```txt:注意: 実際には全て小文字です（フォントがない可能性があるため大文字で書いています）
		0	1	2	3	4	5	6	7	8	9	A	B	C	D	E	F
U+31F0	ク	シ	ス	ト	ヌ	ハ	ヒ	フ	ヘ	ホ	ム	ラ	リ	ル	レ	ロ
U+1B160					ヰ	ヱ	ヲ	ン
```

かつては「すべてのカタカナ」を表す正規表現に `[ァ-ヶー]` が使われたこともありますが、カタカナに該当する文字は追加され、分散されて配置されている Unicode ではその常識は通用しません。カタカナにマッチさせるのに一番簡単な方法は、おそらく `\p{Katakana}` を使うことですが、GNU grep (`ggrep`) など使えるコマンドは限られます。

```console:macOS 15.3での GNU grep (ggrep) の実行結果
$ echo ガヺㇰ漢 | LC_ALL=ja_JP.UTF-8 ggrep -o '[ァ-ヶー]' | xargs
ガ

$ echo ガヺㇰ漢 | LC_ALL=ja_JP.UTF-8 ggrep -o -P '\p{Katakana}' | xargs
ガ ヺ ㇰ

補足: grep では文字の範囲の判定もおかしい（macOS 15.4 では修正されている）
$ echo ガヺㇰ漢 | LC_ALL=ja_JP.UTF-8 grep -o '[ァ-ヶー]' | xargs
ガ ヺ ㇰ 漢
```

## gsort（GNU版）でも同じ

macOS 15.3 までの `sort` コマンドのソート順の問題は `sort` コマンド自身ではなく macOS が持っている照合順序データの問題です。そのため Homebrew からインストールした `gsort` (GNU sort) コマンドでも発生します。もちろんその他のコマンドでも `sort` コマンドと同様の方法を使っている場合には同じような問題が発生するでしょう。Unix / Linux 系由来の多くのプログラム（例えば MySQL）に影響します。

```console:macOS 15.3での実行結果
$ gsort data.txt
マーズ
ジュピター
マーキュリー
```

macOS 版のコマンドは（全てではありませんが）FreeBSD 版のコマンドを（独自の修正を加えて）流用しているというのは有名な話です。`sort` コマンドも例外ではなく、FreeBSD 版に手を加えて使用しています。FreeBSD では以前は GNU 版の `sort` コマンドを採用しており、現在は独自の FreeBSD 版に変更されていますが、おそらく互換性を維持するために、GNU コマンド特有のロングオプションや `--help`、`--version` が実装されています。その出力より、macOS 版の `sort` コマンドは FreeBSD 版の `sort` コマンドがベースになっていることがわかります。

```console:FreeBSD版での出力
$ sort --help
Usage: sort [-bcCdfigMmnrsuz] [-kPOS1[,POS2] ... ] [+POS1 [-POS2]] [-S memsize]
[-T tmpdir] [-t separator] [-o outfile] [--batch-size size] [--files0-from file]
[--heapsort] [--mergesort] [--radixsort] [--qsort] [--mmap]
[--human-numeric-sort] [--version-sort] [--random-sort [--random-source file]]
[--compress-program program] [file ...]

$ sort --version
2.3-FreeBSD
```

```console:macOS版での出力（ほぼ同じ）
$ sort --help
Usage: sort [-bcCdfigMmnrsuz] [-kPOS1[,POS2] ... ] [+POS1 [-POS2]] [-S memsize]
[-T tmpdir] [-t separator] [-o outfile] [--batch-size size] [--files0-from file]
[--heapsort] [--mergesort] [--radixsort] [--qsort] [--mmap] [--parallel thread_no]
[--human-numeric-sort] [--version-sort] [--random-sort [--random-source file]]
[--compress-program program] [file ...]

$ sort --version
2.3-Apple (190.0.1)
```

<sub>補足: macOS では `--parallel` オプションが追加されていますが現時点では機能していません。FreeBSD 版では、ソースコード上にオプション自体は定義されていますが未実装であるため無効になっています。このオプションは元は GNU sort で実装されていたオプションで、おそらく macOS は機能しなくとも互換性のために使えたほうが良いと考えたのでしょう。</sub>

それでは、FreeBSD でも macOS と同じようなソートの問題があるのかと言うと、それはありません。`sort` コマンドの実装は（ほぼ）同じですが、2021年の FreeBSD 11 以降では照合順序データはちゃんと定義されているからです。

```console:FreeBSD 11の照合順序のデータは定義されている
$ wc -c /usr/share/locale/ja_JP.UTF-8/LC_COLLATE
  538904 /usr/share/locale/ja_JP.UTF-8/LC_COLLATE
```

ただし FreeBSD 10 ではファイルサイズが 4KB 程度しかなく照合順序データはないようです。しかし macOS とは文字の順番が異なることから、おそらく照合順序データのない文字は同じ文字と扱われることはなく、単にバイナリ順でソートされているようです。以前の macOS よりはマシな仕様と言えます。

```console:FreeBSD 10の照合順序のデータは定義されていない
$ wc -c /usr/share/locale/ja_JP.UTF-8/LC_COLLATE
    4642 /usr/share/locale/ja_JP.UTF-8/LC_COLLATE

$ LC_ALL=ja_JP.UTF=8 sort data.txt
ウラヌス
サターン
ジュピター
ネプチューン
プルート
マーキュリー
マーズ
ムーン
ヴィーナス
```

## `wcscoll` 関数

おかしな順番でソートする問題は、macOS 版の `sort` コマンドが内部で使用している C 言語の `wcscoll` 関数（文字列のロケールを考慮した大小の比較）で発生しています。ソースコードは GitHub で公開されているので、これを参考に C 言語のソースコードレベルで動作を確認してみましょう。

1. https://opensource.apple.com/releases/
2. https://github.com/apple-oss-distributions/text_cmds/tree/main/sort

`sort` コマンドは 2 の text_cmds リポジトリに含まれています。1 のリンクより macOS 15.3 では text_cmds-190.0.1 が使われいます。macOS 15.4 (15.5) では text_cmds-195 が使われていますが、シグナル周りの修正だけなのでソート順に違いはないはずです。`wcscoll` 関数は `bwstring.c` ファイルで定義された [wide_str_coll](https://github.com/apple-oss-distributions/text_cmds/blob/text_cmds-190.0.1/sort/bwstring.c#L114) 関数で使われており、`wide_str_coll` 関数は [bwscoll](https://github.com/apple-oss-distributions/text_cmds/blob/text_cmds-190.0.1/sort/bwstring.c#L813) 関数から呼び出されています。このリポジトリのコードの正式なコンパイル方法は知らないのですが（誰か教えてください）、とりあえず以下の方法でコンパイルできました。これで実際にどのコードが呼び出されていることを確かめられるでしょう。

```console
$ cc bwstring.c coll.c file.c mem.c radixsort.c sort.c vsort.c
```

文字列の比較に `wcscoll` 関数が使われると分かったので、次のような検証プログラムで `sort` コマンドと同様の処理の動作確認してみます。

```c:wcscoll_test.c
#include <stdio.h>
#include <stdlib.h>
#include <locale.h>
#include <wchar.h>

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "Usage: %s string1 string2\n", argv[0]);
        return 1;
    }

    // 環境のロケールを設定
    setlocale(LC_ALL, "");

    // マルチバイト文字列をワイド文字列に変換（エラーチェックは省略）
    wchar_t wstr1[1024], wstr2[1024];
    mbstowcs(wstr1, argv[1], sizeof(wstr1) / sizeof(wchar_t));
    mbstowcs(wstr2, argv[2], sizeof(wstr2) / sizeof(wchar_t));

    int result = wcscoll(wstr1, wstr2);
    if (result < 0) {
        printf("%s < %s (%d)\n", argv[1], argv[2], result);
    } else if (result > 0) {
        printf("%s > %s (%d)\n", argv[1], argv[2], result);
    } else {
        printf("%s = %s (%d)\n", argv[1], argv[2], result);
    }

    return 0;
}
```

この検証プログラムは、2 つの引数の文字列を比較し大小を出力します。カッコ内の値は `wcscoll` 関数の戻り値で、通常は 0 または正負のどちらかで判断するものなので、値に具体的な意味を考えるものではありません（とは言うものの何の値なんでしょうね？）。以下は検証プログラムと `sort` コマンドの `--debug` オプションの出力結果です。

```console
$ ./a.out マーズ ヴィーナス
マーズ < ヴィーナス (-12490)

$ printf '%s\n' マーズ ヴィーナス | sort --debug
Memory to be used for sorting: 8589934592
Number of CPUs: 4
Using collate rules of ja_JP.UTF-8 locale
sort_method=mergesort
; k1=<マーズ>(3), k2=<ヴィーナス>(5); s1=<マーズ>, s2=<ヴィーナス>; cmp1=-12490
マーズ
ヴィーナス
```

上記の出力から、`k1=<マーズ>(3)` の 3、`k2=<ヴィーナス>(5)` の 5 が 文字数であり、`cmp1=-12490` が `wcscoll` 関数の戻り値であることがわかります。この結果から `sort` コマンド自体の内部の処理はあまり関係なく、文字列の比較自体に問題があると推測できます。ちなみに `bwscoll` 関数をよく読むと、`sort` コマンドは、C (POSIX) ロケールの場合には [memcmp](https://github.com/apple-oss-distributions/text_cmds/blob/text_cmds-190.0.1/sort/bwstring.c#L717) 関数でバイナリで比較しており、US-ASCII や ISO8859 関係などのシングルバイト文字のロケール（`MB_CUR_MAX` が 1 の場合）では [strcoll](https://github.com/apple-oss-distributions/text_cmds/blob/text_cmds-190.0.1/sort/bwstring.c#L749) 関数を使って照合順序で比較していることがわかります。

- C (POSIX) ロケール ・・・ `memcmp` 関数（バイナリ順）
- シングルバイト文字列 ・・・ `strcoll` 関数（照合順序順）
- マルチバイト文字列 ・・・ `wcscoll` 関数（照合順序順）※ ワイド文字に変換して比較

本来 `strcoll` 関数は（シングルバイトを含む）可変長のマルチバイト文字列を比較する関数なので、マルチバイト文字列である UTF-8 文字列は `strcoll` 関数でも比較できます。結果は同じになるはずです。おそらくパフォーマンスの上の理由で、不要な場合にワイド文字列への変換を避けるためにこのような実装になっていると思われます。以下は `strcoll` 関数を使用した場合のサンプルコードです。

```c:strcoll_test.c
#include <stdio.h>
#include <string.h>
#include <locale.h>

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "Usage: %s string1 string2\n", argv[0]);
        return 1;
    }

    // 環境のロケールを設定
    setlocale(LC_ALL, "");

    int result = strcoll(argv[1], argv[2]);
    if (result < 0) {
      printf("%s < %s (%d)\n", argv[1], argv[2], result);
    } else if (result > 0) {
      printf("%s > %s (%d)\n", argv[1], argv[2], result);
    } else {
      printf("%s = %s (%d)\n", argv[1], argv[2], result);
    }

    return 0;
}
```

なお、`strcoll` 関数も `wcscoll` 関数も POSIX で標準化されている関数なので、POSIX に準拠した環境ではどちらも使えるはずです。言い換えると移植性が高い Unix プログラムはロケールに影響される文字列の比較に、このどちらかを使っているということです。

- https://pubs.opengroup.org/onlinepubs/9799919799/functions/strcoll.html
- https://pubs.opengroup.org/onlinepubs/9799919799/functions/wcscoll.html

## じゃあなんで Finder は問題ないの？

照合順序は OS が持っているデータです。それならば OS 自体もそのデータを使っていると考えるのが自然です。しかし macOS 15.3 でも Finder のファイル一覧ではこのような問題は発生しません。その理由は、おそらく Finder は Unix 標準とは異なる macOS 独自の Foundationフレームワークが提供する比較関数 `localizedStandardCompare` を利用してソートしているからです。Finder と同等のソートを Swift で実装した場合は次のようになります。

```swift:sort_test.swift
import Foundation

if CommandLine.arguments.count <= 1 {
    print("File path required.")
    exit(1)
}

let filePath = CommandLine.arguments[1]

do {
    let contents = try String(contentsOfFile: filePath, encoding: .utf8)
    var lines = contents.components(separatedBy: .newlines)
    if lines.last == "" { lines.removeLast() }

    let sortedLines = lines.sorted {
        ($0 as NSString).localizedStandardCompare($1) == .orderedAscending
    }

    for line in sortedLines {
        print(line)
    }
} catch {
    print("File read error: \(error)")
    exit(1)
}
```

```console
$ swiftc sort_test.swift
$ ./sort_test data.txt
ヴィーナス
ジュピター
マーキュリー
マーズ
ムーン
```

実はこのソートは Finder と同様に数字を数値の小さい順に並べるため、照合順序によるソートとは仕様が異なります。

```console
$ ./sort_test num.txt
1
2
10
20
100
200
```

Windows 標準アプリが Windows 標準の API を基本的に使用するように、macOS でも標準アプリは Unix 互換ではなく macOS 標準の API を使います。ただし Unix 由来の CLI コマンドなどは FreeBSD 版を流用したほうが開発コストは抑えられます。macOS としては Unix の関連の機能はソフトウェア開発者のための機能であり、Unix プログラムに対してのロケール対応は優先順位が低かったのでしょう。

## ひらがなとカタカナの順番

macOS 15.4 や FreeBSD ではひらがなとカタカナの順番も JIS X 4061 準拠の順番になっています。この並びはひらがなとカタカナを区別せず、ひらがなはカタカナよりも前に来ます。

```console:macOS 15.5、FreeBSD 14
$ printf "%s\n" あ い ア イ ゔ ヴ え エ | LC_ALL=ja_JP.UTF-8 sort | xargs
あ ア い イ ゔ ヴ え エ
```

macOS 15.3 などバイナリ順でソートされる場合は次のような結果になります。

```console:macOS 15.3（長さが同じなのでバイナリ順）、NetBSD、OpenBSD
$ printf "%s\n" あ い ア イ ゔ ヴ え エ | LC_ALL=ja_JP.UTF-8 sort | xargs
あ い え ゔ ア イ エ ヴ
```

この記事のメインは macOS ですが、Linux や Solaris 11 でもおかしいところがあります。まず Linux や Solaris 11 ではひらがなとカタカナは区別され、すべてのひらがなはカタカナよりも前に並ぶようです。そして照合順序が定義されていないからなのか、ひらがなの「ゔ」はLinux では前の方に並び Solaris 11では後ろの方に並んでいます。

```console:Linux (Ubuntu 24.04、AlmaLinux 9.5)
$ printf "%s\n" あ い ア イ ゔ ヴ え エ | LC_ALL=ja_JP.UTF-8 sort | xargs
ゔ あ い え ア イ エ ヴ
```

```console:Solaris 11
$ printf "%s\n" あ い ア イ ゔ ヴ え エ | LC_ALL=ja_JP.UTF-8 sort | xargs
あ い え ア イ エ ヴ ゔ
```

参考として、ひらがなとカタカナのコードポイントを以下に示します。

```txt
       	0	1	2	3	4	5	6	7	8	9	A	B	C	D	E	F
U+3040		ぁ	あ	ぃ	い	ぅ	う	ぇ	え	ぉ	お	か	が	き	ぎ	く
U+3050	ぐ	け	げ	こ	ご	さ	ざ	し	じ	す	ず	せ	ぜ	そ	ぞ	た
U+3060	だ	ち	ぢ	っ	つ	づ	て	で	と	ど	な	に	ぬ	ね	の	は
U+3070	ば	ぱ	ひ	び	ぴ	ふ	ぶ	ぷ	へ	べ	ぺ	ほ	ぼ	ぽ	ま	み
U+3080	む	め	も	ゃ	や	ゅ	ゆ	ょ	よ	ら	り	る	れ	ろ	ゎ	わ
U+3090	ゐ	ゑ	を	ん	ゔ	ゕ	ゖ			゙	゚	゛	゜	ゝ	ゞ	ゟ

U+30A0	゠	ァ	ア	ィ	イ	ゥ	ウ	ェ	エ	ォ	オ	カ	ガ	キ	ギ	ク
U+30B0	グ	ケ	ゲ	コ	ゴ	サ	ザ	シ	ジ	ス	ズ	セ	ゼ	ソ	ゾ	タ
U+30C0	ダ	チ	ヂ	ッ	ツ	ヅ	テ	デ	ト	ド	ナ	ニ	ヌ	ネ	ノ	ハ
U+30D0	バ	パ	ヒ	ビ	ピ	フ	ブ	プ	ヘ	ベ	ペ	ホ	ボ	ポ	マ	ミ
U+30E0	ム	メ	モ	ャ	ヤ	ュ	ユ	ョ	ヨ	ラ	リ	ル	レ	ロ	ヮ	ワ
U+30F0	ヰ	ヱ	ヲ	ン	ヴ	ヵ	ヶ	ヷ	ヸ	ヹ	ヺ	・	ー	ヽ	ヾ	ヿ
```

## 補足1: NFD問題とファイル名

Finder でファイル名一覧のソートの話をしたので、ついでに CLI コマンドのファイル名一覧のソートの話をしましょう。macOS のファイル名は NFD 問題と UTF-8-MAC 問題の 2 つの問題があります。これから説明しますがこの 2 つは別の問題です。

まず NFD 問題です。これは macOS で特に問題になりますが、NFD 自体は Unicode の仕様です。NFD に関連する仕様の 1 つは濁音です。濁音は Unicode で 2 つの方法で表現できます。**例えば「ヴ」を 濁点付きの 1 文字で表現するか「ウ」「゛」の 2 文字で表現するか**です。フォントにもよりますが、フォントが理想的な仕様で処理していればこの 2 つは見た目では区別できません。NFD に関連するもう一つの仕様は互換文字の存在です。**例えば「羽」(U+7FBD)という文字には互換性がある文字として「羽」(U+FA1E) があります**。

NFD は Unicode が定義している正規化のルールの 1 つで、先程の濁音を 2 文字で表現し、互換文字を通常の文字に変換します。ここでは省略しますが、NFD の他に NFC、NFKC、NFKD と呼ばれるルールもあります。macOS で NFD が問題になる理由は、Finder や macOS 標準のアプリ（テキストエディットなど）ではファイルやディレクトリの作成時に、ファイル名に対して NFD による正規化が行われるからです。試しに「羽」という名前で Finder からフォルダを作成してみてください。おそらく「羽」という名前に変換されるはずです。見た目にはわかりませんが濁音も 2 文字に変換されます。**すべてのプログラムが NFD による正規化を行うわけではない**ことに注意してください。Unix 由来の CLI コマンド（`touch` コマンドや `mkdir` コマンドなど）や、Windows や Linux などでは入力したままのファイル名が使われます。

UTF-8-MAC は、2017 年の macOS 10.13 より前のバージョンで標準的に使用していたファイルシステム「HFS+」で内部的に行われる、macOS 独自の文字の正規化のルールです。macOS 版の `iconv` コマンドで文字コードの 1 つとして利用可能ですが、実際には文字コードではなく Unicode の枠内で行われる正規化のルールです。ファイルシステムの仕様なので、現在の macOS でも標準の「APFS」ではなく「HFS+」を使うと同じ正規化が行われます。時々 UTF-8-MAC を NFD を混同している例が見られますが、UTF-8-MAC は NFD の濁音に関する正規化に相当する変換だけを行い互換文字の正規化は行いません。ファイルシステムレベルで行われるため HFS+上に作成したファイルやディレクトリは、Unix 由来の CLI コマンドも含めて全て UTF-8-MAC 正規化が行われます。

さて、この NFD と UTF-8-MAC ですが、ファイル名を `sort` コマンドでソートするようなときに関連してきます。Finder などから作成した場合は、濁音は 2 文字で記録されますが、CLI では入力した文字のまま（通常は 1 文字）で記録されます。ただしファイルシステムが HFS+ の場合は必ず 2 文字で記録されます。`sort` コマンドは記録された文字をそのまま返すため、ファイルシステムに記録されている文字によってソート順が変わってしまうことがあります。つまり照合順序ではなくバイナリ順でソートした場合に、**「ジュピター」「ヴィーナス」と「シ゛ュヒ゜ター」「ウ゛ィーナス」とで順番が異なる**ということです。

```console:mkdirやtouchコマンドなどで作成した場合（結合された文字で記録）
$ ls | sort
ジュピター
ヴィーナス
```

```console:Finderやテキストエディットなどで作成した場合（分解された文字で記録）
$ ls | sort
ヴィーナス
ジュピター
```

これは内部的に勝手に NFD または UTF-8-MAC への正規化が行われたり行われなかったりする macOS でよく問題になりますが、実際には Windows や Linux などでも発生します。ようは結合された文字で記録されているか分解された文字で記録されているかだからです。NFD 問題と UTF-8-MAC 問題に関してはもう少し説明することがあるので、別の記事を~~書く予定でいます~~[書きました](https://qiita.com/ko1nksm/items/3a66197efd1c096a801f)。

ちなみに、もう一つのファイル名に関する注意点として、POSIX では [sort](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/sort.html
) コマンドをパス名に使用する場合は、`LC_ALL` （または `LC_CTYPE` と `LC_COLLATE`）を `C` に変更することを推奨しています。理由はパス名に一部のロケールで有効な文字として機能しないバイトシーケンスが含まれる場合に、コマンドの動作が未定義となるためとのことです。UTF-8 のみを使用するようにしていれば問題ないのではないかと思いますが、POSIX ではこのように書かれています。

> When using sort to process pathnames, it is recommended that LC_ALL, or at least LC_CTYPE and LC_COLLATE, are set to POSIX or C in the environment, since pathnames can contain byte sequences that do not form valid characters in some locales, in which case the utility's behavior would be undefined. In the POSIX locale each byte is a valid single-byte character, and therefore this problem is avoided.

macOS のファイルシステムは、一般的な Unix のファイルシステムとは異なり大文字と小文字を区別しません。また HFS+ が内部で行う UTF-8-MAC への正規化も Unix のファイルシステムでは行われません。このような仕様では POSIX 準拠とは言えず UNIX と認定されないはずですが、おそらく認定テストのときだけ認定テストに通る環境でテストしているのでしょう。

## 補足2: macOS 15.4で導入された別のバグ

macOS 15.4 では `sort` コマンドのソート順がまともに修正されましたが、ロケール周りに大きな修正を加えたためか、次のようなバグを入れてしまっています。

https://onk.hatenablog.jp/entry/2025/05/22/023048

```console:macOS 15.4、15.5でのバグ
$ LC_ALL=ja_JP.UTF-8 date
#午後                 👈️ なぜか日時ではなく「#午後」と出力される

$ LC_ALL=ja_JP.UTF-8 cal
      4月 2025        👈️ 現在は6月だが2ヶ月ずれている
日 月 火 水 木 金 土
 1  2  3  4  5  6  7  👈️ カレンダーの日付は6月
 8  9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
```

このバグは照合順序ではなく、日本語（と韓国）の日時のロケール情報の問題です。したがって別のロケールに変更することで回避できます。もちろんその場合は日本語では出力できなくなってしまいます。

```console:macOS 15.4、15.5でのバグ
$ LC_ALL=C date
Mon Jun  9 13:10:31 UTC 2025

補足: どうしても日本語で出力したい場合はこのような方法もある
$ LC_ALL=ja_JP.UTF-8 date +'%Y年 %B%e日 %A %X %Z'  
2025年 6月 9日 月曜日 13時09分57秒 UTC

$ LC_TIME=C cal     👈️ LC_TIMEでも良い
     June 2025
Su Mo Tu We Th Fr Sa
 1  2  3  4  5  6  7
 8  9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
```

`date` コマンドはシェルスクリプトでは書式を指定したほうが良いでしょう。POSIX では `date` コマンドのデフォルトの動作は日付と時刻が出力されるとだけ規定されており、デフォルト時の書式は指定されていないので環境依存してしまうからです。

このようなバグがあるのに、UNIX と認定されるのかと思うかもしれませんが、POSIX では C (POSIX) ロケール以外のロケールが定義されておらず、認定テストでは日本語ロケールでの十分なテストは行われていないと考えられます。バグが有っても認定テストで見逃されていれば UNIX と認定されるでしょう。しかし、そもそも [UNIX と認定されているのは macOS 15.0 のみ](https://www.opengroup.org/openbrand/register/) なので、15.4 でバグが導入されていても関係ないのでしょう。もしかしたらバグは次の認定テストを行うであろう次のメジャーバージョン（macOS 16・・・ではなくmacOS 26となる噂）のリリースまでに直せば十分だと考えているかもしれません。

## さいごに

文字の順番を含むロケールは環境依存しやすいものの 1 つです。Windows も macOS も古くから Unicode を標準の文字コードとして使用しており、絵文字やさまざまな言語の文字を扱えます（Windows の内部文字コードは UTF-16、macOS はカーネルなどは UTF-8 ですが Cocoa フレームワークなどは UTF-16 です）。しかし Unix 的な部分や CLI コマンドは完全な対応が行われているわけではありません。これは macOS だけではなく Linux などでもそうです。

```txt:現時点での結果
# Linux、NetBSD、OpenBSDでは文字化けする
$ echo あいうえお | LC_ALL=ja_JP.UTF-8 tr あいう アイウ
アイウより

# macOS、FreeBSD、Solaris 11 では問題ない
$ echo あいうえお | LC_ALL=ja_JP.UTF-8 tr あいう アイウ
アイウえお
```

もちろん Windows や macOS の GUI アプリなども Unicode 対応が完璧だというわけではありません。最近はかなり改善されていますが、例えば一部の Unicode 文字が表示されない問題や、macOS の Finder から作成した zip 形式が UTF-8 ファイル名の仕様を満たしていない（そのせいで Windows 11 で文字化けする）という問題も残っています。そもそも Unicode は毎年バージョンアップしており、そのたびに数百から数千の文字が追加されているので、Unicode への対応は終わることがありません。[CLDR の最新のデータベースのバージョンは 47](https://cldr.unicode.org/index/downloads) のようです。macOS の `sort` コマンドのソート順が日本語ロケールでおかしい問題はようやく修正されました。世界中の文字を扱える作業は今も現在進行系で行われています。