業務データの検証やログ抽出、ユーザー検索など「文字列の先頭・末尾・部分一致で絞り込みたい」という場面はC#で日常的に発生します。本記事では List<string> を対象に、正規表現(Regex)とワイルドカード(*)を安全かつ高速に扱う実装を、実務でそのまま使える完成コードとともに丁寧に解説します。大文字小文字の無視、ユーザー入力の無害化、パフォーマンス、カルチャ差異、タイムアウトまで網羅します。
結論(先に要点)
- 末尾一致:
ABC$、先頭一致:^ABC、部分一致:ABC。大文字小文字の無視はRegexOptions.IgnoreCaseを付与。 - ユーザー入力は必ず
Regex.Escapeでエスケープしてからパターンに挿入。 - ワイルドカード(
*)は.*に置換し、^と$で全体をアンカーすると「*Some=末尾一致」「Some*=先頭一致」「*Some*=部分一致」を実現。 - 単純な先頭/末尾/部分一致なら
StartsWith/EndsWith/IndexOf(StringComparison.OrdinalIgnoreCase)のほうが速く、読みやすい。複雑になったらRegex。 - Regexはタイムアウト・事前コンパイル・カルチャ設定を忘れない(
RegexOptions.Compiled、RegexOptions.CultureInvariant、new Regex(pattern, options, timeout))。
基本パターンと最短コード
3条件の代表的な正規表現
| 抽出したい条件 | 代表パターン | 説明 |
|---|---|---|
| 「ABC」で終わる | ABC$ | $ は行末アンカー |
| 「ABC」で始まる | ^ABC | ^ は行頭アンカー |
| 「ABC」を含む | ABC | 位置指定なし(部分一致) |
List<string> をRegexでフィルタする最短コード
using System.Text.RegularExpressions;
// 例データ
var items = new List { "xABC", "ABCy", "xxABCyy", "abc", "Abc", "zzz" };
// 末尾がABC(大文字小文字無視)
var endWith = items.Where(s => Regex.IsMatch(s, "ABC$", RegexOptions.IgnoreCase));
// 先頭がABC
var startWith = items.Where(s => Regex.IsMatch(s, "^ABC", RegexOptions.IgnoreCase));
// 部分一致(含む)
var contains = items.Where(s => Regex.IsMatch(s, "ABC", RegexOptions.IgnoreCase));
補足:複数条件をひとつにまとめる場合はグルーピングを忘れずに。^ABC|ABC$|ABC は「ABC」だけで十分(部分一致が他を包含)ですが、明示したいなら (^ABC|ABC$|ABC) のように括弧でまとめます。
ユーザー入力は必ずエスケープする(安全性)
ユーザーが自由にキーワードを入力できる場合、.、+、?、(、) などの特殊文字が意味を持ち、意図しないマッチや性能劣化(ReDoS)を招きます。Regex.Escape で無効化してからパターンに組み込みましょう。
string keyword = userInput; // 例: "A.C(1)+?"
string escaped = Regex.Escape(keyword); // "A\.C\(1\)\+\?"
// 先頭/末尾/部分一致
string patternStart = $"^{escaped}";
string patternEnd = $"{escaped}$";
string patternAny = escaped;
// 実行(カルチャ非依存 + 大文字小文字無視 + タイムアウト)
var options = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant;
var timeout = TimeSpan.FromMilliseconds(500);
var startHits = items.Where(s => Regex.IsMatch(s, patternStart, options, timeout));
var endHits = items.Where(s => Regex.IsMatch(s, patternEnd, options, timeout));
var anyHits = items.Where(s => Regex.IsMatch(s, patternAny, options, timeout));
ポイント:RegexOptions.CultureInvariant を付けるとカルチャ依存の大小比較ゆらぎ(ßなど)を避け、安定した結果になります。
ワイルドカード「*」を扱う:要件どおりの変換
要件では「*Some は末尾一致」「Some* は先頭一致」「*Some* は部分一致」と定義されています。これはワイルドカード * を正規表現の .* に展開し、全体を ^ ... $ で囲むだけで実現できます。
| 入力例 | 意味 | 変換後の正規表現 |
|---|---|---|
*Some | 末尾が Some | ^.*Some$ |
Some* | 先頭が Some | ^Some.*$ |
*Some* | どこかに Some | ^.*Some.*$ |
string wildcard = userInput; // 例: "*Some"
string body = Regex.Escape(wildcard).Replace(@"\*", ".*");
string pattern = $"^{body}$";
var regex = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
var hits = items.Where(s => regex.IsMatch(s));
検証例:データ {"SomeOne","HasSome","Some","something"} に対し、入力が *Some のとき、マッチするのは {"HasSome","Some"} です(SomeOne は末尾ではないので除外)。
「*」を任意位置で使う一般形
もし a*b*c のような任意位置の * を許可するなら、同じ置換で対応できます(* → .*、全体を ^...$ で囲む)。? も使いたい場合は \? を . に置換するだけでOKです。
static Regex BuildWildcardRegex(string input, bool ignoreCase = true)
{
if (string.IsNullOrEmpty(input) || input == "*")
return new Regex("^.*$", RegexOptions.Singleline | (ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None));
string escaped = Regex.Escape(input)
.Replace(@"\*", ".*")
.Replace(@"\?", ".");
string pattern = $"^{escaped}$";
var options = RegexOptions.CultureInvariant | RegexOptions.Singleline |
(ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None);
return new Regex(pattern, options, TimeSpan.FromMilliseconds(500));
}
Regexを使わない高速解法(実務の第一選択)
単純な「始まる/終わる/含む」だけなら、StartsWith/EndsWith/IndexOf のほうが高速で読みやすく、バグも入りにくいです。比較は StringComparison.OrdinalIgnoreCase を使います(カルチャによる大小の差を避け、安定して高速)。
static IEnumerable<string> FilterStart(IEnumerable<string> src, string key) =>
src.Where(s => s?.StartsWith(key, StringComparison.OrdinalIgnoreCase) == true);
static IEnumerable<string> FilterEnd(IEnumerable<string> src, string key) =>
src.Where(s => s?.EndsWith(key, StringComparison.OrdinalIgnoreCase) == true);
static IEnumerable<string> FilterContains(IEnumerable<string> src, string key) =>
src.Where(s => s?.IndexOf(key, StringComparison.OrdinalIgnoreCase) >= 0);
目安:データ量が多い・繰り返し回数が多い場合はまずこの方法。複数キーワードの組合せや否定・後読みなどが必要になってからRegexへ移行すると良いです。
パフォーマンス・保守の勘所
- 事前コンパイル:同じパターンを何度も使うなら
RegexOptions.Compiledまたは[GeneratedRegex](ソースジェネレータ)を検討。 - タイムアウト:ユーザー入力から生成したRegexは必ずタイムアウトを設定(
new Regex(pattern, options, timeout))。 - カルチャ:
RegexOptions.CultureInvariant/StringComparison.OrdinalIgnoreCaseを基本にすると環境差による不一致を防止。 - キャッシュ:構築済みRegexを
ConcurrentDictionary<string, Regex>にキャッシュ。
[GeneratedRegex] を用いた例
using System.Text.RegularExpressions;
partial class MyRegex
{
// .NET 7+ : コンパイル済みRegexを生成(大文字小文字無視・カルチャ非依存)
[GeneratedRegex("ABC$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
public static partial Regex EndWithAbc();
}
// 使い方
var hits = items.Where(s => MyRegex.EndWithAbc().IsMatch(s));
改行・複数行の注意点:^/$ と \A/\z
- 既定では
^と$は文字列全体の先頭・末尾を指します。RegexOptions.Multilineを付けると各行の先頭・末尾にマッチします。 - 文字列全体のアンカーを常に指したいときは
\A(先頭)と\z(末尾)を使うと安全です。
// 常に文字列全体の末尾にマッチ(Multilineの影響を受けない)
var endStrict = new Regex(@"ABC\z", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
完成ユーティリティ:安全・高速・汎用
要件(先頭/末尾/部分一致・ワイルドカード・大文字小文字無視)をひとまとめにした、実務投入可能なユーティリティです。
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
public enum MatchMode { StartsWith, EndsWith, Contains, Wildcard }
public static class TextFilter
{
private static readonly ConcurrentDictionary<string, Regex> Cache = new();
public static IEnumerable<string> Filter(IEnumerable<string> source,
string input,
MatchMode mode,
bool ignoreCase = true,
bool preferPlainSearch = true)
{
if (source == null) yield break;
// 空文字 or "*" は全件返す(要件に応じて変更)
if (string.IsNullOrEmpty(input) || input == "*")
{
foreach (var s in source) yield return s;
yield break;
}
// まずは高速な文字列APIを試す(ワイルドカード以外)
if (preferPlainSearch && mode != MatchMode.Wildcard)
{
var comp = StringComparison.OrdinalIgnoreCase;
foreach (var s in source)
{
if (s == null) continue;
bool ok =
mode == MatchMode.StartsWith ? s.StartsWith(input, comp) :
mode == MatchMode.EndsWith ? s.EndsWith(input, comp) :
s.IndexOf(input, comp) >= 0;
if (ok) yield return s;
}
yield break;
}
// Regexにフォールバック
var regex = GetRegex(input, mode, ignoreCase);
foreach (var s in source)
{
if (s != null && regex.IsMatch(s))
yield return s;
}
}
private static Regex GetRegex(string input, MatchMode mode, bool ignoreCase)
{
string key = $"{mode}|{ignoreCase}|{input}";
return Cache.GetOrAdd(key, _ =>
{
string pattern;
if (mode == MatchMode.Wildcard)
{
string body = Regex.Escape(input)
.Replace(@"\*", ".*")
.Replace(@"\?", ".");
pattern = $"^{body}$";
}
else
{
string e = Regex.Escape(input);
pattern = mode switch
{
MatchMode.StartsWith => $"^{e}",
MatchMode.EndsWith => $"{e}$",
_ => e, // Contains
};
}
var options = RegexOptions.CultureInvariant |
(ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None);
return new Regex(pattern, options, TimeSpan.FromMilliseconds(500));
});
}
}
使用例
var values = new List<string> { "SomeOne", "HasSome", "Some", "Thing" };
var endSome = TextFilter.Filter(values, "Some", MatchMode.EndsWith) .ToList(); // { "HasSome", "Some" }
var startSome = TextFilter.Filter(values, "Some", MatchMode.StartsWith) .ToList(); // { "Some", "SomeOne" }
var anySome = TextFilter.Filter(values, "Some", MatchMode.Contains) .ToList(); // { "SomeOne", "HasSome", "Some" }
var wcEnd = TextFilter.Filter(values, "*Some", MatchMode.Wildcard) .ToList(); // { "HasSome", "Some" }
var wcStart = TextFilter.Filter(values, "Some*", MatchMode.Wildcard) .ToList(); // { "SomeOne", "Some" }
var wcAny = TextFilter.Filter(values, "*Some*", MatchMode.Wildcard) .ToList(); // { "SomeOne", "HasSome", "Some" }
サンプルデータでの結果比較(Regex vs 文字列API)
| 入力 | 条件 | Regexパターン | 文字列APIに相当 | 一致例 |
|---|---|---|---|---|
ABC | 末尾一致 | ABC$ | EndsWith("ABC") | {"xABC", "xxABC"} |
ABC | 先頭一致 | ^ABC | StartsWith("ABC") | {"ABCy", "ABCxx"} |
ABC | 部分一致 | ABC | IndexOf("ABC") >= 0 | {"xABC", "ABCy", "xxABCyy"} |
*Some | 末尾一致 | ^.*Some$ | — | {"HasSome","Some"} |
Some* | 先頭一致 | ^Some.*$ | — | {"Some","SomeOne"} |
*Some* | 部分一致 | ^.*Some.*$ | — | {"Some","SomeOne","HasSome"} |
よくある落とし穴と対策
- グルーピング忘れ:
^ABC|ABC$は(^ABC)|(ABC$)と同義。複雑な式では( )を明示。 - Multilineの影響:行単位でマッチさせたいときだけ
RegexOptions.Multiline。全体末尾を指したいなら\zを使う。 - カルチャ依存:
IgnoreCaseはカルチャの影響を受ける。安定性重視ならRegexOptions.CultureInvariantまたは文字列APIのOrdinalIgnoreCase。 - ReDoS対策:ユーザー入力由来のパターンに
.*.*のような貪欲表現が含まれがち。必ずタイムアウトを設定し、可能なら入力側で桁数制限。 - 不要なRegex:
StartsWith/EndsWith/IndexOfで十分ならRegexは使わない。速度・可読性・保守性が上がる。 - 空文字の扱い:意図しない全件ヒットを防ぐため、空文字や
*だけの入力は「フィルタなし」と明示ルール化。
テストコード(xUnit)で安心運用
using Xunit;
public class TextFilterTests
{
private readonly List values = new() { "SomeOne", "HasSome", "Some", "Thing" };
[Fact]
public void EndsWith_Some()
{
var result = TextFilter.Filter(values, "Some", MatchMode.EndsWith).ToList();
Assert.Equal(new[] { "HasSome", "Some" }, result);
}
[Fact]
public void StartsWith_Some()
{
var result = TextFilter.Filter(values, "Some", MatchMode.StartsWith).ToList();
Assert.Equal(new[] { "Some", "SomeOne" }, result);
}
[Fact]
public void Contains_Some()
{
var result = TextFilter.Filter(values, "Some", MatchMode.Contains).ToList();
Assert.Equal(new[] { "SomeOne", "HasSome", "Some" }, result);
}
[Fact]
public void Wildcard_End_Some()
{
var result = TextFilter.Filter(values, "*Some", MatchMode.Wildcard).ToList();
Assert.Equal(new[] { "HasSome", "Some" }, result);
}
}
運用Tips:現場で役立つ小技
- 大量データ:LINQの前に
AsParallel()を検討(CPU/メモリと相談)。 - UI連動:入力が1文字未満なら検索抑制、2文字以上で発火などのスロットリング。
- 前処理:多言語や濁点・半角全角が混在するなら正規化(
string.Normalize())。 - ログ/監査:生成したパターンとオプション、経過時間を記録して原因追跡を容易に。
まとめ
- 3条件はアンカーで素直に書く:
^、$、リテラル。 - 大文字小文字はオプションで統制:
IgnoreCase+CultureInvariant。 - ユーザー入力は必ずエスケープ:
Regex.Escape。 - ワイルドカードの核:
*→.*、全体を^...$。 - まずは文字列API、必要ならRegex:性能と保守性を両立。
- タイムアウト・キャッシュ・コンパイル:実運用の安定性を確保。
付録:頻出レシピ集
| 目的 | コード断片 |
|---|---|
| 大小無視で「含む」 | items.Where(s => s?.IndexOf("abc", StringComparison.OrdinalIgnoreCase) >= 0) |
| Regexで「先頭一致」 | Regex.IsMatch(s, "^" + Regex.Escape(key), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant) |
| Wildcard一般形 | new Regex("^" + Regex.Escape(w).Replace(@"\*", ".*") + "$", RegexOptions.IgnoreCase) |
| Regexタイムアウト | new Regex(pat, opts, TimeSpan.FromMilliseconds(500)) |
| 行全体末尾を厳密に | @"ABC\z" |

コメント