はじめに
以前の macOS は sort
コマンドで日本語を含む Unicode 文字を正しくソートできませんでした。また grep
コマンドでも正規表現の文字の範囲に正しくマッチできないという問題もありました。この記事ではその問題と理由を明らかにし、その原因がどこにあるかを確認します。なおこの問題は 2025年3月31日にリリースされた macOS 15.4 で修正されたようで(ただし別のバグがあります。補足2参照)、この記事に明らかにしている問題は macOS 15.3 以前の話です。
この記事は元々「macOS の sort コマンドのソート順はデタラメだ!」というタイトルだったのですが、書いている途中で macOS 15.4 で修正されてることに気づき、没にしようかと思ったのですが、もったいないので内容を書き換えた記事となります。記事公開時点ですでに少し古い情報ですが、macOS 14 (14.7.6) では問題が発生しサポート期間を考えれば記事の内容はまだ数年は有効でしょう。
問題の確認
まず macOS 15.3 以前でどのような問題が発生していたのかを確認してみましょう。例えば「マーズ」「マーキュリー」「ジュピター」の 3 行の文字列が含まれたファイルを sort
コマンドでソートしてみてください。次のように「マーズ」「ジュピター」「マーキュリー」の順番になるはずです。
$ sort data.txt
マーズ
ジュピター
マーキュリー
「マーズ」と「マーキュリー」はどちらも「マー」で始まるわけですから、「マーズ」と「マーキュリー」は隣り合わせになるはずです。なのに、なぜ「ジュピター」は間に割り込んでくるのでしょうか?
項目を追加し、内部太陽系惑星1をすべてをソートすると次のようになります。
$ sort data.txt
マーズ
ムーン
ジュピター
ヴィーナス
マーキュリー
さらに項目を追加し、外部太陽系惑星2を追加してソートすると次のようになります。
$ sort data.txt
マーズ
ムーン
ウラヌス
サターン
プルート
ジュピター
ヴィーナス
ネプチューン
マーキュリー
追加された「ウラヌス」「サターン」「プルート」「ネプチューン」だけを見れば順番は正しいようです。しかし「ネプチューン」は1つ離れて「マーキュリー」の近くにあります。「ネプチューン」と「マーキュリー」は一体何が似ているというのでしょうか?
もうおわかりですね? これは文字列の長さが短い順に並んでいるのです。もう少し正確に言うと、(おそらく)日本語文字などは同一文字として扱われ、同じ長さの文字列は同じ順番となり、その場合はバイナリ順で比較されるという仕様だと思われます。この問題はいくつかのページで言及されています。
理由についても書かれており、文字の照合順序がおかしいからです。
文字の並びを決める照合順序 (Collation)
1969 年に Unix が誕生するよりも前、1950 年代から 1960 年代は当時のコンピュータの都合により 1 バイトの文字コード順でソートされていました。例えば ASCII コードでは全ての大文字はすべての小文字よりも小さくなります(Z < a)。しかし英語の辞書では大文字と小文字を区別せずに並べるのが普通で、この並びは一般的に「辞書順」と呼ばれています。コンピュータが英数字といくつかの記号しか使えなかった時代から、コンピュータが扱いやすい順番と、人に優しい順番は違うものでした。
1970 年代から 1980 年代にかけてコンピュータの国際化対応が行われ始め、さまざまな国の言語に対応するようになりました。このときに文字の順番は世界で一律に決まるようなものではなく、その国の文化の影響を受けることが広く認識されました。例えば(一般的に)英語では「a」と「b」の間に「æ」が来ますが、多くの日本人にとってはこの順番は馴染みの薄い並びでしょう。ちなみに日本語の文字の順番は JIS X 4061 で定義されています。
$ 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 (GitHub リポジトリ)で定義されていますが、実際の環境や実装がこの通りに従っているかというと、それは別の話です。
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 です。
$ 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
$ wc -c /usr/share/locale/ja_JP.UTF-8/LC_COLLATE
538904 /usr/share/locale/ja_JP.UTF-8/LC_COLLATE
$ 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
と大差ない大きさのファイルへのシンボリックリンクとなっていました。これらは一部の文字の順番に違いがあると想定されますが大部分の文字の順番は定義されていないと推測できます。
$ 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 では、多くの言語ごとに別の内容に変更されています。
$ 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 のカタカナの範囲を抜き出したものです。
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 ではアイヌ語を表記するための追加の片仮名拡張(小文字のカタカナ)や、Unicode 12.0 で追加された小文字の「ヰ」「ヱ」「ヲ」「ン」などがあります(下記参照)。Unicode の改訂で文字が追加されるという事実は、文字の順番も環境ごとに微妙に異なっており、OS のアップデートなどで変化し続けることを意味しています。
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
) など使えるコマンドは限られます。
$ 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)に影響します。
$ gsort data.txt
マーズ
ジュピター
マーキュリー
macOS 版のコマンドは(全てではありませんが)FreeBSD 版のコマンドを(独自の修正を加えて)流用しているというのは有名な話です。sort
コマンドも例外ではなく、FreeBSD 版に手を加えて使用しています。FreeBSD では以前は GNU 版の sort
コマンドを採用しており、現在は独自の FreeBSD 版に変更されていますが、おそらく互換性を維持するために、GNU コマンド特有のロングオプションや --help
、--version
が実装されています。その出力より、macOS 版の sort
コマンドは FreeBSD 版の sort
コマンドがベースになっていることがわかります。
$ 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
$ 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)
補足: macOS では --parallel
オプションが追加されていますが現時点では機能していません。FreeBSD 版では、ソースコード上にオプション自体は定義されていますが未実装であるため無効になっています。このオプションは元は GNU sort で実装されていたオプションで、おそらく macOS は機能しなくとも互換性のために使えたほうが良いと考えたのでしょう。
それでは、FreeBSD でも macOS と同じようなソートの問題があるのかと言うと、それはありません。sort
コマンドの実装は(ほぼ)同じですが、2021年の 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 よりはマシな仕様と言えます。
$ 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 言語のソースコードレベルで動作を確認してみましょう。
- https://opensource.apple.com/releases/
- 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 関数で使われており、wide_str_coll
関数は bwscoll 関数から呼び出されています。このリポジトリのコードの正式なコンパイル方法は知らないのですが(誰か教えてください)、とりあえず以下の方法でコンパイルできました。これで実際にどのコードが呼び出されていることを確かめられるでしょう。
$ cc bwstring.c coll.c file.c mem.c radixsort.c sort.c vsort.c
文字列の比較に wcscoll
関数が使われると分かったので、次のような検証プログラムで sort
コマンドと同様の処理の動作確認してみます。
#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
オプションの出力結果です。
$ ./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 関数でバイナリで比較しており、US-ASCII や ISO8859 関係などのシングルバイト文字のロケール(MB_CUR_MAX
が 1 の場合)では strcoll 関数を使って照合順序で比較していることがわかります。
- C (POSIX) ロケール ・・・
memcmp
関数(バイナリ順) - シングルバイト文字列 ・・・
strcoll
関数(照合順序順) - マルチバイト文字列 ・・・
wcscoll
関数(照合順序順)※ ワイド文字に変換して比較
本来 strcoll
関数は(シングルバイトを含む)可変長のマルチバイト文字列を比較する関数なので、マルチバイト文字列である UTF-8 文字列は strcoll
関数でも比較できます。結果は同じになるはずです。おそらくパフォーマンスの上の理由で、不要な場合にワイド文字列への変換を避けるためにこのような実装になっていると思われます。以下は strcoll
関数を使用した場合のサンプルコードです。
#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 で実装した場合は次のようになります。
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)
}
$ swiftc sort_test.swift
$ ./sort_test data.txt
ヴィーナス
ジュピター
マーキュリー
マーズ
ムーン
実はこのソートは Finder と同様に数字を数値の小さい順に並べるため、照合順序によるソートとは仕様が異なります。
$ ./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 準拠の順番になっています。この並びはひらがなとカタカナを区別せず、ひらがなはカタカナよりも前に来ます。
$ printf "%s\n" あ い ア イ ゔ ヴ え エ | LC_ALL=ja_JP.UTF-8 sort | xargs
あ ア い イ ゔ ヴ え エ
macOS 15.3 などバイナリ順でソートされる場合は次のような結果になります。
$ printf "%s\n" あ い ア イ ゔ ヴ え エ | LC_ALL=ja_JP.UTF-8 sort | xargs
あ い え ゔ ア イ エ ヴ
この記事のメインは macOS ですが、Linux や Solaris 11 でもおかしいところがあります。まず Linux や Solaris 11 ではひらがなとカタカナは区別され、すべてのひらがなはカタカナよりも前に並ぶようです。そして照合順序が定義されていないからなのか、ひらがなの「ゔ」はLinux では前の方に並び Solaris 11では後ろの方に並んでいます。
$ printf "%s\n" あ い ア イ ゔ ヴ え エ | LC_ALL=ja_JP.UTF-8 sort | xargs
ゔ あ い え ア イ エ ヴ
$ printf "%s\n" あ い ア イ ゔ ヴ え エ | LC_ALL=ja_JP.UTF-8 sort | xargs
あ い え ア イ エ ヴ ゔ
参考として、ひらがなとカタカナのコードポイントを以下に示します。
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
コマンドは記録された文字をそのまま返すため、ファイルシステムに記録されている文字によってソート順が変わってしまうことがあります。つまり照合順序ではなくバイナリ順でソートした場合に、「ジュピター」「ヴィーナス」と「シ゛ュヒ゜ター」「ウ゛ィーナス」とで順番が異なるということです。
$ ls | sort
ジュピター
ヴィーナス
$ ls | sort
ヴィーナス
ジュピター
これは内部的に勝手に NFD または UTF-8-MAC への正規化が行われたり行われなかったりする macOS でよく問題になりますが、実際には Windows や Linux などでも発生します。ようは結合された文字で記録されているか分解された文字で記録されているかだからです。NFD 問題と UTF-8-MAC 問題に関してはもう少し説明することがあるので、別の記事を書く予定でいます。
ちなみに、もう一つのファイル名に関する注意点として、POSIX では sort コマンドをパス名に使用する場合は、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
コマンドのソート順がまともに修正されましたが、ロケール周りに大きな修正を加えたためか、次のようなバグを入れてしまっています。
$ 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
このバグは照合順序ではなく、日本語(と韓国)の日時のロケール情報の問題です。したがって別のロケールに変更することで回避できます。もちろんその場合は日本語では出力できなくなってしまいます。
$ 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 のみ なので、15.4 でバグが導入されていても関係ないのでしょう。もしかしたらバグは次の認定テストを行うであろう次のメジャーバージョン(macOS 16・・・ではなくmacOS 26となる噂)のリリースまでに直せば十分だと考えているかもしれません。
さいごに
文字の順番を含むロケールは環境依存しやすいものの 1 つです。Windows も macOS も古くから Unicode を標準の文字コードとして使用しており、絵文字やさまざまな言語の文字を扱えます(Windows の内部文字コードは UTF-16、macOS はカーネルなどは UTF-8 ですが Cocoa フレームワークなどは UTF-16 です)。しかし Unix 的な部分や CLI コマンドは完全な対応が行われているわけではありません。これは macOS だけではなく Linux などでもそうです。
# 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 のようです。macOS の sort
コマンドのソート順が日本語ロケールでおかしい問題はようやく修正されました。世界中の文字を扱える作業は今も現在進行系で行われています。