LINQ

【C#】LINQでスマートに文字列検索する方法──Contains・StartsWith・Regex活用術

C#のLINQなら配列やListなどの文字列コレクションに対し、Where(s => s.Contains("key", StringComparison.OrdinalIgnoreCase))のようにラムダ式で意図を直感的に記述でき、部分一致・前方一致・正規表現検索を柔軟に併用できるので、検索処理が短く読みやすく保守もしやすくなります。

目次から探す
  1. LINQによる文字列検索の基礎
  2. 部分一致検索 ― Contains
  3. 前方一致検索 ― StartsWith
  4. 後方一致検索 ― EndsWith
  5. 正規表現を組み合わせた高度検索
  6. 複数条件・複合検索テクニック
  7. クエリ構文とメソッド構文の比較
  8. 大量データとパフォーマンス最適化
  9. カスタムIEqualityComparerの利用
  10. 拡張メソッドで検索ロジックを再利用
  11. エラー・例外への対処
  12. 実践シナリオ別サンプル
  13. バージョン別差異と最新機能
  14. まとめ

LINQによる文字列検索の基礎

LINQの位置付けとメリット

LINQ(Language Integrated Query)は、C#に標準で組み込まれているクエリ機能で、配列やリスト、データベースなどさまざまなデータソースに対して統一的な方法でデータ操作を行えます。

文字列検索においてもLINQを活用することで、コードの可読性が向上し、複雑な条件でも直感的に記述できるのが大きなメリットです。

たとえば、従来のループ処理で文字列の検索を行う場合、条件分岐やインデックス管理が必要でコードが冗長になりがちです。

一方、LINQを使うとWhereSelectなどのメソッドを組み合わせて、まるでSQLのように簡潔に検索条件を表現できます。

また、LINQは遅延実行を採用しているため、必要なデータだけを効率的に取得できる点も魅力です。

これにより、大量の文字列データを扱う場合でもパフォーマンスを抑えつつ柔軟な検索が可能になります。

コレクション対象の前提条件

LINQで文字列検索を行う際、対象となるデータはIEnumerable<T>を実装している必要があります。

文字列自体はIEnumerable<char>を実装しているため、文字単位での検索はそのままLINQのメソッドを使えます。

一方、複数の文字列をまとめたリストや配列を検索する場合は、List<string>string[]などのコレクションが対象になります。

これらはIEnumerable<string>を実装しているため、LINQのWhereAnyAllなどのメソッドを使って条件に合う文字列を抽出できます。

ただし、LINQのメソッドは拡張メソッドとして提供されているため、対象の型がIEnumerable<T>を満たしていない場合は利用できません。

たとえば、単一の文字列に対して文字列全体の部分一致を調べる場合は、string.Containsなどのメソッドを直接使うほうが適切です。

メソッド構文での最小構成

LINQのメソッド構文は、拡張メソッドをチェーンして記述するスタイルです。

文字列検索の最小構成としては、Whereメソッドを使って条件に合う要素を絞り込みます。

以下は、文字列のリストから「apple」を含む文字列を抽出する例です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var fruits = new List<string> { "apple", "banana", "pineapple", "grape" };
        // "apple"を含む文字列を抽出
        var result = fruits.Where(fruit => fruit.Contains("apple"));
        foreach (var item in result)
        {
            Console.WriteLine(item);
        }
    }
}
apple
pineapple

このコードでは、fruitsリストの各要素に対してContains("apple")を評価し、条件を満たす要素だけをresultに抽出しています。

Whereは遅延実行なので、foreachで列挙するタイミングで実際の検索が行われます。

メソッド構文はラムダ式を使うため、条件式を柔軟に記述できるのが特徴です。

複雑な条件も複数のメソッドを組み合わせて表現しやすいです。

クエリ構文での最小構成

LINQにはもう一つ、SQLに似たクエリ構文があります。

こちらはfromwhereselectなどのキーワードを使って記述します。

文字列検索でも同様に使えます。

先ほどの例をクエリ構文で書き換えると以下のようになります。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var fruits = new List<string> { "apple", "banana", "pineapple", "grape" };
        // クエリ構文で"apple"を含む文字列を抽出
        var result = from fruit in fruits
                     where fruit.Contains("apple")
                     select fruit;
        foreach (var item in result)
        {
            Console.WriteLine(item);
        }
    }
}
apple
pineapple

クエリ構文はSQLに慣れている方にとっては直感的で読みやすいスタイルです。

特に複数の条件や結合を行う場合に見通しが良くなります。

ただし、単純な検索ではメソッド構文のほうが短く書けることが多いです。

どちらを使うかは好みやチームのコーディング規約に合わせて選ぶとよいでしょう。

以上がLINQを使った文字列検索の基礎です。

LINQの位置付けやメリット、対象となるコレクションの条件、そしてメソッド構文とクエリ構文の最小限の使い方を理解しておくと、以降の応用的な検索方法もスムーズに習得できます。

部分一致検索 ― Contains

大文字小文字を区別する比較

C#の文字列検索で最も基本的な部分一致はContainsメソッドを使う方法です。

デフォルトのstring.Containsは大文字小文字を区別して検索を行います。

つまり、検索対象の文字列に完全に一致する部分が存在しなければfalseを返します。

以下の例では、大文字の「Hello」と小文字の「hello」で検索した場合の違いを示しています。

using System;
class Program
{
    static void Main()
    {
        string text = "Hello World";
        // 大文字小文字を区別するContains
        bool result1 = text.Contains("Hello");
        bool result2 = text.Contains("hello");
        Console.WriteLine($"Contains 'Hello': {result1}");
        Console.WriteLine($"Contains 'hello': {result2}");
    }
}
Contains 'Hello': True
Contains 'hello': False

このように、Containsはデフォルトでケースセンシティブ(大文字小文字を区別)です。

大文字小文字を区別したい場合はこのまま使えば問題ありませんが、ユーザー入力などでケースを無視したい場合は別の方法を検討する必要があります。

StringComparisonを用いたケースインセンシティブ

.NET Core 2.1以降および.NET Standard 2.1以降では、string.ContainsStringComparisonを指定できるオーバーロードが追加されました。

これを使うと大文字小文字を区別しない検索が簡単に行えます。

using System;
class Program
{
    static void Main()
    {
        string text = "Hello World";
        // 大文字小文字を区別しないContains
        bool result = text.Contains("hello", StringComparison.OrdinalIgnoreCase);
        Console.WriteLine($"Contains 'hello' (ignore case): {result}");
    }
}
Contains 'hello' (ignore case): True

StringComparison.OrdinalIgnoreCaseはバイト単位で大文字小文字を無視して比較するため高速で、文化依存の影響を受けにくいです。

ほかにもCurrentCultureIgnoreCaseInvariantCultureIgnoreCaseなどのオプションがあります。

CurrentCultureとOrdinalの違い

StringComparisonには複数の比較方法があり、特にCurrentCulture系とOrdinal系で挙動が異なります。

比較方法特徴用途例
CurrentCulture現在のカルチャ(ロケール)に基づく比較。言語特有の大文字小文字変換を考慮。ユーザー向けの文字列表示や検索
CurrentCultureIgnoreCaseカルチャに基づき大文字小文字を無視して比較。ユーザー入力の柔軟な検索
Ordinalバイト単位の比較。文化依存しない。内部処理や高速比較
OrdinalIgnoreCaseバイト単位で大文字小文字を無視して比較。パフォーマンス重視の検索
InvariantCulture文化に依存しない固定のカルチャで比較。ロケールに依存しない処理
InvariantCultureIgnoreCase固定カルチャで大文字小文字を無視して比較。ロケール非依存の柔軟検索

たとえば、トルコ語の「i」と「İ」のように文化によって大文字小文字の扱いが異なるケースでは、CurrentCultureIgnoreCaseを使うと期待通りの結果になることがあります。

一方、単純にバイト列として比較したい場合はOrdinalIgnoreCaseが適しています。

CultureInfoを明示指定するパターン

StringComparisonはあらかじめ用意された比較方法を指定しますが、より細かく文化情報を指定したい場合はCultureInfoを使ってCompareInfoIndexOfメソッドを利用します。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string text = "straße";
        string search = "STRASSE";
        // ドイツ語のカルチャを指定して大文字小文字を無視して検索
        var culture = new CultureInfo("de-DE");
        int index = culture.CompareInfo.IndexOf(text, search, CompareOptions.IgnoreCase);
        Console.WriteLine(index >= 0
            ? $"Found '{search}' in '{text}' at index {index}"
            : $"'{search}' not found in '{text}'");
    }
}
Found 'STRASSE' in 'straße' at index 0

この例では、ドイツ語の特殊文字「ß」と「ss」を等価とみなす文化依存の比較を行っています。

CompareInfo.IndexOfは部分一致検索に使え、CompareOptionsで大文字小文字の無視やアクセントの無視など細かい指定が可能です。

null・空文字を含むコレクションの扱い

LINQで文字列の部分一致検索を行う際、検索対象のコレクションにnullや空文字列が含まれている場合は注意が必要です。

string.Containsnullに対して呼び出すとNullReferenceExceptionが発生します。

以下の例では、nullや空文字列を含むリストから「test」を含む文字列を検索しています。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var list = new List<string> { "test", null, "", "testing", "example" };
        string searchTerm = "test";
        // nullチェックを入れて安全に検索
        var result = list.Where(s => !string.IsNullOrEmpty(s) && s.Contains(searchTerm));
        foreach (var item in result)
        {
            Console.WriteLine(item);
        }
    }
}
test
testing

string.IsNullOrEmptynullや空文字を除外してからContainsを呼び出すことで例外を防げます。

空文字列はContainsの引数が空文字の場合は常にtrueを返すため、意図しない結果になることもあります。

もし空文字列を検索対象に含めたい場合は条件を調整してください。

nullは必ず除外するのが安全です。

Containsをラップする拡張メソッド設計

ContainsメソッドにStringComparisonを指定できる環境であっても、毎回引数を指定するのは冗長になることがあります。

そこで、よく使う比較方法をラップした拡張メソッドを作成すると便利です。

以下は、大文字小文字を区別しない部分一致検索を簡単に呼び出せる拡張メソッドの例です。

using System;
public static class StringExtensions
{
    public static bool ContainsIgnoreCase(this string source, string toCheck)
    {
        if (source == null || toCheck == null)
            return false;
        return source.Contains(toCheck, StringComparison.OrdinalIgnoreCase);
    }
}

この拡張メソッドを使うと、以下のようにシンプルに呼び出せます。

using System;
class Program
{
    static void Main()
    {
        string text = "Hello World";
        bool result = text.ContainsIgnoreCase("hello");
        Console.WriteLine(result);
    }
}
True

拡張メソッド内でnullチェックを行うことで、呼び出し側のコードがすっきりし、例外のリスクも減らせます。

用途に応じてCultureInfoを引数に追加したり、空文字列の扱いをカスタマイズすることも可能です。

このように、Containsのラップはコードの再利用性と可読性を高める効果があります。

前方一致検索 ― StartsWith

プレフィックス検索の内部動作

StartsWithメソッドは、文字列が指定した接頭辞(プレフィックス)で始まっているかどうかを判定します。

内部的には、対象文字列の先頭から指定した文字列の長さ分だけを比較し、一致すればtrueを返します。

この比較は、StringComparisonの指定により大文字小文字の区別や文化依存の有無が変わります。

たとえば、StringComparison.Ordinalを使うとバイト単位での比較となり高速ですが、文化依存の文字の違いは無視されます。

一方、CurrentCultureを指定すると、言語特有の大文字小文字変換や特殊文字の扱いが考慮されます。

StartsWithは部分文字列の先頭一致を効率的に判定するために最適化されており、文字列の長さが検索文字列より短い場合は即座にfalseを返します。

パフォーマンスとIndexOf(0)の比較

StartsWithと似た動作をIndexOfメソッドで代替することも可能です。

IndexOfは指定した文字列が最初に現れる位置を返しますが、0を返せば先頭一致と同じ意味になります。

以下は両者の比較例です。

using System;
class Program
{
    static void Main()
    {
        string text = "HelloWorld";
        string prefix = "Hello";
        bool startsWith = text.StartsWith(prefix);
        bool indexOfZero = text.IndexOf(prefix) == 0;
        Console.WriteLine($"StartsWith: {startsWith}");
        Console.WriteLine($"IndexOf(0): {indexOfZero}");
    }
}
StartsWith: True
IndexOf(0): True

ただし、StartsWithは先頭一致専用に最適化されているため、パフォーマンス面で優位です。

IndexOfは文字列全体を検索するため、先頭以外の位置も調べる必要があり、無駄な処理が発生します。

また、StartsWithStringComparisonを指定できるオーバーロードがあり、大文字小文字の区別や文化依存の比較が簡単に行えます。

IndexOfでもStringComparisonを指定できますが、先頭一致判定のために== 0の条件を付ける必要があり、コードの可読性がやや劣ります。

複数プレフィックスをまとめて判定する方法

複数のプレフィックスのいずれかで始まるかを判定したい場合、LINQのAnyメソッドを使うと簡潔に書けます。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        string text = "application";
        var prefixes = new List<string> { "app", "pre", "sub" };
        bool startsWithAny = prefixes.Any(prefix => text.StartsWith(prefix));
        Console.WriteLine($"Starts with any prefix: {startsWithAny}");
    }
}
Starts with any prefix: True

このコードは、prefixesの中にtextの先頭と一致する文字列が一つでもあればtrueを返します。

StartsWithStringComparisonを指定したい場合は、ラムダ式内で明示的に指定してください。

bool startsWithAnyIgnoreCase = prefixes.Any(prefix => text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));

複数のプレフィックスをまとめて判定することで、条件分岐を減らしコードの見通しを良くできます。

文化依存の注意点

StartsWithStringComparisonの指定によって文化依存の挙動が変わります。

特にCurrentCultureCurrentCultureIgnoreCaseを使う場合は、実行環境のロケール設定に影響されるため注意が必要です。

たとえば、トルコ語の「i」と「İ」のように、文化によって大文字小文字の変換ルールが異なる文字が存在します。

StartsWithで大文字小文字を無視して比較する際に、期待した結果と異なることがあります。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string text = "İstanbul";
        string prefix = "i";
        bool resultCurrentCulture = text.StartsWith(prefix, true, new CultureInfo("tr-TR"));
        bool resultOrdinalIgnoreCase = text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
        Console.WriteLine($"CurrentCultureIgnoreCase: {resultCurrentCulture}");
        Console.WriteLine($"OrdinalIgnoreCase: {resultOrdinalIgnoreCase}");
    }
}
CurrentCultureIgnoreCase: True
OrdinalIgnoreCase: False

この例では、トルコ語の文化情報を指定した場合はtrueとなり、バイト単位のOrdinalIgnoreCaseではfalseになります。

文化依存の比較を使う場合は、対象の文字列やユーザーのロケールに合わせて適切なStringComparisonCultureInfoを選択してください。

また、文化依存の比較はパフォーマンスがやや低下することも念頭に置いておくとよいでしょう。

文化に依存しない高速な比較が必要な場合はOrdinal系の比較を使うのがおすすめです。

後方一致検索 ― EndsWith

ファイル拡張子フィルタの典型例

ファイル名の拡張子でフィルタリングする際に、EndsWithメソッドは非常に便利です。

たとえば、特定の拡張子を持つファイルだけを抽出したい場合、文字列の末尾がその拡張子であるかを判定します。

以下は、拡張子が「.txt」または「.csv」のファイルをリストから抽出する例です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var files = new List<string>
        {
            "report.txt",
            "data.csv",
            "image.png",
            "document.pdf",
            "notes.TXT"
        };
        var filteredFiles = files.Where(file =>
            file.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
            file.EndsWith(".csv", StringComparison.OrdinalIgnoreCase));
        foreach (var file in filteredFiles)
        {
            Console.WriteLine(file);
        }
    }
}
report.txt
data.csv
notes.TXT

この例では、EndsWithStringComparison.OrdinalIgnoreCaseを指定して大文字小文字を区別せずに拡張子を判定しています。

これにより、「.TXT」や「.txt」などの違いを気にせずにフィルタリングできます。

ケースインセンシティブでの比較

EndsWithはデフォルトで大文字小文字を区別しますが、StringComparisonを指定することでケースインセンシティブ(大文字小文字を無視)な比較が可能です。

特にファイル拡張子のように大文字小文字の違いが無視されることが多い文字列では、OrdinalIgnoreCaseを使うのが一般的です。

using System;
class Program
{
    static void Main()
    {
        string filename = "example.TXT";
        bool isTxt = filename.EndsWith(".txt", StringComparison.OrdinalIgnoreCase);
        bool isTxtCaseSensitive = filename.EndsWith(".txt");
        Console.WriteLine($"Case-insensitive check: {isTxt}");
        Console.WriteLine($"Case-sensitive check: {isTxtCaseSensitive}");
    }
}
Case-insensitive check: True
Case-sensitive check: False

ケースインセンシティブで比較することで、ユーザーが大文字小文字を混在させて入力しても正しく判定できます。

OrdinalIgnoreCaseはバイト単位で大文字小文字を無視するため高速であり、文化依存の影響も受けにくいです。

Suffix集合をDictionaryで最適化

複数の拡張子やサフィックスを判定する場合、EndsWithを複数回呼び出すとパフォーマンスに影響が出ることがあります。

特に大量のファイル名を処理する際は、判定対象のサフィックスを効率的に管理する工夫が必要です。

DictionaryHashSetを使ってサフィックスの集合を管理し、検索時に高速に判定する方法があります。

ただし、EndsWithは文字列の末尾を比較するため、単純にHashSetContainsを使うことはできません。

そこで、サフィックスの長さごとにグループ化し、対象文字列の末尾を切り出してHashSetで判定する方法が有効です。

以下は、複数の拡張子を効率的に判定するサンプルです。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var suffixes = new List<string> { ".txt", ".csv", ".log", ".md" };
        // サフィックスを長さごとにグループ化し、HashSetで管理
        var suffixGroups = suffixes
            .GroupBy(s => s.Length)
            .ToDictionary(g => g.Key, g => new HashSet<string>(g, StringComparer.OrdinalIgnoreCase));
        var files = new List<string>
        {
            "report.txt",
            "data.csv",
            "image.png",
            "notes.md",
            "error.LOG",
            "readme.MD"
        };
        var filteredFiles = files.Where(file =>
        {
            foreach (var group in suffixGroups)
            {
                int len = group.Key;
                if (file.Length >= len)
                {
                    string end = file.Substring(file.Length - len, len);
                    if (group.Value.Contains(end))
                    {
                        return true;
                    }
                }
            }
            return false;
        });
        foreach (var file in filteredFiles)
        {
            Console.WriteLine(file);
        }
    }
}
report.txt
data.csv
notes.md
error.LOG
readme.MD

この方法では、サフィックスの長さごとにHashSetを作成し、対象文字列の末尾を切り出して高速に存在チェックを行います。

StringComparer.OrdinalIgnoreCaseを使うことで大文字小文字を無視した比較が可能です。

大量のファイル名や多くの拡張子を扱う場合に、EndsWithを繰り返すよりも効率的に判定できるため、パフォーマンス向上に役立ちます。

正規表現を組み合わせた高度検索

Regex.IsMatchとWhereの併用

LINQのWhereメソッドと正規表現のRegex.IsMatchを組み合わせることで、複雑なパターンにマッチする文字列を効率的に抽出できます。

Regex.IsMatchは文字列が正規表現パターンにマッチするかどうかを判定し、Whereの条件式として使うことでフィルタリングが可能です。

以下は、メールアドレスの形式にマッチする文字列だけを抽出する例です。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        var texts = new List<string>
        {
            "user@example.com",
            "invalid-email",
            "admin@domain.org",
            "test@site"
        };
        string pattern = @"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$";
        var matches = texts.Where(text => Regex.IsMatch(text, pattern));
        foreach (var match in matches)
        {
            Console.WriteLine(match);
        }
    }
}
user@example.com
admin@domain.org

このように、Regex.IsMatchWhereの条件に使うことで、正規表現に合致する文字列だけを簡単に抽出できます。

パターンキャプチャとMatchオブジェクト活用

Regex.IsMatchはマッチの有無だけを返しますが、より詳細な情報が必要な場合はRegex.Matchを使います。

Matchオブジェクトはマッチした部分文字列やキャプチャグループを取得できるため、抽出や加工に役立ちます。

以下は、メールアドレスのユーザー名部分とドメイン部分をキャプチャして表示する例です。

using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        var texts = new List<string>
        {
            "user@example.com",
            "admin@domain.org",
            "invalid-email"
        };
        string pattern = @"^(?<user>[A-Za-z0-9._%+-]+)@(?<domain>[A-Za-z0-9.-]+\.[A-Za-z]{2,})$";
        foreach (var text in texts)
        {
            Match match = Regex.Match(text, pattern);
            if (match.Success)
            {
                string user = match.Groups["user"].Value;
                string domain = match.Groups["domain"].Value;
                Console.WriteLine($"User: {user}, Domain: {domain}");
            }
        }
    }
}
User: user, Domain: example.com
User: admin, Domain: domain.org

キャプチャグループを使うことで、マッチした文字列の一部を抽出して処理に活用できます。

LINQのSelectと組み合わせて、マッチした情報だけを新しいコレクションに変換することも可能です。

Lookahead/Lookbehindによる条件指定

正規表現の高度な機能として、LookaheadLookbehindを使った条件指定があります。

これらは特定のパターンの前後にある文字列を条件に含めつつ、マッチ結果には含めない「先読み」「後読み」の機能です。

たとえば、数字の後に「kg」が続くパターンを抽出したい場合、Lookaheadを使うと「kg」はマッチ結果に含めずに判定できます。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string text = "Weight: 70kg, Height: 180cm, Limit: 100kg";
        string pattern = @"\d+(?=kg)";
        var matches = Regex.Matches(text, pattern);
        foreach (Match match in matches)
        {
            Console.WriteLine(match.Value);
        }
    }
}
70
100

この例では、\d+(数字の連続)に対して(?=kg)というポジティブ・ルックアヘッドを指定し、「数字の後にkgが続く」ことを条件にしていますが、マッチ結果には「kg」は含まれません。

同様に、Lookbehindは特定の文字列の後に続くパターンをマッチさせる際に使います。

string pattern = @"(?<=Weight: )\d+";

このパターンは「Weight: 」の後に続く数字を抽出します。

これらの機能を使うことで、より柔軟で複雑な検索条件を正規表現で表現できます。

RegexOptions.Compiledの効果

RegexOptions.Compiledは、正規表現のパターンをILコードにコンパイルして高速化するオプションです。

これを指定すると、初回の正規表現オブジェクト生成時にコンパイル処理が行われ、その後のマッチング処理が高速になります。

ただし、コンパイルには初期コストがかかるため、頻繁に同じパターンを使う場合に効果的です。

逆に、一度しか使わないパターンに対してはオーバーヘッドが大きくなるため注意が必要です。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string pattern = @"^\d{3}-\d{4}$";
        string text = "123-4567";
        var regex = new Regex(pattern, RegexOptions.Compiled);
        bool isMatch = regex.IsMatch(text);
        Console.WriteLine($"Match: {isMatch}");
    }
}
Match: True

RegexOptions.Compiledを使うことで、特に大量の文字列に対して繰り返しマッチングを行う場合にパフォーマンスが向上します。

Regexをキャッシュして使い回す設計

正規表現オブジェクトの生成はコストが高いため、同じパターンを繰り返し使う場合はキャッシュして使い回す設計が推奨されます。

これにより、毎回新しいRegexインスタンスを生成するオーバーヘッドを削減できます。

以下は、パターンごとにRegexオブジェクトをキャッシュするシンプルな例です。

using System;
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
public static class RegexCache
{
    private static readonly ConcurrentDictionary<string, Regex> cache = new ConcurrentDictionary<string, Regex>();
    public static Regex GetRegex(string pattern, RegexOptions options = RegexOptions.None)
    {
        string key = pattern + options.ToString();
        return cache.GetOrAdd(key, _ => new Regex(pattern, options));
    }
}
class Program
{
    static void Main()
    {
        string pattern = @"^\d{3}-\d{4}$";
        string text1 = "123-4567";
        string text2 = "987-6543";
        var regex = RegexCache.GetRegex(pattern, RegexOptions.Compiled);
        Console.WriteLine(regex.IsMatch(text1)); // True
        Console.WriteLine(regex.IsMatch(text2)); // True
    }
}
True
True

この設計では、ConcurrentDictionaryを使ってスレッドセーフにRegexオブジェクトを管理しています。

パターンとオプションの組み合わせをキーにして、一度生成したRegexを再利用します。

大量のパターンを扱う場合やマルチスレッド環境でも安全に使えるため、パフォーマンスと安定性の両立に役立ちます。

複数条件・複合検索テクニック

ContainsとStartsWithを組み合わせる

文字列検索で部分一致と前方一致の条件を同時に使いたい場合、ContainsStartsWithを組み合わせてLINQのWhere句に記述できます。

たとえば、ある文字列が特定の接頭辞で始まるか、または特定のキーワードを含むかを判定するケースです。

以下は、文字列リストから「pre」で始まるか「test」を含む要素を抽出する例です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var items = new List<string>
        {
            "prefix",
            "testing",
            "example",
            "presentation",
            "contest"
        };
        var filtered = items.Where(s => s.StartsWith("pre") || s.Contains("test"));
        foreach (var item in filtered)
        {
            Console.WriteLine(item);
        }
    }
}
prefix
testing
presentation
contest

このように||(論理和)を使って複数の条件を組み合わせることで、柔軟な検索が可能です。

StartsWithは前方一致、Containsは部分一致をそれぞれ判定し、どちらかを満たせば結果に含まれます。

Any・Allを使った多キーワード判定

複数のキーワードに対して、いずれかが含まれているか(OR条件)やすべて含まれているか(AND条件)を判定するには、LINQのAnyAllメソッドが便利です。

Anyを使ったいずれかのキーワードを含む判定

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var keywords = new List<string> { "apple", "banana", "cherry" };
        var texts = new List<string>
        {
            "I like apple pie",
            "Banana smoothie is tasty",
            "Grapes are sour",
            "Cherry blossoms are beautiful"
        };
        var filtered = texts.Where(text => keywords.Any(k => text.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0));
        foreach (var text in filtered)
        {
            Console.WriteLine(text);
        }
    }
}
I like apple pie
Banana smoothie is tasty
Cherry blossoms are beautiful

Anyはキーワードの中で一つでも条件を満たすものがあればtrueを返します。

IndexOfStringComparison.OrdinalIgnoreCaseを指定して大文字小文字を無視した検索を行っています。

Allを使ったすべてのキーワードを含む判定

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var keywords = new List<string> { "apple", "banana" };
        var texts = new List<string>
        {
            "I like apple and banana",
            "Apple pie is delicious",
            "Banana smoothie",
            "Apple banana cherry salad"
        };
        var filtered = texts.Where(text => keywords.All(k => text.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0));
        foreach (var text in filtered)
        {
            Console.WriteLine(text);
        }
    }
}
I like apple and banana
Apple banana cherry salad

Allはすべてのキーワードが含まれている場合にtrueを返します。

複数キーワードのAND条件を簡潔に表現できます。

OR条件・AND条件の可読性向上

複数条件を組み合わせると、条件式が複雑になりがちです。

可読性を高めるために、条件を変数に分割したり、拡張メソッドを使って意味のある名前を付ける方法があります。

以下は、OR条件を変数に分けて読みやすくした例です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static bool ContainsAny(string source, IEnumerable<string> keywords)
    {
        return keywords.Any(k => source.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0);
    }
    static void Main()
    {
        var keywords = new List<string> { "error", "fail", "exception" };
        var logs = new List<string>
        {
            "Operation completed successfully",
            "Error occurred during processing",
            "Unhandled exception thrown",
            "Process failed to start"
        };
        var filtered = logs.Where(log =>
        {
            bool hasKeyword = ContainsAny(log, keywords);
            return hasKeyword;
        });
        foreach (var log in filtered)
        {
            Console.WriteLine(log);
        }
    }
}
Error occurred during processing
Unhandled exception thrown
Process failed to start

拡張メソッドやヘルパーメソッドを使うことで、条件の意味が明確になり、コードの保守性が向上します。

AND条件でも同様にContainsAllのようなメソッドを作成するとよいでしょう。

また、条件が複雑な場合はLINQのクエリ構文を使うことで、条件のグルーピングや読みやすさを改善できます。

適切な命名と分割で、複数条件の検索もスムーズに扱えます。

クエリ構文とメソッド構文の比較

可読性の相違

LINQには主に「クエリ構文」と「メソッド構文」の2つの書き方があります。

どちらも同じ処理を実現できますが、可読性に違いがあります。

クエリ構文はSQLに似た文法で、fromwhereselectなどのキーワードを使って記述します。

複数の条件や結合がある場合に、処理の流れが直感的に理解しやすいのが特徴です。

var result = from item in collection
             where item.Name.StartsWith("A")
             select item;

一方、メソッド構文は拡張メソッドをチェーンでつなげて書きます。

ラムダ式を使うため、条件が短い場合や単純な処理ではコンパクトに書けます。

var result = collection.Where(item => item.Name.StartsWith("A"));

複雑なクエリになると、クエリ構文のほうが読みやすくなることが多いですが、単純なフィルタリングや変換ではメソッド構文のほうがスッキリします。

チームのコーディング規約や個人の好みによって使い分けることが多いです。

デバッグ・ステップ実行のしやすさ

デバッグ時のステップ実行では、メソッド構文のほうが細かく処理を追いやすい傾向があります。

なぜなら、メソッド構文は各メソッド呼び出しが明示的で、ブレークポイントをラムダ式内に設定できるためです。

var result = collection.Where(item =>
{
    bool condition = item.Name.StartsWith("A");
    return condition;
});

このようにラムダ式内に処理を展開すれば、条件判定の途中で変数の値を確認できます。

一方、クエリ構文は一連のクエリとしてまとめて記述されるため、ステップ実行時にどの部分がどの処理に対応しているかがやや分かりにくい場合があります。

特に複数のjoingroupを含む複雑なクエリでは、デバッグが難しくなることがあります。

ラムダ式と匿名関数の選択基準

メソッド構文で使うラムダ式は、簡潔な式を記述するのに適していますが、複雑な処理や複数行のロジックが必要な場合は匿名関数(ラムダ式のブロック形式)を使うことが多いです。

// 簡潔なラムダ式
var result = collection.Where(item => item.IsActive);
// 複数行の匿名関数
var result = collection.Where(item =>
{
    if (item.IsActive && item.Score > 50)
    {
        return true;
    }
    return false;
});

匿名関数を使うと、条件の途中で変数を使ったり、複雑なロジックを分かりやすく記述できます。

ただし、長くなりすぎると可読性が下がるため、適度にメソッドに切り出すことも検討してください。

また、パフォーマンス面ではラムダ式と匿名関数に大きな差はありませんが、匿名関数はデリゲートの生成がやや多くなる場合があります。

通常は気にする必要はありませんが、パフォーマンスが極めて重要な場面では注意が必要です。

まとめると、シンプルな条件はラムダ式で、複雑な条件は匿名関数で記述し、必要に応じてメソッドに分割するのが良い選択基準です。

大量データとパフォーマンス最適化

文字列長と検索コストの関係

文字列検索のパフォーマンスは、検索対象の文字列長に大きく影響されます。

一般的に、文字列が長くなるほど検索にかかるコストは増加します。

これは、ContainsStartsWithEndsWithなどのメソッドが内部で文字列の比較を行う際、比較対象の長さに比例して処理時間が増えるためです。

例えば、Containsは検索文字列の長さ分だけ対象文字列の各位置で部分文字列比較を行うため、対象文字列が長い場合は比較回数が増えます。

特に部分一致検索では、対象文字列の全体をスキャンする必要があるため、文字列長がパフォーマンスに直結します。

また、検索文字列が長い場合は、比較の途中で不一致が判明しやすく、早期に処理を打ち切れることもありますが、一般的には長い文字列ほど処理コストは高くなります。

パフォーマンスを意識する場合は、検索対象の文字列長を考慮し、必要に応じて検索範囲を限定したり、インデックスを作成するなどの工夫が求められます。

Parallel LINQ(PLINQ)での並列検索

大量の文字列データを検索する際、CPUのマルチコアを活用して処理を高速化する方法としてParallel LINQ(PLINQ)があります。

PLINQはLINQの拡張で、クエリを自動的に複数スレッドで並列実行します。

以下は、PLINQを使って大量の文字列から特定のキーワードを含むものを並列検索する例です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var data = Enumerable.Range(0, 1000000).Select(i => "Item" + i).ToList();
        string keyword = "999";
        var results = data.AsParallel()
                          .Where(s => s.Contains(keyword))
                          .ToList();
        Console.WriteLine($"Found {results.Count} items containing '{keyword}'.");
    }
}
Found 3700 items containing '999'.

AsParallel()を呼び出すだけで、LINQクエリが並列化されます。

これにより、CPUコア数に応じて検索処理が分散され、処理時間が短縮されることが期待できます。

ただし、PLINQはスレッド間のオーバーヘッドやデータの分割・結合コストもあるため、データ量が少ない場合や単純な処理では逆に遅くなることがあります。

並列化の効果はデータ量や処理内容に依存するため、適切なベンチマークを行うことが重要です。

Span<char>・Memory<char>でGC圧縮

.NET Core以降で利用可能なSpan<char>Memory<char>は、文字列の部分操作を効率的に行うための構造体です。

これらはヒープ上に割り当てられず、スタックや既存のメモリ領域を参照するため、GC(ガベージコレクション)の負荷を軽減できます。

文字列検索で部分文字列を切り出す際にSubstringを多用すると、新たな文字列オブジェクトが生成されGC負荷が増大します。

Span<char>を使うと、文字列の一部をコピーせずに参照できるため、メモリ効率が大幅に向上します。

以下は、Span<char>を使って部分文字列を比較する例です。

using System;
class Program
{
    static void Main()
    {
        string text = "Hello, World!";
        ReadOnlySpan<char> span = text.AsSpan();
        ReadOnlySpan<char> prefix = "Hello".AsSpan();
        bool startsWith = span.StartsWith(prefix, StringComparison.Ordinal);
        Console.WriteLine($"Starts with 'Hello': {startsWith}");
    }
}
Starts with 'Hello': True

Span<char>stringの拡張として使え、StartsWithIndexOfなどのメソッドも利用可能です。

これにより、文字列操作時のメモリ割り当てを抑えつつ高速な処理が可能になります。

ただし、Span<char>はスタック上の構造体であり、非同期処理やヒープに保存する必要がある場合は使えない制約があります。

用途に応じてMemory<char>を使うことも検討してください。

BenchmarkDotNetによる計測手順

パフォーマンス最適化の効果を正確に把握するためには、ベンチマーク計測が欠かせません。

C#ではBenchmarkDotNetという強力なベンチマークライブラリが広く使われています。

BenchmarkDotNetを使うと、メソッド単位で処理時間やメモリ使用量を詳細に計測でき、最適化の効果を客観的に評価できます。

以下は、ContainsSpan<char>を使った検索のパフォーマンスを比較する簡単なベンチマーク例です。

using System;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class StringSearchBenchmark
{
    private string text = new string('a', 10000) + "b";
    [Benchmark]
    public bool ContainsSearch()
    {
        return text.Contains("b");
    }
    [Benchmark]
    public bool SpanSearch()
    {
        ReadOnlySpan<char> span = text.AsSpan();
        return span.IndexOf('b') >= 0;
    }
}
class Program
{
    static void Main()
    {
        var summary = BenchmarkRunner.Run<StringSearchBenchmark>();
    }
}

このコードを実行すると、各メソッドの実行時間やメモリ割り当てが詳細にレポートされます。

BenchmarkDotNetはウォームアップやGCの影響を考慮し、信頼性の高い結果を提供します。

ベンチマークを行う際は、実際の使用シナリオに近いデータや条件で計測することが重要です。

また、複数回の計測や異なる環境での比較も推奨されます。

カスタムIEqualityComparerの利用

ケースインセンシティブComparer実装

文字列の比較で大文字小文字を区別せずに判定したい場合、IEqualityComparer<string>を実装したカスタムComparerを作成すると便利です。

これにより、DictionaryHashSet、LINQのDistinctなどでケースインセンシティブな比較を一貫して行えます。

以下は、StringComparison.OrdinalIgnoreCaseを使ったケースインセンシティブなIEqualityComparer<string>の実装例です。

using System;
using System.Collections.Generic;
public class CaseInsensitiveComparer : IEqualityComparer<string>
{
    public bool Equals(string x, string y)
    {
        return string.Equals(x, y, StringComparison.OrdinalIgnoreCase);
    }
    public int GetHashCode(string obj)
    {
        if (obj == null) return 0;
        return StringComparer.OrdinalIgnoreCase.GetHashCode(obj);
    }
}

このComparerを使うと、以下のように大文字小文字を無視した集合操作が可能です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var set = new HashSet<string>(new CaseInsensitiveComparer())
        {
            "Apple",
            "apple",
            "BANANA"
        };
        Console.WriteLine($"Count: {set.Count}"); // 2
        Console.WriteLine(set.Contains("APPLE")); // True
        Console.WriteLine(set.Contains("banana")); // True
    }
}
Count: 2
True
True

このように、ケースインセンシティブな比較を一元化でき、コードの重複やミスを防げます。

正規化(NFC/NFKD)Comparer実装

Unicode文字列は同じ見た目でも異なるコードポイントの組み合わせで表現されることがあります。

たとえば、アクセント付き文字は単一の合成文字(NFC)か、ベース文字とアクセントの分解文字(NFD)で表現される場合があります。

このため、文字列比較で正規化を考慮しないと、同じ意味の文字列が異なると判定されることがあります。

これを防ぐために、文字列を正規化してから比較するIEqualityComparer<string>を作成します。

以下は、NFC(Normalization Form C)で正規化して比較する例です。

using System;
using System.Collections.Generic;
using System.Text;
public class NormalizedStringComparer : IEqualityComparer<string>
{
    private readonly StringComparison _comparison;
    public NormalizedStringComparer(StringComparison comparison = StringComparison.Ordinal)
    {
        _comparison = comparison;
    }
    public bool Equals(string x, string y)
    {
        if (x == null && y == null) return true;
        if (x == null || y == null) return false;
        string nx = x.Normalize(NormalizationForm.FormC);
        string ny = y.Normalize(NormalizationForm.FormC);
        return string.Equals(nx, ny, _comparison);
    }
    public int GetHashCode(string obj)
    {
        if (obj == null) return 0;
        string normalized = obj.Normalize(NormalizationForm.FormC);
        return normalized.GetHashCode();
    }
}

このComparerを使うと、分解文字と合成文字の違いを吸収して比較できます。

using System;
class Program
{
    static void Main()
    {
        string s1 = "é"; // 合成文字 (U+00E9)
        string s2 = "e\u0301"; // 分解文字 (e + ́)
        var comparer = new NormalizedStringComparer(StringComparison.OrdinalIgnoreCase);
        Console.WriteLine(comparer.Equals(s1, s2)); // True
    }
}
True

正規化を考慮したComparerは、国際化対応やユーザー入力の多様性を扱う際に重要です。

ハイブリッドComparerのパターン

ケースインセンシティブかつ正規化を考慮した比較を同時に行いたい場合は、上記の2つの機能を組み合わせたハイブリッドComparerを作成します。

これにより、Unicode正規化と大文字小文字の違いを両方吸収した比較が可能になります。

以下は、正規化(NFC)とStringComparison.OrdinalIgnoreCaseを組み合わせたハイブリッドComparerの例です。

using System;
using System.Collections.Generic;
using System.Text;
public class NormalizedCaseInsensitiveComparer : IEqualityComparer<string>
{
    public bool Equals(string x, string y)
    {
        if (x == null && y == null) return true;
        if (x == null || y == null) return false;
        string nx = x.Normalize(NormalizationForm.FormC);
        string ny = y.Normalize(NormalizationForm.FormC);
        return string.Equals(nx, ny, StringComparison.OrdinalIgnoreCase);
    }
    public int GetHashCode(string obj)
    {
        if (obj == null) return 0;
        string normalized = obj.Normalize(NormalizationForm.FormC);
        return StringComparer.OrdinalIgnoreCase.GetHashCode(normalized);
    }
}

このComparerを使うと、以下のように大文字小文字の違いとUnicode正規化の違いを同時に無視して比較できます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        string s1 = "École"; // 合成文字
        string s2 = "e\u0301cole"; // 分解文字で小文字
        var comparer = new NormalizedCaseInsensitiveComparer();
        var set = new HashSet<string>(comparer) { s1 };
        Console.WriteLine(set.Contains(s2)); // True
    }
}
True

このようなハイブリッドComparerは、多言語対応やユーザー入力の多様な表記を扱うアプリケーションで特に有効です。

用途に応じてNormalizationFormStringComparisonの種類を調整して実装してください。

拡張メソッドで検索ロジックを再利用

ContainsAny実装と応用例

複数のキーワードのうち、いずれかが文字列に含まれているかを判定する処理はよく使われます。

これを毎回書くのは冗長なので、拡張メソッドとしてまとめると便利です。

以下は、string型に対して複数キーワードのいずれかを含むか判定するContainsAny拡張メソッドの実装例です。

using System;
using System.Collections.Generic;
public static class StringExtensions
{
    public static bool ContainsAny(this string source, IEnumerable<string> keywords, StringComparison comparison = StringComparison.Ordinal)
    {
        if (source == null || keywords == null)
            return false;
        foreach (var keyword in keywords)
        {
            if (keyword == null) continue;
            if (source.IndexOf(keyword, comparison) >= 0)
                return true;
        }
        return false;
    }
}

このメソッドは、source文字列がkeywordsのいずれかを含む場合にtrueを返します。

StringComparisonを指定できるため、大文字小文字を無視した検索も可能です。

応用例として、ログメッセージの中にエラーワードが含まれているかを判定するコードを示します。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var errorKeywords = new List<string> { "error", "fail", "exception" };
        string log = "Operation completed with an Exception.";
        bool hasError = log.ContainsAny(errorKeywords, StringComparison.OrdinalIgnoreCase);
        Console.WriteLine($"Contains error keyword: {hasError}");
    }
}
Contains error keyword: True

このように、ContainsAnyを使うと複数キーワードの部分一致判定が簡潔に書けます。

ContainsAll実装と応用例

逆に、複数のキーワードすべてが文字列に含まれているかを判定したい場合はContainsAll拡張メソッドを作成します。

using System;
using System.Collections.Generic;
public static class StringExtensions
{
    public static bool ContainsAll(this string source, IEnumerable<string> keywords, StringComparison comparison = StringComparison.Ordinal)
    {
        if (source == null || keywords == null)
            return false;
        foreach (var keyword in keywords)
        {
            if (keyword == null) continue;
            if (source.IndexOf(keyword, comparison) < 0)
                return false;
        }
        return true;
    }
}

このメソッドは、source文字列がkeywordsのすべてを含む場合にtrueを返します。

応用例として、ユーザー入力が複数の必須キーワードをすべて含んでいるかをチェックするコードを示します。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var requiredKeywords = new List<string> { "C#", "LINQ", "search" };
        string input = "This tutorial explains LINQ search in C#.";
        bool isValid = input.ContainsAll(requiredKeywords, StringComparison.OrdinalIgnoreCase);
        Console.WriteLine($"Input contains all keywords: {isValid}");
    }
}
Input contains all keywords: True

ContainsAllはAND条件の部分一致判定に便利で、複数条件を満たすかどうかを簡単に表現できます。

例外安全なTryContains設計

文字列検索の拡張メソッドを作る際、null参照や空文字列の扱いに注意しないと例外が発生するリスクがあります。

安全に使えるように、TryContainsのような例外安全な設計を行うことが望ましいです。

以下は、TryContains拡張メソッドの例です。

sourcevaluenullの場合はfalseを返し、例外を防ぎます。

using System;
public static class StringExtensions
{
    public static bool TryContains(this string source, string value, StringComparison comparison = StringComparison.Ordinal)
    {
        if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(value))
            return false;
        return source.IndexOf(value, comparison) >= 0;
    }
}

このメソッドを使うと、呼び出し側でnullチェックを省略でき、コードがすっきりします。

using System;
class Program
{
    static void Main()
    {
        string text = null;
        string keyword = "test";
        bool result = text.TryContains(keyword);
        Console.WriteLine($"Contains keyword: {result}");
    }
}
Contains keyword: False

TryContainsは例外を防ぎつつ、部分一致検索を安全に行いたい場面で役立ちます。

用途に応じて空文字列の扱いを変えたり、nullを許容するかどうかを調整してください。

エラー・例外への対処

NullReferenceException防止のnullチェック

文字列検索を行う際に最も多い例外の一つがNullReferenceExceptionです。

これは、nullの文字列に対してContainsStartsWithなどのメソッドを呼び出した場合に発生します。

安全に検索処理を行うためには、必ずnullチェックを行うことが重要です。

以下は、nullチェックを行いながらContainsを使う例です。

using System;
class Program
{
    static void Main()
    {
        string text = null;
        string keyword = "test";
        bool contains = text != null && text.Contains(keyword);
        Console.WriteLine($"Contains keyword: {contains}");
    }
}
Contains keyword: False

このように、textnullの場合はfalseを返すことで例外を防げます。

拡張メソッドでnullチェックを組み込む設計も有効です。

また、LINQのWhere句で文字列検索を行う場合も、nullを含むコレクションでは以下のようにnullチェックを入れることが推奨されます。

var filtered = collection.Where(s => !string.IsNullOrEmpty(s) && s.Contains(keyword));

string.IsNullOrEmptyを使うことで、nullや空文字列の両方を除外できます。

CultureNotFoundExceptionの回避

StringComparisonCultureInfoを使った文字列比較で、存在しないカルチャ名を指定するとCultureNotFoundExceptionが発生します。

特にユーザー入力や外部設定からカルチャ名を受け取る場合は注意が必要です。

以下は、カルチャ名の検証と例外回避の例です。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string cultureName = "invalid-culture";
        CultureInfo culture;
        try
        {
            culture = new CultureInfo(cultureName);
        }
        catch (CultureNotFoundException)
        {
            Console.WriteLine($"Culture '{cultureName}' is invalid. Using InvariantCulture.");
            culture = CultureInfo.InvariantCulture;
        }
        string text = "example";
        string search = "EXAMPLE";
        bool contains = text.IndexOf(search, true, culture) >= 0;
        Console.WriteLine($"Contains (culture-aware): {contains}");
    }
}
Culture 'invalid-culture' is invalid. Using InvariantCulture.
Contains (culture-aware): True

このように、CultureInfoの生成時に例外をキャッチして代替のカルチャを使うことで、アプリケーションの安定性を保てます。

RegexMatchTimeoutExceptionのハンドリング

正規表現を使った検索では、複雑なパターンや長い文字列に対してマッチング処理が長時間かかることがあります。

これによりRegexMatchTimeoutExceptionが発生する場合があります。

この例外を防ぐには、RegexのコンストラクタやMatchメソッドにタイムアウトを設定し、例外発生時に適切に処理を行うことが重要です。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string pattern = @"(a+)+$"; // 悪意のあるパターン(バックトラッキング多発)
        string input = new string('a', 10000) + "b";
        var regex = new Regex(pattern, RegexOptions.None, TimeSpan.FromMilliseconds(500));
        try
        {
            bool isMatch = regex.IsMatch(input);
            Console.WriteLine($"Match result: {isMatch}");
        }
        catch (RegexMatchTimeoutException)
        {
            Console.WriteLine("Regex match timed out.");
        }
    }
}
Regex match timed out.

タイムアウトを設定することで、無限ループや過剰な計算を防ぎ、アプリケーションの応答性を維持できます。

例外発生時はログ出力や代替処理を行うなど、適切なハンドリングを行いましょう。

実践シナリオ別サンプル

CSV列の値フィルタリング

CSVファイルの特定の列の値を条件にフィルタリングするケースはよくあります。

LINQを使うと、CSVの各行を分割して目的の列を抽出し、条件に合う行だけを簡単に取得できます。

以下は、カンマ区切りのCSVデータから3列目(インデックス2)が「Active」の行だけを抽出する例です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var csvLines = new List<string>
        {
            "ID,Name,Status",
            "1,Alice,Active",
            "2,Bob,Inactive",
            "3,Charlie,Active",
            "4,David,Pending"
        };
        // ヘッダーを除くデータ行をフィルタリング
        var filtered = csvLines.Skip(1)
            .Where(line =>
            {
                var columns = line.Split(',');
                return columns.Length > 2 && columns[2].Equals("Active", StringComparison.OrdinalIgnoreCase);
            });
        foreach (var line in filtered)
        {
            Console.WriteLine(line);
        }
    }
}
1,Alice,Active
3,Charlie,Active

この例では、Splitで列を分割し、3列目の値をEqualsで比較しています。

大文字小文字を無視した比較により柔軟なフィルタリングが可能です。

ログファイルからのエラーワード抽出

ログファイルから「error」や「fail」などのエラーワードを含む行だけを抽出する場合、LINQのWhereContainsを組み合わせて効率的に検索できます。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var logLines = new List<string>
        {
            "2024-06-01 10:00:00 Info: Process started",
            "2024-06-01 10:01:00 Error: File not found",
            "2024-06-01 10:02:00 Warning: Low memory",
            "2024-06-01 10:03:00 Fail: Connection lost",
            "2024-06-01 10:04:00 Info: Process completed"
        };
        var errorKeywords = new[] { "error", "fail" };
        var errorLines = logLines.Where(line =>
            errorKeywords.Any(keyword => line.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0));
        foreach (var line in errorLines)
        {
            Console.WriteLine(line);
        }
    }
}
2024-06-01 10:01:00 Error: File not found
2024-06-01 10:03:00 Fail: Connection lost

Anyを使うことで複数のキーワードを簡潔に判定し、大文字小文字を無視した検索も実現しています。

ユーザー入力検索ボックスのライブフィルタ

ユーザーが入力する検索ボックスの文字列に応じて、リストの表示をリアルタイムに絞り込むライブフィルタは、StartsWithContainsを使って実装できます。

以下は、ユーザー入力に基づいて名前リストを部分一致でフィルタリングする例です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var names = new List<string> { "Alice", "Bob", "Charlie", "David", "Eve" };
        Console.Write("検索キーワードを入力してください: ");
        string input = Console.ReadLine();
        var filtered = names.Where(name =>
            !string.IsNullOrEmpty(input) &&
            name.Contains(input, StringComparison.OrdinalIgnoreCase));
        Console.WriteLine("検索結果:");
        foreach (var name in filtered)
        {
            Console.WriteLine(name);
        }
    }
}
検索キーワードを入力してください: a
検索結果:
Alice
Charlie
David

ContainsStringComparison.OrdinalIgnoreCaseを指定して大文字小文字を無視し、空文字やnullの入力を除外しています。

これによりユーザー体験が向上します。

ファイルシステム走査での拡張子検索

ファイルシステムを走査して特定の拡張子を持つファイルだけを抽出する場合、Directory.EnumerateFilesとLINQのWhereEndsWithを組み合わせると効率的です。

以下は、指定フォルダ内の「.txt」および「.log」ファイルを列挙する例です。

using System;
using System.IO;
using System.Linq;
class Program
{
    static void Main()
    {
        string folderPath = @"C:\Logs";
        var extensions = new[] { ".txt", ".log" };
        var files = Directory.EnumerateFiles(folderPath)
            .Where(file => extensions.Any(ext => file.EndsWith(ext, StringComparison.OrdinalIgnoreCase)));
        foreach (var file in files)
        {
            Console.WriteLine(file);
        }
    }
}
C:\Logs\app.log
C:\Logs\readme.txt

EndsWithStringComparison.OrdinalIgnoreCaseを指定して拡張子の大文字小文字を無視し、Anyで複数拡張子をまとめて判定しています。

EnumerateFilesはファイル数が多い場合でも遅延実行で効率的に処理できます。

バージョン別差異と最新機能

.NET 5以降でのString.Contains強化点

.NET 5からstring.ContainsメソッドにStringComparisonを指定できるオーバーロードが追加されました。

これにより、大文字小文字を区別しない検索や文化依存の比較が簡単に行えるようになり、従来のIndexOfを使った回避策が不要になりました。

using System;
class Program
{
    static void Main()
    {
        string text = "Hello World";
        // 大文字小文字を無視して部分一致を判定
        bool containsIgnoreCase = text.Contains("hello", StringComparison.OrdinalIgnoreCase);
        Console.WriteLine($"Contains 'hello' (ignore case): {containsIgnoreCase}");
    }
}
Contains 'hello' (ignore case): True

この強化により、コードがシンプルになり、意図しない比較ミスを減らせます。

また、パフォーマンス面でもContainsの内部実装が最適化されているため、IndexOfを使うより効率的です。

C# 11のRaw string literal活用

C# 11では、複数行の文字列やエスケープシーケンスを気にせずに記述できるRaw string literal(生文字列リテラル)が導入されました。

これにより、正規表現パターンや複雑な文字列をより読みやすく書けるようになりました。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string pattern = """
            ^\w+@\w+\.\w{2,3}$
            """;
        string email = "user@example.com";
        bool isMatch = Regex.IsMatch(email, pattern);
        Console.WriteLine($"Is valid email: {isMatch}");
    }
}
Is valid email: True

Raw string literalは"""で囲み、改行やバックスラッシュをそのまま文字列に含められます。

正規表現やJSON、SQLクエリなどの埋め込みが多いコードで特に効果を発揮します。

将来予定されるパターンマッチ拡張

C#のパターンマッチング機能はバージョンアップごとに強化されており、将来的には文字列に対するより柔軟で強力なパターンマッチが期待されています。

例えば、部分文字列の存在チェックや正規表現との連携が言語レベルでサポートされる可能性があります。

これにより、LINQや条件分岐での文字列検索がより簡潔かつ高速に書けるようになる見込みです。

また、andornotなどの論理パターンの拡張や、文字列の部分一致を表現する新しいパターン構文の導入も検討されています。

現時点ではプレビュー段階の機能も多いため、最新のC#コンパイラや.NET SDKのリリースノートをチェックし、適宜新機能を取り入れていくことが推奨されます。

まとめ

この記事では、C#のLINQを活用した文字列検索の基本から応用まで幅広く解説しました。

ContainsStartsWithEndsWithの使い方や正規表現との組み合わせ、複数条件の効率的な検索方法、パフォーマンス最適化のポイント、カスタムComparerや拡張メソッドによる再利用性向上まで網羅しています。

さらに、バージョン別の機能強化や最新のC#言語機能も紹介し、実践的なシナリオに即した具体例を通じて、効果的かつ安全な文字列検索の実装方法が理解できます。

関連記事

Back to top button
目次へ