C#の文字列比較で結果が変わる原因と対処|==・Equals・string.Compareの違い、StringComparisonの選び方、ToUpperのトルコ語問題と日本語環境でひらがなとカタカナが一致する落とし穴(.NET 10)

オフィスのモニターに並んだ2つの画面を、ななみが確認している

こんにちは、おうどんです🍜

おうどん

ななみさん、メニュー名の重複チェックを作ったよ。SortedSet に入れて数えるだけ。うどんとウドンを入れたら、ちゃんと1件にまとめてくれた

ななみ

それ、ひらがなのメニューとカタカナのメニューで、別の商品ですよね

(会話は説明用に作ったものです。コードの結果は今回の検証で実際に動かしたものです)

まとめてくれた、じゃないんです。
消えたんです。1品。

C# の文字列の比較には、性格のまったく違う係が2人います。

  • 券売機:文字の番号を1つずつ見る。番号が1つでも違えば別物(Ordinal、序数比較)
  • 女将さん:人間の言葉として読む。読み方が同じなら同じお客さんとみなすこともある(カルチャ依存の比較)

そして C# は、メソッドによって、どちらの係を呼ぶかが黙って違います。
冒頭の SortedSet は、女将さんに受付を任せていました。女将さん、常連の顔を覚えるのが得意すぎる。

この記事では、==・Equals・string.Compare の違いと、StringComparison の選び方、ハマりやすい落とし穴を順番にまとめます。

検証は、今回の検証環境(.NET SDK 10.0.403、ランタイム .NET 10.0.14、Windows、現在のカルチャ ja-JP)で行いました。カルチャ(言語と地域の設定。日本語環境なら ja-JP)や OS で結果が変わる話が多いので、結果はこの環境のものとして読んでください。

まず覚えるのは、この3つだけです

  • 文字列が同じかどうかは、string.Equals(a, b, StringComparison.Ordinal) か OrdinalIgnoreCase で比べる
  • StartsWith・EndsWith・IndexOf・string.Compare は、StringComparison を必ず書く
  • 大文字小文字を無視したいときに、ToUpper() でそろえてから比べない
目次

== と Equals は券売機、string.Compare は女将さん

いきなり答えから言うと、== と、引数が1つの Equals は券売機です。string.Compare は女将さんです。

.NET の文字列比較のベストプラクティスには、StringComparison を受け取らない String.Equals と等価演算子(==)は序数比較が既定、と書いてあります。一方で、StringComparison を受け取らない String.Compare は、現在のカルチャを使う比較が既定です。

並べて動かしてみます。

string a = "Udon";
string b = "udon";

Console.WriteLine(a == b);
Console.WriteLine(a.Equals(b));
Console.WriteLine(string.Equals(a, b, StringComparison.OrdinalIgnoreCase));
Console.WriteLine(a.ToUpper() == b.ToUpper());
Console.WriteLine(string.Compare(a, b));
Console.WriteLine(string.Compare(a, b, StringComparison.Ordinal));
Console.WriteLine(string.Compare(a, b, StringComparison.OrdinalIgnoreCase));
False
False
True
True
1
-32
0

== と Equals は、大文字と小文字が違うので False。券売機は融通がききません。そこが長所です。

気になるのは string.Compare の2行です。
カルチャで比べると 1、序数で比べると -32。符号まで逆になっています。

おうどん

同じ2つの文字列を比べて、大きいのか小さいのか答えが割れるの、会議だったら解散だよ

解散しなくて大丈夫です。比べ方が違うだけです。
券売機は U(番号 85)と u(番号 117)を番号で比べるので、U が先。女将さんは辞書の順番で、小文字を先に並べるので、Udon が後ろになりました。

今回の -32 は、たまたま2つの番号の差と同じ値でした。でも戻り値は、比べ方しだいで 1 にも -32 にもなります。見るのは符号(負・0・正)だけにしておきましょう。

ベストプラクティスには、もう1つ大事な注意があります。

  • 等しいかどうかは String.Equals の多重定義で調べる
  • String.Compare と CompareTo は並べ替えに使い、等しいかどうかの判定に使わない(戻り値が0かどうかで等しさを判定しない)

読者の心の声「string.Compare(a, b) == 0 で一致判定、10年書いてきたんですけど」

その10年分、券売機に並び直してもらいましょう。理由は、このあと上級者向けの章で、ひらがなとカタカナが0になるのを見るとよく分かります。

並べ替えも、黙っていると女将さんが担当する

並べ替えも同じです。Array.Sort に何も渡さないと、女将さんが並べます。

string[] menu = { "udon", "Udon", "UDON", "soba", "_tmp", "10", "9", "co-op", "coop" };

string[] byCulture = (string[])menu.Clone();
Array.Sort(byCulture);
Console.WriteLine("default : " + string.Join(", ", byCulture));

string[] byOrdinal = (string[])menu.Clone();
Array.Sort(byOrdinal, StringComparer.Ordinal);
Console.WriteLine("Ordinal : " + string.Join(", ", byOrdinal));
default : _tmp, 10, 9, co-op, coop, soba, udon, Udon, UDON
Ordinal : 10, 9, UDON, Udon, _tmp, co-op, coop, soba, udon

女将さんは、小文字・頭文字だけ大文字・全部大文字の順に並べました。人間の目には自然です。

おうどん

小文字のお客さんを先に通すのは、声の小さい人への気づかいかな

気づかいではなく、ベストプラクティスの言う言語的な並べ方(英語の辞書のような順番)です。女将さんは、意外と事務的です。
券売機は番号順なので、今回の ASCII 英字では大文字が小文字より先に来て、_ はその間にはさまります。

どちらも 10 が 9 より先なのは同じです。数字も1文字ずつ比べるからです。

新人

10 が 9 より前なら、10 杯目のお客さんを先に通していいですか

番号札の文字の順番で通すと、9 番さんが怒ります。数字の大きさで並べたいときの方法は、上級者向けの章で .NET 10 の新機能を使って紹介します。

画面に出す一覧なら、女将さんの並べ方で困ることは少ないです。
困るのは、並べた結果をあとで二分探索(並んでいる前提で真ん中から探す方法)する、ファイル名で次の処理を決める、のように、機械が順番を前提にするときです。並べる係と探す係が別人だと、探し物は見つかりません。

どれを使う? 用途と StringComparison の対応表

ここまでで、係の性格は分かりました。次は、どの仕事をどちらの係に頼むかです。
ベストプラクティスには、データの種類ごとの対応表があります。要点を日本語にまとめます。

比べるもの使う値
大文字小文字を区別する識別子・セキュリティに関わる設定Ordinal
大文字小文字を区別しない識別子(HTTP のヘッダー名など)・ファイルパス・レジストリ・環境変数OrdinalIgnoreCase
言語として意味があり、カルチャに左右されたくない保存データ・固定の並び順で見せたいものInvariantCulture / InvariantCultureIgnoreCase
画面に出すデータ・ユーザーが入力したもの(並べ替えて見せるなど)CurrentCulture / CurrentCultureIgnoreCase

迷ったら、Ordinal か OrdinalIgnoreCase です。ベストプラクティスにも、カルチャに関係のない一致判定の安全な既定として、この2つを使うように書いてあります。速さの面でも有利、とあります。

新人

じゃあ全部 Ordinal にしておけば、一生迷わないですね

画面の一覧まで券売機に並べさせると、UDON さんが毎回先頭に来ます。今度はお客さんが迷います。ベストプラクティスにも、序数比較は辞書の順番ではなく番号順なので、ユーザーに見せると奇妙に見えることが多い、とあります。

StringComparison の6つの値の意味も並べておきます。

値中身
CurrentCulture現在のカルチャで、大文字小文字を区別
CurrentCultureIgnoreCase現在のカルチャで、大文字小文字を無視
InvariantCultureインバリアントカルチャ(特定の国に寄らない、固定のカルチャ)で、大文字小文字を区別
InvariantCultureIgnoreCaseインバリアントカルチャで、大文字小文字を無視
Ordinal序数比較(文字の番号で比べる)
OrdinalIgnoreCase大文字小文字を無視する序数比較
おうどん

6つもあるなら、全部試して、一番それっぽいのを選べばいいよね

それっぽいのを選ぶと、テストの日だけそれっぽく動きます。用途で選びましょう。

書き方は、こう置き換えます。

既定任せの書き方意図が分かる書き方(一致判定の例)
a == b(大文字小文字を無視したい)string.Equals(a, b, StringComparison.OrdinalIgnoreCase)
s.StartsWith("http")s.StartsWith("http", StringComparison.Ordinal)
s.IndexOf("ERROR")s.IndexOf("ERROR", StringComparison.Ordinal)
s.ToUpper() == "FILE"s.Equals("FILE", StringComparison.OrdinalIgnoreCase)
string.Compare(a, b) == 0string.Equals(a, b, StringComparison.Ordinal)

a == b は、もともと序数比較です。大文字小文字まで区別してよいなら、そのままで問題ありません。それでもベストプラクティスは、StringComparison のある多重定義を呼んで、意図をはっきりさせるようにすすめています。

🔰 ここまで読めば今日から困らない

  • == と Equals(b) は券売機(序数比較)、string.Compare(a, b) は女将さん(カルチャ依存)
  • 同じかどうかは string.Equals(..., StringComparison.Ordinal) または OrdinalIgnoreCase
  • StartsWith・EndsWith・IndexOf・Compare には、StringComparison を書く
  • Compare の戻り値は並べ替え用。0 かどうかで一致判定しない
おうどん

4つも覚えるの? 天かす、ネギ、かまぼこ、わかめ……あ、トッピングと同じ数だ

その覚え方で覚えられるなら、止めません。

おうどん

ここまでで、もう帰っていい?

帰って大丈夫です。お会計は券売機でどうぞ。
ここから先は中級者向けです。読み飛ばしてもOKです。ToUpper の落とし穴、見えない文字、コレクションの既定、ビルドで見つける方法に進みます。

中級:ToUpper で比べると、トルコで扉が開かない

大文字小文字を無視したいとき、両方を ToUpper() してから == で比べる書き方をよく見かけます。
これが、カルチャによっては壊れます。

ベストプラクティスでは、トルコ語(tr-TR)の I の問題として説明されています。ほとんどのラテン文字の言語では、i の大文字は I です。でもトルコ語では、i の大文字は点付きの İ(U+0130)で、点なしの小文字 ı(U+0131)の大文字が I です。アゼルバイジャン語(az)も同じです。

現在のカルチャを切り替えて試します。

using System.Globalization;

Console.OutputEncoding = System.Text.Encoding.UTF8;

string scheme = "file";
foreach (string name in new[] { "ja-JP", "tr-TR" })
{
    CultureInfo.CurrentCulture = new CultureInfo(name);
    Console.WriteLine($"[{name}]");
    Console.WriteLine($"  ToUpper()                 : {scheme.ToUpper()}");
    Console.WriteLine($"  ToUpper() == \"FILE\"       : {scheme.ToUpper() == "FILE"}");
    Console.WriteLine($"  CurrentCultureIgnoreCase  : {scheme.Equals("FILE", StringComparison.CurrentCultureIgnoreCase)}");
    Console.WriteLine($"  OrdinalIgnoreCase         : {scheme.Equals("FILE", StringComparison.OrdinalIgnoreCase)}");
    Console.WriteLine($"  ToUpperInvariant()        : {scheme.ToUpperInvariant()}");
    Console.WriteLine($"  \"FILE\".ToLower()          : {"FILE".ToLower()}");
}
[ja-JP]
  ToUpper()                 : FILE
  ToUpper() == "FILE"       : True
  CurrentCultureIgnoreCase  : True
  OrdinalIgnoreCase         : True
  ToUpperInvariant()        : FILE
  "FILE".ToLower()          : file
[tr-TR]
  ToUpper()                 : FİLE
  ToUpper() == "FILE"       : False
  CurrentCultureIgnoreCase  : False
  OrdinalIgnoreCase         : True
  ToUpperInvariant()        : FILE
  "FILE".ToLower()          : fıle

(Console.OutputEncoding は、İ などを画面に正しく出すための1行です)

トルコ語のカルチャでは、file の大文字が FİLE になり、FILE と一致しません。CurrentCultureIgnoreCase でも False です。
OrdinalIgnoreCase と ToUpperInvariant() は、どちらのカルチャでも同じ結果でした。

おうどん

うちの会社のサーバー、トルコにないから関係ないよ

サーバーがなくても、ユーザーのパソコンはどこにでもあります。デスクトップアプリなら、カルチャはその人の設定です。

ベストプラクティスは、これがセキュリティの穴になる例も挙げています。FILE: で始まる URI を大文字小文字を無視してはじく処理が、トルコ語のシステムでは素通りされうる、というものです。対処はもちろん StartsWith("FILE:", StringComparison.OrdinalIgnoreCase) です。

鍵屋さんが国によって合鍵の形を変えたら、扉は閉まりません。もっと困るのは、閉まらないことに気づかないことです。

ちなみに、比べるためにどうしても大文字か小文字にそろえるなら、ベストプラクティスは ToLowerInvariant ではなく ToUpperInvariant を使うようにすすめています。OrdinalIgnoreCase での比較は、両方を ToUpperInvariant してから序数比較するのと同じ結果で、それより速い、とも書いてあります。そろえるより、最初から OrdinalIgnoreCase で比べるほうが近道です。

中級:見えない文字を、女将さんは数えない

次は、券売機には見えて、女将さんには見えない文字の話です。

.NET のグローバリゼーションと ICUによると、.NET 5 から Windows でも ICU(International Components for Unicode、言語ごとの比べ方を持つライブラリ)を使うようになりました。それ以前の Windows では、OS の NLS(National Language Support)が使われていました。
同じページには、ICU はヌル文字 \0 を重みゼロの文字として扱うので、カルチャ依存の検索では見つからない、という例が載っています。

試したのはこちらです。

string log = "udon\r\nsoba";
Console.WriteLine(log.IndexOf("\n"));
Console.WriteLine(log.IndexOf("\n", StringComparison.Ordinal));
Console.WriteLine(log.IndexOf('\n'));
Console.WriteLine(log.Contains("\n"));
Console.WriteLine("udon\r\n".EndsWith("\n"));
Console.WriteLine("udon\r\n".EndsWith("\n", StringComparison.Ordinal));
Console.WriteLine("---");

string s = "Hel\0lo";
Console.WriteLine(s.IndexOf("\0"));
Console.WriteLine(s.IndexOf("\0", StringComparison.Ordinal));
Console.WriteLine("abc".StartsWith("\0"));
Console.WriteLine("abc".StartsWith("\0", StringComparison.Ordinal));
Console.WriteLine("---");

string shy = "u\u00ADdon";   // u と d の間にソフトハイフン(U+00AD)
Console.WriteLine(shy == "udon");
Console.WriteLine(string.Compare(shy, "udon", StringComparison.CurrentCulture));
Console.WriteLine(shy.StartsWith("udon"));
Console.WriteLine(shy.StartsWith("udon", StringComparison.Ordinal));
Console.WriteLine(shy.Length);
5
5
5
True
True
True
---
0
3
True
False
---
False
0
True
False
5

1つ目のかたまり(改行の検索)は、今回の .NET 10.0.14 ではどれも同じ結果でした。

2つ目は、ドキュメントの例と同じ動きです。"Hel\0lo" の \0 は3番目にあるのに、既定の IndexOf(string) は 0 を返しました。"abc" は \0 で始まっていないのに、StartsWith("\0") は True です。
女将さんにとって \0 は重さのない文字なので、どこにでもいて、どこにもいない。哲学みたいになってきました。

3つ目のソフトハイフン(改行するときだけ表示されるハイフン)も、今回の結果では女将さんに無視されました。Length は 5 なのに、カルチャで比べると udon と同じ(0)で、StartsWith("udon") も True です。

新人

見えない文字なら、無視してくれたほうが親切じゃないですか

画面に出す並べ替えなら、そうかもしれません。でも、ファイル名やユーザー ID の一致判定で、見えない文字の入った別の名前を同じとみなしたら、それは親切ではなく通せんぼのすり抜けです。

対処は、ここでも StringComparison.Ordinal か、char を受け取る多重定義です。ベストプラクティスにも、IndexOf(char) は既定で序数比較、IndexOf(string) は既定でカルチャ依存、と書き分けてあります。

1つ、地味に覚えておきたい例外があります。String.Contains のリファレンスでは、Contains(string) は序数比較(大文字小文字を区別し、カルチャに依存しない)です。上の結果でも、Contains("\n") は正しく True でした。
IndexOf(string) は女将さん、Contains(string) は券売機。似た名前で担当が違うのは、正直ずるいと思います。

中級:Dictionary・HashSet・OrderBy・SortedSet の既定はばらばら

コレクションにも、係の割り当てがあります。

var stock = new Dictionary<string, int> { ["Udon"] = 3 };
Console.WriteLine(stock.ContainsKey("udon"));

var stock2 = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["Udon"] = 3 };
Console.WriteLine(stock2.ContainsKey("udon"));
Console.WriteLine(stock2.ContainsKey("UDON"));

string[] orders = { "udon", "Udon", "UDON", "soba" };
Console.WriteLine(string.Join(", ", orders.Distinct()));
Console.WriteLine(string.Join(", ", orders.Distinct(StringComparer.OrdinalIgnoreCase)));
Console.WriteLine(string.Join(", ", orders.OrderBy(x => x)));
Console.WriteLine(string.Join(", ", orders.OrderBy(x => x, StringComparer.Ordinal)));
Console.WriteLine(orders.Contains("SOBA"));
False
True
True
udon, Udon, UDON, soba
udon, soba
soba, udon, Udon, UDON
UDON, Udon, soba, udon
False

何も渡さない Dictionary と Distinct は、大文字小文字を区別して比べました。キーで探す仕事は券売機です。
一方、何も渡さない OrderBy は、女将さんの並び方(小文字が先)でした。

並べ替える系は女将さん、探す系は券売機、と覚えておくと、だいたい当たります。だいたい、というのが大事で、最後は渡すほうが確実です。

ベストプラクティスには、StringComparer に6つの静的プロパティ(Ordinal、OrdinalIgnoreCase、CurrentCulture など)があり、Hashtable や SortedSet などのコンストラクターに渡せることが書いてあります。上の stock2 のように、作るときに渡すのが一番です。

おうどん

じゃあ ContainsKey の前に、毎回キーを ToUpper しておけば

前の章でトルコに行ってきたばかりです。

ベストプラクティスの .NET と .NET Framework の違いの節では、カルチャ依存の比べ方を既定で使うものとして、Array.Sort(文字列の配列)、List<T>.Sort()、SortedDictionary・SortedList のキー、SortedSet などを挙げています。冒頭の SortedSet も、この仲間です。何が起きていたのかは、上級者向けの章で見ます。

中級:既定任せの呼び出しを、ビルドで見つける

全部の呼び出しを目で探すのは大変です。コード分析に探してもらいましょう。

ベストプラクティスには、CA1307・CA1309・CA1310 のルールで、意図しないカルチャ依存の比較を見つけられること、プロジェクトファイルに次の設定を書けばビルドエラーにできることが書いてあります。

<PropertyGroup>
  <AnalysisMode>All</AnalysisMode>
  <WarningsAsErrors>$(WarningsAsErrors);CA1307;CA1309;CA1310</WarningsAsErrors>
</PropertyGroup>

今回は、この2行をコンソールアプリのプロジェクトの PropertyGroup に足して、次のコードをビルドしました。

string menu = "udon set";
Console.WriteLine(menu.StartsWith("udon"));
Console.WriteLine(string.Compare(menu, "soba"));
Console.WriteLine(menu.IndexOf("set"));
Console.WriteLine(menu.Equals("UDON SET", StringComparison.InvariantCultureIgnoreCase));
Program.cs(3,26): error CA1309: Use ordinal string comparison
Program.cs(5,43): error CA1309: Use ordinal string comparison
Program.cs(2,19): error CA1310: The behavior of 'string.StartsWith(string)' could vary based on the current user's locale settings. Replace this call in '<top-level-statements-entry-point>' with a call to 'string.StartsWith(string, System.StringComparison)'.
Program.cs(3,19): error CA1310: The behavior of 'string.Compare(string?, string?)' could vary based on the current user's locale settings. Replace this call in '<top-level-statements-entry-point>' with a call to 'string.Compare(string?, string?, System.StringComparison)'.
Program.cs(4,19): error CA1310: The behavior of 'string.IndexOf(string)' could vary based on the current user's locale settings. Replace this call in '<top-level-statements-entry-point>' with a call to 'string.IndexOf(string, System.StringComparison)'.

(dotnet build の出力から、ファイルのパス、末尾のルールの説明リンクとプロジェクト名を省き、最後にもう一度出るまとめの一覧を除いています。結果は 5 Error(s)・ビルド失敗でした)

おうどん

5行のコードでエラーが5個。打率10割だ

ほめていいのか迷う打率です。中身を見ます。

StartsWith・Compare・IndexOf の3か所が CA1310、Compare と InvariantCultureIgnoreCase の2か所が CA1309 で止まりました。
StringComparison を書いた5行目まで止められているのは、CA1309 が「序数比較を使って」というルールだからです。画面に出す並べ替えのように、本当にカルチャで比べたい場所は、ルールを外すか抑制して、理由をコメントに残すのがいいと思います。

読者の心の声「既存のプロジェクトで有効にしたら、エラーが300個出そう」

300個出たら、300個の「どっちの係のつもり?」が見つかったということです。いきなりエラーにせず、最初は警告のまま数を見て、新しく書くコードから直す、くらいがちょうどいいです。

ここから先は上級者向け。読み飛ばしてもOKです。日本語環境で起きること、文字の合成と分解、ICU と NLS の違い、.NET 10 の新機能を見ます。

上級:日本語環境の女将さんは、ひらがなとカタカナを同じお客さんと見る

冒頭の SortedSet の話です。日本語のメニュー名で、既定の比べ方を使うとどうなるかを確かめます。

using System.Globalization;

Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine(CultureInfo.CurrentCulture.Name);

string menu = "ウドン定食";
Console.WriteLine(menu.StartsWith("うどん"));
Console.WriteLine(menu.StartsWith("うどん", StringComparison.Ordinal));
Console.WriteLine(menu.IndexOf("うどん"));
Console.WriteLine(menu.Contains("うどん"));
Console.WriteLine(string.Compare("ウドン", "うどん"));
Console.WriteLine("ウドン".Equals("うどん", StringComparison.CurrentCulture));
Console.WriteLine("ウドン".Equals("うどん", StringComparison.CurrentCultureIgnoreCase));
Console.WriteLine("ウドン" == "うどん");
Console.WriteLine("---");

var sorted = new SortedSet<string> { "ウドン", "うどん", "ウドン" };
Console.WriteLine($"SortedSet  : {sorted.Count} {string.Join(", ", sorted)}");
var hash = new HashSet<string> { "ウドン", "うどん", "ウドン" };
Console.WriteLine($"HashSet    : {hash.Count}");
var sortedOrd = new SortedSet<string>(StringComparer.Ordinal) { "ウドン", "うどん", "ウドン" };
Console.WriteLine($"SortedSet(Ordinal): {sortedOrd.Count} {string.Join(", ", sortedOrd)}");
ja-JP
True
False
0
False
0
True
False
False
---
SortedSet  : 2 ウドン, ウドン
HashSet    : 3
SortedSet(Ordinal): 3 うどん, ウドン, ウドン

今回の検証環境(ICU、カルチャ ja-JP)では、既定の StartsWith・IndexOf・Compare が、ひらがなの うどん とカタカナの ウドン を同じとみなしました。
既定の比べ方を使う SortedSet は、うどん を重複として入れず、2件になりました。冒頭のおうどんの1品は、ここで消えています。

おうどん

つまり女将さん、うどんとウドンは同じ人だと思ってる

読者の心の声「でも IgnoreCase を付けると、違う人になってますよ」

そこが一番ふしぎなところです。CurrentCulture では True、CurrentCultureIgnoreCase では False。大文字小文字を無視する設定を足したら、一致しなくなりました。

組み合わせを広げて試しました。CompareInfo.Compare に CompareOptions を渡して、ja-JP とインバリアントカルチャで比べています。

using System.Globalization;

Console.OutputEncoding = System.Text.Encoding.UTF8;

(string, string)[] pairs =
{
    ("ア", "あ"), ("ウドン", "うどん"), ("ありがとう", "アリガトウ"),
    ("カ", "カ"), ("ド", "ド"), ("ウドン", "ウドン"), ("UDON", "udon"), ("A", "a"),
};
CompareOptions[] options =
{
    CompareOptions.None,
    CompareOptions.IgnoreCase,
    CompareOptions.IgnoreKanaType,
    CompareOptions.IgnoreWidth,
    CompareOptions.IgnoreKanaType | CompareOptions.IgnoreWidth,
    CompareOptions.IgnoreCase | CompareOptions.IgnoreKanaType | CompareOptions.IgnoreWidth,
};
Console.WriteLine("cols: None / IgnoreCase / IgnoreKanaType / IgnoreWidth / Kana+Width / Case+Kana+Width");
foreach (string cname in new[] { "ja-JP", "" })
{
    CompareInfo ci = CultureInfo.GetCultureInfo(cname).CompareInfo;
    Console.WriteLine($"[{(cname == "" ? "Invariant" : cname)}]");
    foreach (var (x, y) in pairs)
    {
        string row = string.Join(" ", options.Select(o => ci.Compare(x, y, o) == 0 ? "T" : "F"));
        Console.WriteLine($"  {x}/{y}: {row}  (cmp None={ci.Compare(x, y, CompareOptions.None)})");
    }
}
cols: None / IgnoreCase / IgnoreKanaType / IgnoreWidth / Kana+Width / Case+Kana+Width
[ja-JP]
  ア/あ: T F T T T T  (cmp None=0)
  ウドン/うどん: T F T T T T  (cmp None=0)
  ありがとう/アリガトウ: T F T T T T  (cmp None=0)
  カ/カ: T F T T T T  (cmp None=0)
  ド/ド: F F F F F T  (cmp None=-1)
  ウドン/ウドン: F F F F F T  (cmp None=-1)
  UDON/udon: F F F F F T  (cmp None=1)
  A/a: F T F F F T  (cmp None=1)
[Invariant]
  ア/あ: F F T F T T  (cmp None=1)
  ウドン/うどん: F F T F T T  (cmp None=1)
  ありがとう/アリガトウ: F F T F T T  (cmp None=-1)
  カ/カ: F F F T T T  (cmp None=-1)
  ド/ド: F F F F F T  (cmp None=-1)
  ウドン/ウドン: F F F F F T  (cmp None=-1)
  UDON/udon: F F F F F T  (cmp None=1)
  A/a: F T F F F T  (cmp None=1)

T は一致(Compare が0)、F は不一致です。今回の結果から言えることを並べます。

  • ja-JP では、None でも、ひらがなとカタカナ、全角と半角の カ/カ が一致した。IgnoreCase を付けると一致しなくなった
  • インバリアントカルチャでは、None では一致せず、CompareOptions のリファレンスの説明どおり、IgnoreKanaType でかなの種類、IgnoreWidth で全角半角の違いを無視した
  • 半角の濁点つき(ド は ト と ゙ の2文字)は、どちらのカルチャでも、3つ全部を付けたときだけ一致した
  • 全角の UDON と udon も、3つ全部を付けたときだけ一致した

ja-JP の None と IgnoreCase で結果が逆になる理由は、今回読んだ資料には書いてありませんでした。ICU のページには、ICU の動きを決めるカルチャのデータは、ふつうランタイムではなく CLDR(Unicode の、言語ごとのデータを集めたプロジェクト)が管理している、とあります。少なくとも、.NET の版や ICU の版、OS が変わったときに同じ結果になるとは考えないほうが安全です。

女将さんの勘は、店ごとに違うということです。暖簾分けしたら、別人になる可能性まであります。

なので、使い分けはこうしておきます。

  • 同じ商品か、同じキーかの判定は Ordinal(または OrdinalIgnoreCase)。かなの違いを同じとみなさない
  • 「ひらがなでもカタカナでも見つけたい」検索は、IgnoreKanaType などを明示して、その環境で結果をテストする
  • SortedSet・SortedDictionary に日本語のキーを入れるなら、StringComparer.Ordinal を渡す

上級:「が」は1文字とは限らない

もう1つ、日本語で気をつけたいのが、文字の合成と分解です。
が は、1文字の が(U+304C)と、か に結合用の濁点(U+3099)を足した2文字の、どちらでも表せます。画面ではどちらも同じ が に見えます。

string nfc = "が";            // U+304C の1文字
string nfd = "か\u3099";       // か + 結合用の濁点 U+3099

Console.WriteLine(nfc == nfd);
Console.WriteLine($"{nfc.Length} {nfd.Length}");
Console.WriteLine(string.Compare(nfc, nfd, StringComparison.CurrentCulture));
Console.WriteLine(string.Equals(nfc, nfd, StringComparison.OrdinalIgnoreCase));
Console.WriteLine(nfc == nfd.Normalize());
False
1 2
0
False
True

券売機(== と OrdinalIgnoreCase)は別物と判定し、女将さん(CurrentCulture)は同じと判定しました。
見た目は同じ が なのに、Length は 1 と 2。券売機にとっては、具が1つの丼と2つの丼くらい違います。

おうどん

じゃあ、ここは女将さんに任せればいいのでは

ここだけ女将さんに任せると、前の章の、うどんとウドンも一緒に付いてきます。
序数比較のまま見た目をそろえたいなら、Normalize() で正規化(文字の表し方を1つの形にそろえること)してから比べます。今回は nfd.Normalize() で合成済みの形になり、== が True になりました。

ベストプラクティスにも、a と結合用の上の輪(U+030A)が、インバリアントカルチャでは å(U+00E5)と同じ重さになり、序数比較では違う、という同じ型の例が載っています。

上級:ICU と NLS で、同じコードの答えが変わる

ここまでの結果は ICU のものです。ICU のページによると、.NET 7 以降は Windows 10 の 1703 以降(サーバーは Windows Server 2019 以降)で ICU を読み込めます(.NET 5・6 は 1903 以降)。ICU を読めない古い Windows では、NLS に戻ります。
また、プロジェクトファイル、runtimeconfig.json、環境変数 DOTNET_SYSTEM_GLOBALIZATION_USENLS のどれかで、NLS に戻せます。.NET 9 以降は、環境変数の指定が優先されます。

同じプログラムを、そのままと、DOTNET_SYSTEM_GLOBALIZATION_USENLS=1 を付けて実行しました。ICU かどうかの判定は、ICU のページに載っている関数をそのまま使っています。

using System.Globalization;
using System.Runtime.InteropServices;

Console.OutputEncoding = System.Text.Encoding.UTF8;

Console.WriteLine(RuntimeInformation.FrameworkDescription);
Console.WriteLine($"ICU                          : {ICUMode()}");
Console.WriteLine($"\"udon\\r\\nsoba\".IndexOf(\"\\n\") : {"udon\r\nsoba".IndexOf("\n")}");
Console.WriteLine($"\"Hel\\0lo\".IndexOf(\"\\0\")      : {"Hel\0lo".IndexOf("\0")}");
Console.WriteLine($"Compare(\"udon\", \"Udon\")      : {string.Compare("udon", "Udon")}");
Console.WriteLine($"ウドン = ウドン (IgnoreWidth)   : {string.Compare("ウドン", "ウドン", CultureInfo.CurrentCulture, CompareOptions.IgnoreWidth) == 0}");
string[] w = { "coop", "co-op", "con", "bills", "bill's", "Udon", "udon" };
Array.Sort(w);
Console.WriteLine($"Array.Sort                   : {string.Join(", ", w)}");

static bool ICUMode()
{
    SortVersion sortVersion = CultureInfo.InvariantCulture.CompareInfo.Version;
    byte[] bytes = sortVersion.SortId.ToByteArray();
    int version = bytes[3] << 24 | bytes[2] << 16 | bytes[1] << 8 | bytes[0];
    return version != 0 && version == sortVersion.FullVersion;
}

そのまま実行したとき(ICU)です。

.NET 10.0.14
ICU                          : True
"udon\r\nsoba".IndexOf("\n") : 5
"Hel\0lo".IndexOf("\0")      : 0
Compare("udon", "Udon")      : -1
ウドン = ウドン (IgnoreWidth)   : False
Array.Sort                   : bill's, bills, co-op, con, coop, udon, Udon

DOTNET_SYSTEM_GLOBALIZATION_USENLS=1 を付けたとき(NLS)です。

.NET 10.0.14
ICU                          : False
"udon\r\nsoba".IndexOf("\n") : 5
"Hel\0lo".IndexOf("\0")      : 3
Compare("udon", "Udon")      : -1
ウドン = ウドン (IgnoreWidth)   : False
Array.Sort                   : bills, bill's, con, coop, co-op, udon, Udon

(今回は Python のスクリプトから環境変数を渡して dotnet run を実行しました。PowerShell なら、$env:DOTNET_SYSTEM_GLOBALIZATION_USENLS = "1" のあとに dotnet run です)

NLS では \0 が3番目で見つかり、並べ替えでは bills が bill's より先、coop が co-op より先になりました。
同じコードで、環境変数を1つ変えただけです。チェーン店だと思って入ったら、のれんだけ同じ別の店だった、という感じです。
CompareOptions のリファレンスにも、ICU では既定の None が StringSort(記号を英数字より先に並べる並べ方)と同じになった、と書いてあります。bill's が先頭に来たのは、その動きです。

新人

じゃあ NLS に戻せば、昔のままで安心ですね

昔のままなのは確かです。ただ、ICU のページには、.NET 5 より前から Unix では ICU を使っていた、とあります。NLS に戻すと、同じアプリが Windows と Linux で違う答えを出す状態に戻ります。安心というより、昔の不揃いに戻る感じです。

なお、ベストプラクティスの .NET Framework との違いの節には、NLS ではヌル文字などの制御文字が無視されることがあり、ICU では実際の文字として扱われる、という説明もあります。今回の実測は ICU のページの説明(ICU では \0 が重みゼロ)と同じ結果でした。資料どうしで読み方が分かれる所は、自分の環境で確かめるのが一番です。

前のほうでは、改行の検索はどちらも 5 でした。比べる文字によって、差が出る所と出ない所があります。ネットで見かけた古い版の結果を、そのまま自分の版に当てはめないのが大事です。

上級:.NET 10 の NumericOrdering で「2」を「10」の前に

最後は、前のほうで新人が困っていた、番号札の並べ方です。
.NET 10 のライブラリの新機能に、数字の並びを数の大きさで比べる CompareOptions.NumericOrdering が入りました。2 は 10 より前、2 と 02 は同じ、という比べ方です。

using System.Globalization;

string[] files = { "udon10.txt", "udon2.txt", "udon1.txt", "udon02.txt" };

Console.WriteLine("Ordinal        : " + string.Join(", ", files.Order(StringComparer.Ordinal)));
Console.WriteLine("CurrentCulture : " + string.Join(", ", files.Order(StringComparer.CurrentCulture)));

StringComparer numeric = StringComparer.Create(CultureInfo.CurrentCulture, CompareOptions.NumericOrdering);
Console.WriteLine("NumericOrdering: " + string.Join(", ", files.Order(numeric)));
Console.WriteLine(numeric.Equals("udon2.txt", "udon02.txt"));

try
{
    CultureInfo.CurrentCulture.CompareInfo.IndexOf("udon10.txt", "10", CompareOptions.NumericOrdering);
}
catch (Exception e)
{
    Console.WriteLine(e.GetType().Name + ": " + e.Message);
}
Ordinal        : udon02.txt, udon1.txt, udon10.txt, udon2.txt
CurrentCulture : udon02.txt, udon1.txt, udon10.txt, udon2.txt
NumericOrdering: udon1.txt, udon2.txt, udon02.txt, udon10.txt
True
ArgumentException: Value of flags is invalid. (Parameter 'options')

NumericOrdering で、udon1・udon2・udon10 の順になりました。udon2.txt と udon02.txt は等しいと判定されています。9 番さんも、ようやく 10 番さんより先に呼ばれます。

ただし、等しいと判定されるということは、この比べ方を HashSet などのキーに使うと、udon2.txt と udon02.txt は1つにまとめられます。新機能のページの例でも、007 を入れた HashSet で Contains("7") が True です。ファイル名の一致判定に使うと、また1品消えます。

また、リファレンスのとおり、IndexOf のような検索には使えず、今回は ArgumentException になりました。並べる専用の係です。

おうどん

これで、伝票の並べ替えに悩む日々は終わりだね

.NET 10 以降の話です。それより前の版のアプリには、この係はいません。

冒頭のメニューに戻ると

冒頭のおうどんは、SortedSet に重複チェックを任せていました。
何が起きていたかを言い直すと、並べるのが得意な女将さんに、同じ品物かどうかの判定まで頼んでいた、ということです。

ななみ

同じかどうかは券売機、並べるのは女将さん。係を書いて頼めば、うどんもウドンも消えませんよ

比べ方は、StringComparison で毎回名指しする。
既定の係に任せると、日によって、国によって、版によって、担当が変わります。

まずは、StartsWith と IndexOf に StringComparison.Ordinal を足すところから。券売機も女将さんも、頼まれた仕事はきちんとこなしてくれます🍜

参考資料

2026年11月時点の内容です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

おうどん|癒しと創作をたのしむ雑食クリエイター
写経アプリやクレイセラピー、優しい和風デザインがすき。
ZARDと刀剣と文字に癒されて、今はアプリ作ってます。

コメント

コメントする

目次