こんにちは、おうどんです🍜
ななみさん、メニュー名の重複チェックを作ったよ。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) == 0 | string.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 を足すところから。券売機も女将さんも、頼まれた仕事はきちんとこなしてくれます🍜
参考資料
- Best practices for comparing strings in .NET(Microsoft Learn) — == と Equals は序数が既定、Compare・StartsWith・EndsWith・IndexOf(string) はカルチャが既定、推奨と避けること、用途別の対応表、トルコ語の I、OrdinalIgnoreCase と ToUpperInvariant、IndexOf(char) と IndexOf(string)、StringComparer、CA1307・CA1309・CA1310 の設定、.NET Framework との違い、å の例
- .NET globalization and ICU(Microsoft Learn) — .NET 5 からの ICU、Windows の版と ICU の表、\0 の IndexOf・StartsWith の例、NLS に戻す方法と .NET 9 以降の優先順位、ICU の判定関数、CLDR
- CompareOptions Enum(Microsoft Learn) — IgnoreKanaType・IgnoreWidth などの意味、NumericOrdering は検索に使えない、ICU では None が StringSort と同じ、Ordinal・OrdinalIgnoreCase の意味
- What’s new in .NET libraries for .NET 10(Microsoft Learn) — NumericOrdering の追加と例(02 と 2、HashSet の 007)
- String.Contains Method(Microsoft Learn) — Contains(string) は序数比較、StringComparison 付きの多重定義
2026年11月時点の内容です。










コメント