C# 文字列比較の注意点と実践方法

はい、C#における文字列比較の注意点と実践方法について、詳細な記事を作成します。約5000語を目指し、各トピックを深く掘り下げて解説します。


C# 文字列比較の注意点と実践方法

プログラミングにおいて、文字列は最も頻繁に扱われるデータ型の一つです。データの入力、処理、出力、保存、検索、そして検証など、様々な場面で文字列が利用されます。そして、これらの処理の多くにおいて、「二つの文字列が同じであるか」あるいは「どちらが大きいか」といった文字列比較が不可欠となります。

しかし、一見単純に見える文字列比較は、特に国際化されたアプリケーションやセキュリティが重要なシステムにおいては、多くの落とし穴が潜んでいます。大文字小文字の区別、ロケール(文化圏)による比較ルールの違い、Unicodeの正規化問題、パフォーマンス、そしてセキュリティリスクなど、考慮すべき点が多岐にわたります。

C#は文字列操作において強力な機能を提供していますが、これらの機能を正しく理解し、適切な方法を選択しないと、意図しないバグや脆弱性を生み出す可能性があります。

この記事では、C#における文字列比較の様々な方法、それぞれの特徴、潜んでいる注意点、そしてどのような状況でどの方法を選択すべきかという実践的な内容について、詳細かつ網羅的に解説します。

1. はじめに:なぜ文字列比較は注意が必要なのか?

文字列比較は、単にバイナリデータを一対一で比較するのとは異なります。人間が扱う自然言語や、システム内部でのIDなど、その用途によって「同じ」とみなす基準が変わるためです。

例えば:

  • ユーザー名 “Admin” と “admin” は同じか?(大文字小文字を区別するか?)
  • ファイル名 “Résumé.txt” と “Resume.txt” は同じか?(アクセント記号を無視するか?)
  • トルコ語で “i” と “I”、”.”付きの “i” と “.”なしの “I” の関係は英語と異なる。トルコ語のカルチャーで比較する場合、”i” を大文字にすると “.”付きの “I” (İ) になり、”I” を小文字にすると “.”なしの “i” (ı) になります。英語のルール(i -> I, I -> i)とは異なります。このようなロケール固有のルールを考慮する必要があるか?
  • データベースのキーとして使う文字列 “ABC” と “abc” は同じとみなすべきか?
  • パスワード “P@ssw0rd” と “P@ssw0rd” は本当にビット列として完全に一致するか?(セキュリティ)

これらの疑問に答えるためには、C#が提供する文字列比較のメカニズムを深く理解する必要があります。

2. C#における文字列の基本

文字列比較の詳細に入る前に、C#における string 型の基本的な特性を確認しておきましょう。

  • 不変性 (Immutability): C# の string オブジェクトは不変です。一度作成された string オブジェクトの内容を変更することはできません。文字列操作(連結、置換、部分文字列抽出など)を行うと、常に新しい string オブジェクトが生成されます。この特性は、文字列比較にも影響を与えます。
  • 参照型だが値型のように振る舞う場合がある: string はクラスであり参照型ですが、特に == 演算子に関しては、通常の値型のように内容を比較する(オーバーロードされているため)という特別な挙動をします。
  • 文字列リテラルのインターン (String Interning): コンパイル時または実行時に同じ文字列リテラルが出現した場合、.NETランタイムは同じ string オブジェクトを再利用することがあります。これは「インターン」と呼ばれ、メモリ使用量の削減と、特定の状況下での == 演算子による高速な比較(参照比較)を可能にしますが、これに依存した比較ロジックは危険です。

3. 基本的な文字列比較方法

C#には、文字列を比較するためのいくつかの方法があります。それぞれの特徴と使い分けを見ていきましょう。

3.1. == 演算子

最も直感的でよく使われる方法です。

“`csharp
string str1 = “hello”;
string str2 = “hello”;
string str3 = “world”;

bool isEqual = (str1 == str2); // true
bool isNotEqual = (str1 == str3); // false
“`

特徴:

  • string 型に対してオーバーロードされており、文字列の内容(値)を比較します。参照比較ではありません(ただし、インターンされたリテラル同士の比較では結果的に参照比較になることがあります)。
  • ヌル (null) との比較は安全です。
    “`csharp
    string nullStr = null;
    string emptyStr = “”;

    bool isNull = (nullStr == null); // true
    bool isEmpty = (emptyStr == null); // false
    bool areEqual = (nullStr == emptyStr); // false
    “`
    * シンプルで読みやすいコードになります。

注意点:

  • 大文字小文字を区別します。 (“Hello” == “hello”) は false です。
  • 現在のカルチャー(ロケール)や正規化を考慮しません。 純粋に文字コードのシーケンスを比較します(ただし、内部的には string.Equals(string, string) を呼び出しているとされていますが、振る舞いとしてはカルチャー非依存の比較に近いです。正確には string.Equals(a, b, StringComparison.CurrentCulture) ではないことに注意が必要です)。
  • 比較対象の変数が object 型として宣言されている場合、string 型としてキャストしないと参照比較になってしまう可能性があります。

    “`csharp
    object obj1 = “hello”;
    object obj2 = “hello”;
    object obj3 = new string(new char[] { ‘h’, ‘e’, ‘l’, ‘l’, ‘o’ }); // 新しいオブジェクト

    // obj1 と obj2 は文字列リテラルのインターンにより、多くの場合同じ参照を指す
    bool compareObj1Obj2_Ref = (obj1 == obj2); // true (しばしば)
    // obj1 と obj3 は異なる参照を指す
    bool compareObj1Obj3_Ref = (obj1 == obj3); // false

    // 正しい値比較にはキャストまたは Equals を使用する
    bool compareObj1Obj2_Value = ((string)obj1 == (string)obj2); // true
    bool compareObj1Obj3_Value = ((string)obj1 == (string)obj3); // true
    ``
    この
    object型での比較の落とし穴は重要です。==演算子は静的に解決されるため、コンパイル時の型がobjectであれば、objectクラスの==` 演算子(参照比較)が使用されます。

3.2. Equals メソッド

string クラスには複数の Equals メソッドがオーバーロードされています。これらは == 演算子よりも柔軟な比較を提供します。

3.2.1. string.Equals(string value) (インスタンスメソッド)

“`csharp
string str1 = “hello”;
string str2 = “hello”;
string str3 = “world”;
string nullStr = null;

bool isEqual = str1.Equals(str2); // true
bool isNotEqual = str1.Equals(str3); // false

// 注意! null に対してインスタンスメソッドを呼び出すと NullReferenceException
// bool error = nullStr.Equals(str1); // <– NullReferenceException
“`

特徴:

  • 呼び出し元の文字列インスタンスと引数の文字列の内容を比較します。
  • 大文字小文字を区別します。
  • == 演算子と同様に、カルチャーや正規化はデフォルトでは考慮しません。
  • 呼び出し元の文字列が null の場合、NullReferenceException が発生します。

3.2.2. string.Equals(object obj) (インスタンスメソッド)

“`csharp
string str1 = “hello”;
object obj1 = “hello”;
object obj2 = 123;
string nullStr = null;

bool isEqual = str1.Equals(obj1); // true (obj1 が string “hello” なので)
bool isNotEqual = str1.Equals(obj2); // false (obj2 が string ではないので)

// 注意! null に対してインスタンスメソッドを呼び出すと NullReferenceException
// bool error = nullStr.Equals(obj1); // <– NullReferenceException
“`

特徴:

  • 引数が null でないかチェックし、呼び出し元と同じ型 (string) かチェックします。同じ型であれば内容比較を行います。
  • 引数が null の場合は false を返します(呼び出し元が null の場合は NullReferenceException)。
  • 引数が string 型でない場合は false を返します。
  • それ以外は string.Equals(string value) と同じ挙動(大文字小文字区別、カルチャー・正規化非考慮)。

3.2.3. string.Equals(string a, string b) (静的メソッド)

“`csharp
string str1 = “hello”;
string str2 = “hello”;
string nullStr1 = null;
string nullStr2 = null;
string emptyStr = “”;

bool isEqual = string.Equals(str1, str2); // true
bool areNullEqual = string.Equals(nullStr1, nullStr2); // true
bool nullAndEmptyEqual = string.Equals(nullStr1, emptyStr); // false
“`

特徴:

  • ヌル安全性: 引数のどちらか一方または両方が null でも例外が発生しません。両方 null なら true、片方だけ null なら false を返します。
  • 大文字小文字を区別します。
  • カルチャーや正規化はデフォルトでは考慮しません。
  • インスタンスが null かどうか気にせずに比較できるため、推奨される形式の一つです。

3.2.4. string.Equals(string a, string b, StringComparison comparisonType) (静的メソッド)
3.2.5. string.Equals(string value, StringComparison comparisonType) (インスタンスメソッド)

これらのメソッドは、後述する StringComparison 列挙体を引数に取ることができ、文字列比較において最も推奨される方法です。大文字小文字、カルチャー、バイナリ比較など、様々な比較ルールを明示的に指定できます。

“`csharp
string strA = “Staße”; // ドイツ語のエスツェット
string strB = “STRASSE”; // ドイツ語で同じ意味
string strC = “strasse”;

// デフォルト (大文字小文字区別、カルチャー考慮なしに近い)
Console.WriteLine(string.Equals(strA, strB)); // False

// 現在のカルチャーで大文字小文字を区別しない比較
// ドイツ語カルチャーの場合、’ß’ は大文字で ‘SS’ とみなされる
Console.WriteLine(string.Equals(strA, strB, StringComparison.CurrentCultureIgnoreCase)); // true (ドイツ語環境の場合)

// 常に大文字小文字を区別しない比較 (カルチャー非依存)
Console.WriteLine(string.Equals(strA, strC, StringComparison.InvariantCultureIgnoreCase)); // true

// バイナリ比較 (高速、予測可能、セキュリティ用途に推奨)
Console.WriteLine(string.Equals(strA, strC, StringComparison.Ordinal)); // false (‘ß’ と ‘s’ が異なるバイナリ値を持つため)
Console.WriteLine(string.Equals(“abc”, “ABC”, StringComparison.OrdinalIgnoreCase)); // false (‘a’ と ‘A’ は異なるバイナリ値を持つため) – OrdinalIgnoreCase の挙動に注意! 後述

“`

StringComparison の詳細については後述します。

3.3. Compare メソッド

string.Compare メソッドは、二つの文字列の辞書式順序での比較を行います。これは、ソートなど、文字列の順序が重要な場合に使用されます。

“`csharp
string strA = “apple”;
string strB = “banana”;
string strC = “Apple”;

// string.Compare(string strA, string strB) – デフォルト (カルチャー考慮、大文字小文字区別あり)
// カルチャーによっては ‘A’ < ‘a’ となる
Console.WriteLine(string.Compare(strA, strB)); // -1 (apple は banana より前)
Console.WriteLine(string.Compare(strB, strA)); // 1 (banana は apple より後)
Console.WriteLine(string.Compare(strA, strA)); // 0 (apple と apple は同じ)
Console.WriteLine(string.Compare(strA, strC)); // 1 または -1 または 0 (カルチャーによる)

// string.Compare(string strA, string strB, bool ignoreCase) – 大文字小文字区別を指定
// boolean 引数版は CultureInfo.CurrentCulture を内部的に使用する
Console.WriteLine(string.Compare(strA, strC, true)); // 0 (大文字小文字を無視すると同じ)

// string.Compare(string strA, string strB, StringComparison comparisonType) – 最も推奨される形式
Console.WriteLine(string.Compare(strA, strC, StringComparison.InvariantCultureIgnoreCase)); // 0
Console.WriteLine(string.Compare(strA, strC, StringComparison.OrdinalIgnoreCase)); // 0
“`

戻り値:

  • strAstrB より辞書順でにあれば負の値
  • strAstrB同じであれば 0
  • strAstrB より辞書順でにあれば正の値

特徴:

  • 文字列の大小関係を判定できます。
  • string.Equals と同様に、StringComparison を指定するオーバーロードが提供されており、これが推奨されます。
  • 部分文字列の比較 (Compare(string strA, int indexA, string strB, int indexB, int length)) も可能です。
  • null に対しても安全なオーバーロード (Compare(string strA, string strB)) があります(両方 null なら 0、片方だけ null なら非nullの方が大きいと判断される)。

4. StringComparison 列挙体の詳細

StringComparison 列挙体は、C#の文字列比較の中心となる重要な要素です。この列挙体を理解することが、適切で安全な文字列比較を行う鍵となります。

StringComparison 列挙体は以下のメンバーを持ちます。

  • CurrentCulture: 現在のスレッドに設定されているカルチャーのルールを使用して、大文字小文字やアクセント記号などを考慮した言語的に関連性の高い比較を行います。
  • CurrentCultureIgnoreCase: CurrentCulture のルールを使用し、さらに大文字小文字を区別しません。
  • InvariantCulture: システムのローカライズ設定に依存しない、カルチャーニュートラルな比較ルールを使用します。これは Unicode の特定バージョンに基づいた静的な比較ルールセットです。言語的な比較には適していますが、CurrentCulture とは異なる結果になる場合があります。
  • InvariantCultureIgnoreCase: InvariantCulture のルールを使用し、さらに大文字小文字を区別しません。
  • Ordinal: 各文字の Unicode スカラー値(バイナリ表現)に基づいて、厳密なバイナリ比較を行います。最も高速で予測可能な比較方法です。大文字小文字、カルチャー、正規化などは一切考慮されません。
  • OrdinalIgnoreCase: 各文字の Unicode スカラー値に基づいて比較を行いますが、比較前に定義済みの変換ルールに従って一部の文字を大文字または小文字にマップしてから比較します。これはバイナリ比較よりは遅いですが、カルチャーに依存しない大文字小文字を区別しない比較を提供します。ただし、これは「完全な」カルチャー非依存の大文字小文字変換ではなく、限定的なルールに基づきます。

なぜ StringComparison を指定するのが重要なのか?

== や引数なしの Equals / Compare は、デフォルトの比較ルール(多くの場合 CurrentCulture に近い、あるいは Ordinal に近いが保証されない)に依存します。しかし、そのデフォルトのルールがどのカルチャーに基づいているのか、大文字小文字を区別するのかしないのか、正規化をどう扱うのかなどがコード上で明確になりません。

StringComparison を明示的に指定することで、開発者はその比較がどのような意図で行われているのかを明確に示し、異なる環境(OSのロケール設定など)でも一貫した結果を得られるように制御できます。

StringComparison の使い分けと注意点:

  • CurrentCulture / CurrentCultureIgnoreCase:

    • 用途: ユーザーに表示されるテキスト(UI上のラベル、メッセージなど)を、そのユーザーの文化圏に合わせた方法で比較したい場合に適しています。自然言語のソートなどにも使われます。
    • 注意点:
      • パフォーマンス: カルチャー固有のルールを適用するため、OrdinalInvariantCulture より遅くなる傾向があります。
      • 予測可能性: ユーザーのOS設定やアプリケーションのカルチャー設定によって比較結果が変わる可能性があります。これは意図しない挙動やバグの原因になり得ます。
      • セキュリティリスク: 特定のカルチャーでのみ文字列が一致すると判断されることを利用した脆弱性(例: 特定のカルチャーで “ADMIN” と “admin” が同じとみなされることを悪用する)につながる可能性があります。ファイルパス、ユーザー名、パスワード、リソースキーなど、システム内部で識別子として使用する文字列の比較には絶対に使用しないでください
  • InvariantCulture / InvariantCultureIgnoreCase:

    • 用途: ロケールに依存しない一貫した比較が必要な場合に適しています。設定ファイルのキー、リソース名、プログラミング言語のキーワードなど、自然言語に由来するがシステム全体で一貫性が求められる識別子などに使われます。
    • 注意点:
      • これは「普遍的」な比較ではなく、特定のバージョンの Unicode に基づいたルールセットです。
      • CurrentCulture と同様に言語的な比較のため、Ordinal よりは遅くなります。
      • 正規化の問題(後述)は依然として考慮する必要があります。
      • InvariantCultureIgnoreCase は、OrdinalIgnoreCase より多くの言語規則を考慮するため、大文字小文字変換がより「正確」ですが、その分遅くなります。
  • Ordinal:

    • 用途: システム内部の識別子、ファイルパス、レジストリキー、プロトコル要素、セキュリティ関連の比較(パスワード、キー、トークンなど) など、厳密なバイナリ一致が必要な場合に強く推奨されます。最も高速で予測可能であり、セキュリティリスクが最小限に抑えられます。
    • 注意点:
      • 大文字小文字を区別します。”ABC” と “abc” は一致しません。
      • アクセント記号などが異なる形式で表現されている場合(正規化されていない場合)は一致しません(例: é と e + ´)。
      • 自然言語の比較には適しません(例: ソート順が辞書順にならない可能性がある)。
  • OrdinalIgnoreCase:

    • 用途: バイナリ比較に近い速度で、かつ大文字小文字を区別しない比較を行いたい場合に検討されます。ファイルパスの比較など、大文字小文字を区別しないがカルチャーの影響を受けたくない場合に使われることがあります。
    • 注意点:
      • Ordinal ほど厳密なバイナリ比較ではありません。内部的に一部の文字をマッピングしてから比較します。
      • 言語によっては予期せぬ結果になる可能性があります。 特にトルコ語のI問題やドイツ語のß/SSのように、特定のカルチャーでの大文字小文字変換ルールは OrdinalIgnoreCase の単純なマッピングと異なる場合があります。
      • パフォーマンスは Ordinal より劣ります。
      • セキュリティ関連の比較では、通常 Ordinal (Case Sensitive) を使用すべきです。大文字小文字を区別しない必要がある場合でも、後述する ConstantTimeEquals のような方法を検討すべきです。

結論として、文字列比較を行う際は、まずその比較の「目的」を明確にし、最も安全で予測可能な StringComparison を選択することが重要です。システム内部やセキュリティ関連では Ordinal が第一の選択肢となるべきです。UI表示など、真に言語的な比較が必要な場合にのみ CurrentCultureInvariantCulture を検討します。

5. 文字列比較における注意点と落とし穴の詳細

StringComparison の選択だけでなく、文字列比較には他にもいくつかの注意点があります。

5.1. 大文字小文字の区別 (Case Sensitivity) とロケール (Culture)

前述の StringComparison で詳しく触れましたが、これは最も一般的で重要な注意点です。同じ文字列に見えても、大文字小文字が異なるだけで比較結果が変わります。さらに、その大文字小文字の変換ルール自体がロケールによって異なります。

  • 例: トルコ語のI問題
    トルコ語では、ラテン文字の ‘I’ (U+0049) の小文字はドットなしの ‘i’ (U+0069) ではなく、ドット付きの ‘ı’ (U+0131) です。また、ラテン文字の ‘i’ (U+0069) の大文字はドット付きの ‘İ’ (U+0130) です。
    英語のルール (i <=> I) とは異なります。

    “`csharp
    string lowerI = “i”;
    string upperI = “I”;

    // 英語カルチャー (デフォルトか InvariantCulture)
    Console.WriteLine(lowerI.ToUpper()); // “I”
    Console.WriteLine(upperI.ToLower()); // “i”
    Console.WriteLine(string.Equals(lowerI, upperI, StringComparison.InvariantCultureIgnoreCase)); // true

    // トルコ語カルチャー (例: “tr-TR”)
    var turkishCulture = new System.Globalization.CultureInfo(“tr-TR”);
    // スレッドのカルチャーを一時的に変更してテスト
    var currentCulture = System.Threading.Thread.CurrentThread.CurrentCulture;
    System.Threading.Thread.CurrentThread.CurrentCulture = turkishCulture;

    Console.WriteLine(lowerI.ToUpper()); // “İ” (U+0130)
    Console.WriteLine(upperI.ToLower()); // “ı” (U+0131)
    Console.WriteLine(string.Equals(lowerI, upperI, StringComparison.CurrentCultureIgnoreCase)); // false (トルコ語では i と I は別の文字ペアとみなされるため)

    System.Threading.Thread.CurrentThread.CurrentCulture = currentCulture; // 元に戻す
    ``
    このように、
    CurrentCultureIgnoreCaseは実行環境のカルチャーによって挙動が変わる可能性があります。InvariantCultureIgnoreCaseはこのような特殊なカルチャー固有のルールの一部を考慮しないため、予測可能ですが、言語的には「不正確」な結果になる場合があります。OrdinalIgnoreCase` はさらに単純なマッピングに基づきます。

    この問題があるため、システム内部の識別子、ファイル名、パスワードなどを CurrentCultureInvariantCulture を使用して大文字小文字を区別せずに比較することは危険です。識別子には Ordinal を使用し、大文字小文字を区別するかどうかはアプリケーションの仕様として固定するか、ユーザー入力などを正規化(例: 全て大文字/小文字に変換)してから Ordinal で比較する方が安全です。

5.2. 正規化 (Normalization)

Unicodeには、同じ文字を異なるバイトシーケンスで表現する方法がいくつか存在します。例えば、アクセント付きの文字 ‘é’ は、単一のコードポイント U+00E9 で表現することも、基本文字 ‘e’ (U+0065) と結合文字のアクセント記号 ‘´’ (U+0301) の組み合わせで表現することも可能です。どちらも見た目は同じ ‘é’ ですが、基になるバイトシーケンスは異なります。

“`csharp
string combinedE = “e\u0301”; // e + combining acute accent
string precomposedE = “\u00E9”; // é (precomposed)

Console.WriteLine(combinedE); // é
Console.WriteLine(precomposedE); // é

// 見た目は同じでもバイナリは異なる
Console.WriteLine(string.Equals(combinedE, precomposedE, StringComparison.Ordinal)); // false
“`

このような文字列を比較する際に、単なるバイナリ比較 (Ordinal) を行うと、「見た目は同じなのに一致しない」という問題が発生します。これを解決するには、比較を行う前に文字列を正規化 (Normalize) する必要があります。

.NETでは string.Normalize() メソッドを使用して文字列を正規化できます。主要な正規化フォームには以下の4つがあります。

  • NFC (Normalization Form Canonical Composition): 合成済み文字を優先するフォーム。多くの場面で推奨されるフォームです。(例: e + ´ -> é)
  • NFD (Normalization Form Canonical Decomposition): 分解された文字を優先するフォーム。(例: é -> e + ´)
  • KC (Normalization Form Compatibility Composition): 互換性分解と正準合成を行うフォーム。
  • KD (Normalization Form Compatibility Decomposition): 互換性分解を行うフォーム。

通常、比較においては NFC か NFD のいずれかに統一してから比較を行います。

“`csharp
string combinedE = “e\u0301”; // e + combining acute accent
string precomposedE = “\u00E9”; // é (precomposed)

// 両方をNFCに正規化してから Ordinal で比較
string normalizedCombined = combinedE.Normalize(System.Text.NormalizationForm.C); // NFC
string normalizedPrecomposed = precomposedE.Normalize(System.Text.NormalizationForm.C); // NFC

Console.WriteLine(string.Equals(normalizedCombined, normalizedPrecomposed, StringComparison.Ordinal)); // true

// NFDに正規化してから Ordinal で比較
string normalizedCombined_NFD = combinedE.Normalize(System.Text.NormalizationForm.D); // NFD
string normalizedPrecomposed_NFD = precomposedE.Normalize(System.Text.NormalizationForm.D); // NFD

Console.WriteLine(string.Equals(normalizedCombined_NFD, normalizedPrecomposed_NFD, StringComparison.Ordinal)); // true
“`

注意点:

  • 正規化は追加の処理が必要なため、パフォーマンスコストがかかります。
  • 比較のたびに正規化するのではなく、文字列を入力/保存する時点で特定の正規化フォームに統一しておくと効率が良い場合があります。
  • ファイルシステムによっては特定の正規化フォームを使用するものがあります(例: macOSのHFS+はNFD)。異なるOS間でファイルをやり取りする場合などに注意が必要です。
  • CurrentCultureInvariantCulture を使用した比較メソッドは、内部的に正規化や分解を行うことがありますが、その具体的な挙動はバージョンやOSに依存する可能性があります。予測可能性が必要な場合は、明示的に正規化してから Ordinal 比較を行うのが最も安全です。

5.3. Unicode サロゲートペア

Unicodeには、基本多言語面 (BMP) に含まれない文字(絵文字や古い漢字など)を表現するために、2つの char (UTF-16 コードユニット) を組み合わせて1つの文字を表す「サロゲートペア」という仕組みがあります。

“`csharp
string emoji = “😂”; // サロゲートペアで表現される絵文字
Console.WriteLine(emoji.Length); // 2 (2つのcharで構成されるため)
Console.WriteLine(emoji[0]); // ‘?’ または表示できない文字 (最初のサロゲートコードポイント)
Console.WriteLine(emoji[1]); // ‘?’ または表示できない文字 (2番目のサロゲートコードポイント)

string simpleChar = “a”;
Console.WriteLine(simpleChar.Length); // 1
Console.WriteLine(simpleChar[0]); // ‘a’
“`

string オブジェクトは UTF-16 で内部的に表現されるため、string.Lengthchar の数であり、文字数(人間が認識する1つのグラフィカルクラスター)とは異なる場合があります。

string.Equalsstring.Compare (特に Ordinal) は、これらのサロゲートペアを正しく(つまり2つの char として)扱います。サロゲートペアを含む文字列同士の Ordinal 比較は、基になる UTF-16 コードユニットのシーケンスが完全に一致するかどうかをチェックします。

問題になるのは、文字列を char 配列に分解したり、インデックスでアクセスしたりする場合です。char は16ビットであり、サロゲートペアの片側しか表現できないため、サロゲートペアの途中で分割したり、片側だけを比較したりすると意図しない結果になります。

サロゲートペアを含む文字列を正しく扱うには、文字(グラフィカルクラスター)単位での処理をサポートする System.Globalization.StringInfo クラスを使用するのが安全です。

“`csharp
string complexString = “Hello\u0301 😂”; // Hello + アクセント + 絵文字
Console.WriteLine(complexString.Length); // 8 (H, e, l, l, o, \u0301, ‘?’, ‘?’) – 8 char

System.Globalization.StringInfo si = new System.Globalization.StringInfo(complexString);
Console.WriteLine(si.LengthInTextElements); // 7 (H, e, l, l, o, é, 😂) – 7 text elements (書記素クラスター)

// テキスト要素単位でアクセス
for (int i = 0; i < si.LengthInTextElements; i++)
{
string element = si.GetTextElement(i);
Console.WriteLine($”Element {i}: {element}”);
}
// Output:
// Element 0: H
// Element 1: e
// Element 2: l
// Element 3: l
// Element 4: o
// Element 5: é // combining chars are treated as one element
// Element 6: 😂 // surrogate pair is treated as one element
``
文字列比較自体は
string` のメソッドで行いますが、文字列を前処理したり部分文字列を比較したりする際には、サロゲートペアの存在を考慮する必要が出てくることがあります。

5.4. 前後の空白文字 (Trimming)

ユーザー入力などでは、意図せず文字列の前後に空白文字(スペース、タブ、改行など)が含まれることがあります。これらの空白も文字列の一部として扱われるため、比較結果に影響します。

“`csharp
string strA = ” hello “;
string strB = “hello”;

Console.WriteLine(string.Equals(strA, strB, StringComparison.Ordinal)); // false
“`

前後の空白を無視して比較したい場合は、比較前に Trim(), TrimStart(), TrimEnd() メソッドを使用して空白を除去する必要があります。

“`csharp
string strA = ” hello “;
string strB = “hello”;

Console.WriteLine(string.Equals(strA.Trim(), strB, StringComparison.Ordinal)); // true
“`

5.5. ヌル (null) の扱い

前述の通り、文字列比較メソッドによってヌルの扱いが異なります。

  • == 演算子: ヌル安全です。
  • インスタンスメソッド (someString.Equals(...)): someStringnull の場合、NullReferenceException が発生します。引数が null の場合は Equals(object obj) のオーバーロードが呼ばれ false を返します。
  • 静的メソッド (string.Equals(a, b), string.Equals(a, b, comparisonType)): ヌル安全です。両方 null なら true、片方だけ null なら false を返します。
  • string.Compare(a, b): ヌル安全です。両方 null なら 0、片方だけ null なら非nullの方が大きい (正の値) と判断されます。

コードを書く際は、比較する文字列が null になり得るかを考慮し、ヌル安全な静的メソッドを使用するか、事前にヌルチェックを行うかのどちらかを選択することが重要です。静的 string.Equals はインスタンスメソッド版より安全で簡潔になるため、多くの場面で推奨されます。

“`csharp
string input1 = null;
string input2 = “some string”;

// 危険: NullReferenceException の可能性
// if (input1.Equals(input2)) …

// 安全: 静的 Equals
if (string.Equals(input1, input2)) // false
{
// …
}

// 安全: ヌルチェック
if (input1 != null && input1.Equals(input2)) // false
{
// …
}
“`

5.6. パフォーマンス

文字列比較のパフォーマンスは、使用するメソッドと StringComparison の種類によって大きく異なります。一般的に、カルチャー固有のルールや大文字小文字変換、正規化を伴う比較は、単純なバイナリ比較より時間がかかります。

  • 速度の傾向 (おおよそ): Ordinal > OrdinalIgnoreCase > InvariantCulture >= InvariantCultureIgnoreCase > CurrentCulture >= CurrentCultureIgnoreCase
  • == 演算子や引数なしの Equals は、短い文字列リテラルがインターンされている場合に限り、参照比較になることで非常に高速になることがありますが、これは保証されない挙動であり、これに依存すべきではありません。
  • 大量の文字列を比較する場合や、パフォーマンスがクリティカルな部分では、Ordinal 比較を選択することで性能を向上させられる可能性があります。
  • Span / ReadOnlySpan による比較 (.NET Core / .NET): .NET Core以降では、Span<char>ReadOnlySpan<char> を使用することで、文字列のコピーを発生させずに部分文字列や既存の文字列バッファを直接比較できます。これは非常に高性能なゼロアロケーション比較を可能にします。

    “`csharp
    string longString = “This is a very long string…”;
    ReadOnlySpan span1 = longString.AsSpan(0, 4); // “This”
    ReadOnlySpan span2 = “This”.AsSpan(); // “This”

    // Span同士の比較は Equals を使用
    Console.WriteLine(span1.Equals(span2, StringComparison.Ordinal)); // true

    // string と Span の比較も可能 (新しいstringオブジェクトは生成されない)
    Console.WriteLine(longString.AsSpan(5, 2).Equals(“is”.AsSpan(), StringComparison.Ordinal)); // true
    ``
    パフォーマンスが最重要視される場面では、
    Span` の利用を検討する価値があります。

5.7. セキュリティリスク (Timing Attacks)

パスワードや秘密鍵、トークンなどのセキュリティに関連する文字列を比較する場合、単純な文字列比較メソッド(string.Equals, ==, string.Compare など)をそのまま使用すると、タイミング攻撃 (Timing Attack) に対して脆弱になる可能性があります。

これらの標準的な比較メソッドは、多くの場合、二つの文字列を文字の先頭から順に比較し、最初に異なる文字が見つかった時点で比較を終了し、結果を返します。つまり、「文字列がどこまで一致しているか」によって比較にかかる時間がわずかに異なります。攻撃者はこの時間の差を観測することで、秘密の文字列(パスワードなど)を一文字ずつ推測できる可能性があります。

セキュリティ関連の文字列比較では、比較にかかる時間が文字列の内容に依存しない「定数時間比較 (Constant-time comparison)」を行う必要があります。

.NET Core 2.1 以降および .NET 5 以降では、System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ReadOnlySpan<byte> left, ReadOnlySpan<byte> right) メソッドが提供されています。これはバイト配列用のメソッドですが、文字列をバイト配列に変換して使用できます。

“`csharp
using System.Text;
using System.Security.Cryptography;

string passwordInput = “correctpassword123”; // ユーザーからの入力
string storedPasswordHash = “hashed_correctpassword123”; // DBなどに保存されたハッシュ値

// パスワード比較の場合 (ハッシュ値を比較するのが一般的で安全)
// パスワード文字列自体を直接比較する場合は注意が必要
string secretToken = “mySuperSecretTokenABC”;
string receivedToken = “mySuperSecretTokenABC”; // ネットワーク経由で受信した可能性のあるトークン

// 危険な比較
// if (secretToken == receivedToken) { … }
// if (string.Equals(secretToken, receivedToken, StringComparison.Ordinal)) { … }

// 安全な比較 (Constant-time comparison)
byte[] secretBytes = Encoding.UTF8.GetBytes(secretToken);
byte[] receivedBytes = Encoding.UTF8.GetBytes(receivedToken);

// バイト配列の長さが異なる場合は、攻撃を防ぐため事前にチェックし、
// かつ FixedTimeEquals は常に呼び出すようにする
bool areLengthsEqual = secretBytes.Length == receivedBytes.Length;
bool areContentsEqual = CryptographicOperations.FixedTimeEquals(secretBytes, receivedBytes);

if (areLengthsEqual && areContentsEqual)
{
Console.WriteLine(“トークンが一致しました (定数時間比較)”);
}
else
{
Console.WriteLine(“トークンが一致しません”);
}

// 注意: FixedTimeEquals はバイト配列の比較です。
// 文字列の比較で Timing Attack が問題になるのは、パスワードやAPIキーなど、
// 秘密として扱われる固定の文字列との比較です。
// 一般的なファイルパス比較などで Timing Attack を気にする必要はありません。
``
パスワードそのものを比較することはまれで、通常はパスワードのハッシュを比較しますが、APIキーや秘密のトークンなど、生の値で比較する必要がある場面では、
CryptographicOperations.FixedTimeEquals` のような定数時間比較メソッドの使用を強く検討すべきです。

6. 実践的なシナリオと推奨される方法

これまでの説明を踏まえ、実際のプログラミングにおける一般的なシナリオごとの推奨される文字列比較方法をまとめます。

  1. ユーザーインターフェース (UI) に表示するテキストの比較(ラベル、メッセージなど):

    • 目的: ユーザーの文化圏に合わせた自然な比較。
    • 推奨: StringComparison.CurrentCulture または StringComparison.CurrentCultureIgnoreCase
    • 注意: システム内部処理やセキュリティには使用しない。
  2. システム内部の識別子(ファイルパス、レジストリキー、リソースキー、DBの非表示キーなど)、プログラミング要素(キーワード、変数名など)、プロトコル要素(HTTPヘッダー名など)の比較:

    • 目的: ロケールに依存しない、高速で予測可能な比較。セキュリティリスクの回避。
    • 推奨: StringComparison.Ordinal または StringComparison.OrdinalIgnoreCase
    • 注意:
      • ファイルパスの大文字小文字区別はOSに依存する場合がある(Windowsは通常区別しないが、Linuxは区別する)。クロスプラットフォーム対応では注意が必要。OrdinalIgnoreCase を使う場合もあるが、正規化や特定の文字(例: ドイツ語のß)の扱いに注意。
      • Unix/Linux 環境ではファイルパスは通常大文字小文字を区別するため、Ordinal がより正確な場合が多い。Windows互換が必要なら OrdinalIgnoreCase を使うかもしれないが、正規化されていないアクセント付き文字などの問題は残る。
      • DBのキーなども、DBのCollation設定に合わせて Ordinal を使うとシンプルになることが多い。
  3. 設定ファイルや構成エントリのキーの比較:

    • 目的: アプリケーション全体で一貫性のある比較。
    • 推奨: StringComparison.InvariantCulture または StringComparison.InvariantCultureIgnoreCase
    • 注意: Ordinal でも多くの場合問題ないが、キー名が自然言語に基づいている場合は InvariantCulture がより意図に近いかもしれない。
  4. セキュリティ関連の比較(パスワードの検証、トークンの比較など):

    • 目的: Timing Attack などのセキュリティリスクを回避する。厳密な一致。
    • 推奨: StringComparison.Ordinal を使用し、さらに可能であれば System.Security.Cryptography.CryptographicOperations.FixedTimeEquals による定数時間比較を検討する。
    • 注意: パスワード自体を保存・比較することは避け、ハッシュを保存・比較するのが一般的。ハッシュの比較にも Timing Attack のリスクは存在するため、定数時間比較メソッドの使用を検討する。
  5. 検索機能(特定の単語を含むか、などで絞り込む):

    • 目的: ユーザーの期待に沿った比較(大文字小文字、アクセント記号などを無視)。
    • 推奨: StringComparison.CurrentCultureIgnoreCase または StringComparison.InvariantCultureIgnoreCase。正規化も検討が必要。
    • 注意: より高度な検索には、全文検索エンジン(Lucene.NETなど)や専用のライブラリを検討すべき。
  6. データベースとの連携:

    • 目的: アプリケーション側とデータベース側の比較ルールを一致させる。
    • 推奨: データベースの Collation 設定を確認し、それに合わせた StringComparison を使用する。多くの場合は Ordinal がデータベースのバイナリ比較(BIN Collationなど)と一致させやすく、シンプル。大文字小文字を区別しない場合は、アプリケーション側で OrdinalIgnoreCase を使うか、DB側で適切な Collation を設定する。
    • 注意: DBの Collation とC#側の StringComparison が一致しないと、アプリケーションとDBで検索結果やソート順が異なるバグの原因になる。

重要な習慣:

  • 常に StringComparison を明示的に指定する。
  • 比較の「目的」を明確にする。
  • システム内部やセキュリティ関連では Ordinal を第一に検討する。

7. 高度なトピック

7.1. CompareInfo クラス

System.Globalization.CompareInfo クラスは、特定のカルチャーにおける文字列比較や検索(IndexOf, StartsWithなど)のためのより詳細なルールを提供します。StringComparison 列挙体だけでは表現できない、細かな制御が可能です。

例えば、アクセント記号や記号を無視するかどうか、ひらがなとカタカナを区別するかどうかなどを指定できます。これは CompareOptions 列挙体を使って指定します。

“`csharp
using System.Globalization;

string strA = “résumé”;
string strB = “resume”;

// InvariantCultureIgnoreCase はアクセントも無視する (ことが多い)
Console.WriteLine(string.Equals(strA, strB, StringComparison.InvariantCultureIgnoreCase)); // true

// CompareInfo を使うともっと細かく制御できる
CompareInfo ci = CultureInfo.GetCultureInfo(“en-US”).CompareInfo; // 特定のカルチャーのCompareInfoを取得

// IgnoreSymbols は記号も無視
Console.WriteLine(ci.Compare(strA, strB, CompareOptions.IgnoreCase | CompareOptions.IgnoreSymbols)); // 0 (同じとみなされる)

// アクセントは無視するが記号は無視しない場合
Console.WriteLine(ci.Compare(strA, strB, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpacing)); // 0 (同じとみなされる)

string kana1 = “ひらがな”;
string kana2 = “ヒラガナ”;
CompareInfo jaCi = CultureInfo.GetCultureInfo(“ja-JP”).CompareInfo;

// デフォルトでは区別しない (CompareOptions.None と Culture の組み合わせ)
Console.WriteLine(jaCi.Compare(kana1, kana2)); // 0

// KanaConversion を指定すると区別する
Console.WriteLine(jaCi.Compare(kana1, kana2, CompareOptions.None)); // 0
Console.WriteLine(jaCi.Compare(kana1, kana2, CompareOptions.IgnoreKanaType)); // 0 (ひらがな/カタカナを無視)
Console.WriteLine(jaCi.Compare(kana1, kana2, CompareOptions.None & ~CompareOptions.IgnoreKanaType)); // 0 (デフォルトと同じ)
// 通常、日本語では KanaType を無視しない比較は CompareOptions.None
// ただし、明示的に None を指定した場合の内部挙動は CultureInfo による

``CompareInfoは、string.Equalsstring.Compareが内部的に使用しているクラスです。より詳細な比較ルールが必要な場合や、特定のカルチャーでの正確な挙動が必要な場合に使用を検討します。ただし、多くの場合、StringComparison` 列挙体で十分です。

7.2. StringBuilder と比較

StringBuilder オブジェクトは、文字列の編集を効率的に行うためのクラスです。string と異なり可変です。

StringBuilder オブジェクトを直接比較する場合、Equals メソッドは object クラスの Equals (参照比較) を継承しているため、内容ではなく参照が同じかどうかを比較します。

“`csharp
using System.Text;

StringBuilder sb1 = new StringBuilder(“hello”);
StringBuilder sb2 = new StringBuilder(“hello”);
StringBuilder sb3 = sb1;

Console.WriteLine(sb1.Equals(sb2)); // false (異なるオブジェクト)
Console.WriteLine(sb1.Equals(sb3)); // true (同じオブジェクト)
``StringBuilderの内容を比較したい場合は、ToString()メソッドでstring` に変換してから比較を行う必要があります。

“`csharp
StringBuilder sb1 = new StringBuilder(“hello”);
StringBuilder sb2 = new StringBuilder(“hello”);

Console.WriteLine(string.Equals(sb1.ToString(), sb2.ToString(), StringComparison.Ordinal)); // true
``
ただし、
ToString()は新しいstringオブジェクトを生成するため、パフォーマンスが重要なループ内で頻繁に呼び出すのは避けるべきです。大量のStringBuilderの比較が必要な場合は、内容をバイト配列などに変換して比較するか、Span` を利用する方法を検討します。

7.3. 構造体やクラス内の文字列フィールドの比較 (IEquatable<T>, GetHashCode)

独自の構造体やクラスが文字列フィールドを持っている場合、その型の等価性(Equals メソッドや == 演算子)を定義する際には、内部の文字列フィールドの比較に注意が必要です。

特に、Equals メソッドをオーバーライドしたり、IEquatable<T> インターフェイスを実装したりする場合、その等価性の定義は内部フィールドの値に基づいているべきです。文字列フィールドの場合は、前述の注意点を考慮して適切な StringComparison を使用する必要があります。

“`csharp
public struct ProductId : IEquatable
{
private readonly string _id;

public ProductId(string id)
{
    // IDは大文字小文字を区別しないシステム内部識別子と想定
    _id = id?.Trim().ToUpperInvariant(); // 標準化して保持
}

public string Value => _id;

// IEquatable<ProductId> の実装
public bool Equals(ProductId other)
{
    // 内部で標準化済みなので Ordinal で安全かつ高速に比較可能
    return string.Equals(_id, other._id, StringComparison.Ordinal);
}

// object.Equals のオーバーライド
public override bool Equals(object obj)
{
    return obj is ProductId other && Equals(other);
}

// == 演算子のオーバーロード (Equals メソッドを呼び出す)
public static bool operator ==(ProductId left, ProductId right)
{
    return left.Equals(right);
}

public static bool operator !=(ProductId left, ProductId right)
{
    return !(left == right);
}

// GetHashCode のオーバーライド
// Equals が true を返す二つのオブジェクトは同じハッシュコードを返す必要がある
public override int GetHashCode()
{
    // 内部の _id (_idはToUpperInvariant済み) に対してハッシュコードを計算
    // string.GetHashCode() はデフォルトで Ordinal に近いハッシュを生成するが、
    // 明示的に StringComparer.Ordinal.GetHashCode() を使用すると安全
    return StringComparer.Ordinal.GetHashCode(_id);
}

public override string ToString()
{
    return _id ?? string.Empty;
}

}
``
この例のように、等価性の定義 (
Equals,==) とハッシュコードの計算 (GetHashCode) はセットで考慮する必要があります。Equalstrueを返す二つのオブジェクトは、必ず同じGetHashCodeの戻り値を返すように実装しなければなりません。文字列フィールドのハッシュコードを取得する場合も、等価性比較で使用するStringComparisonに対応したStringComparerGetHashCodeメソッドを使用するのが安全です(例:StringComparer.Ordinal.GetHashCode(str),StringComparer.InvariantCultureIgnoreCase.GetHashCode(str)` など)。

8. コード例集

これまでに解説した内容を具体的なコード例で示します。

“`csharp
using System;
using System.Globalization;
using System.Text;
using System.Security.Cryptography; // ConstantTimeEquals 用 (.NET Core / .NET)

public class StringComparisonExamples
{
public static void Main(string[] args)
{
Console.WriteLine(“— 基本的な比較 —“);
string s1 = “hello”;
string s2 = “hello”;
string s3 = “Hello”;
string s4 = “world”;
string s5 = null;
string s6 = “”;

    Console.WriteLine($"s1 ({s1}) == s2 ({s2}): {s1 == s2}"); // true (値比較)
    Console.WriteLine($"s1 ({s1}) == s3 ({s3}): {s1 == s3}"); // false (大文字小文字区別)
    Console.WriteLine($"s1 ({s1}).Equals(s2): {s1.Equals(s2)}"); // true (値比較, インスタンスメソッド)
    Console.WriteLine($"string.Equals(s1, s3): {string.Equals(s1, s3)}"); // false (大文字小文字区別, 静的メソッド)
    Console.WriteLine($"string.Equals(s5, null): {string.Equals(s5, null)}"); // true (静的メソッドはnull安全)
    Console.WriteLine($"string.Equals(s5, s6): {string.Equals(s5, s6)}"); // false (静的メソッドはnull安全)
    // Console.WriteLine($"s5.Equals(s6): {s5.Equals(s6)}"); // NullReferenceException!

    Console.WriteLine("\n--- StringComparison を使用した比較 ---");
    string sA = "Staße"; // ドイツ語のエスツェット
    string sB = "strasse";
    string sC = "STRASSE";
    string sD = "Résumé";
    string sE = "Resume";

    Console.WriteLine($"'{sA}' vs '{sB}' (Ordinal): {string.Equals(sA, sB, StringComparison.Ordinal)}"); // false
    Console.WriteLine($"'{sB}' vs '{sC}' (OrdinalIgnoreCase): {string.Equals(sB, sC, StringComparison.OrdinalIgnoreCase)}"); // false (ßの問題)
    Console.WriteLine($"'{sB}' vs '{sC}' (InvariantCultureIgnoreCase): {string.Equals(sB, sC, StringComparison.InvariantCultureIgnoreCase)}"); // true (多くの言語で s==S として扱われるため)

    // ドイツ語カルチャーでの比較の例
    var germanCulture = new CultureInfo("de-DE");
    var currentCulture = System.Threading.Thread.CurrentThread.CurrentCulture;
    System.Threading.Thread.CurrentThread.CurrentCulture = germanCulture;

    Console.WriteLine($"'{sA}' vs '{sC}' (CurrentCultureIgnoreCase, de-DE): {string.Equals(sA, sC, StringComparison.CurrentCultureIgnoreCase)}"); // true (ドイツ語で ß は SS とみなされる)

    System.Threading.Thread.CurrentThread.CurrentCulture = currentCulture; // 元に戻す

    Console.WriteLine($"'{sD}' vs '{sE}' (Ordinal): {string.Equals(sD, sE, StringComparison.Ordinal)}"); // false
    Console.WriteLine($"'{sD}' vs '{sE}' (InvariantCultureIgnoreCase): {string.Equals(sD, sE, StringComparison.InvariantCultureIgnoreCase)}"); // true (アクセントが無視される)


    Console.WriteLine("\n--- 辞書式順序での比較 (Compare) ---");
    string apple = "apple";
    string banana = "banana";
    string Apple = "Apple";

    Console.WriteLine($"'{apple}' vs '{banana}' (Ordinal): {string.Compare(apple, banana, StringComparison.Ordinal)}"); // 負の値
    Console.WriteLine($"'{banana}' vs '{apple}' (Ordinal): {string.Compare(banana, apple, StringComparison.Ordinal)}"); // 正の値
    Console.WriteLine($"'{apple}' vs '{apple}' (Ordinal): {string.Compare(apple, apple, StringComparison.Ordinal)}"); // 0

    Console.WriteLine($"'{apple}' vs '{Apple}' (Ordinal): {string.Compare(apple, Apple, StringComparison.Ordinal)}"); // 正の値 ('a' > 'A')
    Console.WriteLine($"'{apple}' vs '{Apple}' (OrdinalIgnoreCase): {string.Compare(apple, Apple, StringComparison.OrdinalIgnoreCase)}"); // 0


    Console.WriteLine("\n--- 正規化の例 ---");
    string combinedE = "e\u0301"; // e + combining acute accent
    string precomposedE = "\u00E9"; // é (precomposed)

    Console.WriteLine($"'{combinedE}' vs '{precomposedE}' (Ordinal): {string.Equals(combinedE, precomposedE, StringComparison.Ordinal)}"); // false

    string normalizedCombined = combinedE.Normalize(NormalizationForm.C);
    string normalizedPrecomposed = precomposedE.Normalize(NormalizationForm.C);
    Console.WriteLine($"'{combinedE}'(NFC) vs '{precomposedE}'(NFC) (Ordinal): {string.Equals(normalizedCombined, normalizedPrecomposed, StringComparison.Ordinal)}"); // true


    Console.WriteLine("\n--- Trim の例 ---");
    string padded = "  hello  ";
    string notPadded = "hello";

    Console.WriteLine($"'{padded}' vs '{notPadded}' (Ordinal): {string.Equals(padded, notPadded, StringComparison.Ordinal)}"); // false
    Console.WriteLine($"'{padded}'.Trim() vs '{notPadded}' (Ordinal): {string.Equals(padded.Trim(), notPadded, StringComparison.Ordinal)}"); // true


    Console.WriteLine("\n--- サロゲートペアの例 ---");
    string emoji = "😂"; // U+1F602, UTF-16 では 2 chars
    string multiChar = "é"; // U+00E9, UTF-16 では 1 char
    string combinedMultiChar = "e\u0301"; // U+0065 + U+0301, UTF-16 では 2 chars

    Console.WriteLine($"'{emoji}'.Length: {emoji.Length}"); // 2
    Console.WriteLine($"'{multiChar}'.Length: {multiChar.Length}"); // 1
    Console.WriteLine($"'{combinedMultiChar}'.Length: {combinedMultiChar.Length}"); // 2

    // Ordinal 比較は UTF-16 コードユニット単位で行われる
    Console.WriteLine($"'{emoji}' == '{emoji}' (Ordinal): {string.Equals(emoji, emoji, StringComparison.Ordinal)}"); // true
    Console.WriteLine($"'{combinedMultiChar}' == '{precomposedE}' (Ordinal): {string.Equals(combinedMultiChar, precomposedE, StringComparison.Ordinal)}"); // false


    Console.WriteLine("\n--- ConstantTimeEquals の例 (セキュリティ関連) ---");
    string secret = "mysecretkey123";
    string input = "mysecretkey123";
    string wrongInput = "wrongkey";

    byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
    byte[] inputBytes = Encoding.UTF8.GetBytes(input);
    byte[] wrongInputBytes = Encoding.UTF8.GetBytes(wrongInput);

    // .NET Core / .NET 5+ で利用可能
    // 常に長さをチェックする
    bool lengthsMatch1 = secretBytes.Length == inputBytes.Length;
    bool contentMatches1 = CryptographicOperations.FixedTimeEquals(secretBytes, inputBytes);
    Console.WriteLine($"'{secret}' vs '{input}' : Lengths match = {lengthsMatch1}, Content matches = {contentMatches1}"); // true, true

    bool lengthsMatch2 = secretBytes.Length == wrongInputBytes.Length;
    // 長さが一致しない場合でも FixedTimeEquals は呼び出す (Timing Attack対策)
    bool contentMatches2 = CryptographicOperations.FixedTimeEquals(secretBytes, wrongInputBytes);
    Console.WriteLine($"'{secret}' vs '{wrongInput}' : Lengths match = {lengthsMatch2}, Content matches = {contentMatches2}"); // false, false
}

}
“`

9. まとめ

C#における文字列比較は、その用途と性質によって適切な方法を選択することが極めて重要です。単に == 演算子や引数なしの Equals を使うだけでは、大文字小文字、ロケール、正規化、そしてセキュリティといった様々な問題を見落とす可能性があります。

この記事を通じて、以下の重要なポイントを理解していただけたことを願います。

  1. StringComparison 列挙体の重要性: どのような比較ルール(大文字小文字を区別するか、どのカルチャーを使うか、バイナリ比較か)を使用するかを明示的に指定することで、コードの意図を明確にし、予測可能で一貫した挙動を実現できます。
  2. Ordinal の推奨: システム内部の識別子、ファイルパス、セキュリティ関連など、厳密な一致と予測可能性が求められる場面では、StringComparison.Ordinal が最も安全で高速な選択肢です。
  3. ロケールとカルチャーの影響: CurrentCulture を使用した比較は、ユーザー向け表示など言語的に自然な比較に適していますが、システム内部では予期せぬ結果やセキュリティリスクにつながる可能性があります。
  4. 正規化と Trim の必要性: 見た目は同じでもバイナリ表現が異なる文字列(正規化)や、前後の空白は、比較結果に影響を与えます。必要に応じて Normalize()Trim() を適用することを検討します。
  5. ヌル安全性の考慮: == や静的 string.Equals を使用することで、ヌル参照例外を回避できます。
  6. セキュリティ(Timing Attack): パスワードや秘密鍵など、セキュリティが重要な文字列の比較では、標準的な比較メソッドは危険な場合があります。定数時間比較メソッドの使用を検討します。
  7. パフォーマンス: 使用する StringComparison によってパフォーマンスは異なります。大量の比較を行う場合は、より高速な OrdinalSpan の利用を検討します。

常に StringComparison を指定する習慣をつけ、その比較が「なぜその StringComparison なのか」を意識することで、より堅牢で安全なC#アプリケーションを開発することができます。

文字列はプログラミングの基本ですが、その比較は奥深く、多くの考慮すべき点があります。これらの知識を日々のコーディングに活かしていただければ幸いです。


コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール