0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

K33 【鍛錬】C# 文字列操作 早見表 ― Contains Replace Split Join Regex StringComparisonの迷い所と落とし穴

Posted at

連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index

文字列操作を押さえると、実装の“止まる所”が一気に減る。
知っていて損をしない知識なので、よく使う型は手元に揃えておくと強い。
まず一覧で全体像を掴み、次に最短テンプレ、最後に詰まりパターンとレビュー観点で固める。


よく使う文字列操作(一覧)

結論: 迷い所は「比較条件」「空要素」「置換範囲」「連結コスト」「Regexの上限」の5点に集約される。

やりたいこと 代表API 迷い所(1行)
検索/部分一致 Contains / IndexOf / StartsWith / EndsWith StringComparison を明示するか
比較 Equals / Compare Ordinal系かCulture系か
置換 Replace / Regex.Replace 全置換か、範囲を限定するか
分割 Split 空要素を残すか消すか、Trimするか
結合 string.Join / StringBuilder ループ連結で割り当てが増えるか
整形 Trim / Normalize 入力正規化の置き場
正規表現 Regex(IsMatch/Match/Replace) タイムアウトを入れるか
キー比較(コレクション) Dictionary/HashSet + StringComparer 比較方針を生成時に渡すか

1. このページで手に入るもの(最短)

結論: 文字列操作を「比較条件」「空要素」「置換範囲」「連結コスト」「Regex安全性」の5点で揃える。

  • コピペで使える: NullRules / StringRules / KeyRules / SplitRules / ReplaceRules / JoinRules / RegexRules(用途コメント+★OK/★NG付き)
  • StringComparisonStringComparer の使い分けが理屈で言える
  • Split の空要素/トリム方針で列ズレを止められる
  • 「1箇所だけ置換」「先頭だけ置換」を範囲明示で書ける
  • ループ連結の割り当て増加を説明できる(遅くなる理由が言える)
  • Regexにタイムアウトを入れる判断ができる(固まりやすい形を避ける)

2. 先に逆引き(症状→原因→対策)

狙い: スクロールだけで「どこを直すか」が決まる状態へ寄せる。

症状 ありがちな原因 切り分け(見る場所) 最短の対処 再発防止(ルール化)
端末/環境で一致判定が変わる 比較条件が未指定(文化依存) Contains/StartsWith/EndsWith/Equals/IndexOf StringComparison を明示 用途で比較方針を分ける
nullで落ちる/想定外になる インスタンスメソッド呼び出し .Equals()/.Trim()/.Length string.Equals / 起点で正規化 nullを境目で止める
列がズレる/値が欠ける 空要素/トリム方針が未決 Split引数/オプション 空要素の扱いを決める 入力正規化地点を決める
置換が広すぎて壊れる 全置換を雑に当てる Replace利用箇所 範囲を明示 置換意図をコードに残す
連結が遅い/メモリが増える ループで+連結 ループ内連結 StringBuilder ループ連結の規約化
Regexで固まる/保守が止まる 無制限Regex/読めない Regex生成/パターン タイムアウト/命名グループ Regex採用基準を決める
キー大小無視が効かない 既定Comparerのまま Dictionary/HashSet生成 StringComparer 指定 キー比較方針を決める

3. 最短テンプレ(コピペ)

狙い: 「何が悪い/何が起きる/なぜ良い」を短く言語化し、コードが説明の補助になる形へ寄せる。

3-1. nullで落ちる形を避ける(Equals/Trim/Length)

結論: 文字列の比較は string.Equals に寄せ、入力正規化は起点で止める。

なぜ悪い(具体):

  • sss.Equals("A")呼び出し先がnullなら即座に例外になる(比較のつもりが制御フローになる)
  • 例外が起きる場所が「比較箇所」になり、原因が文字列比較なのか入力なのかが曖昧になる

なぜ良い(具体):

  • string.Equals(a, b, ...)nullを値として扱える(比較条件も1行で見える)
  • 起点で Trim / null→"" を済ませると、後段の条件分岐とログが安定する
using System;

public static class NullRules
{
    // 用途: nullを含む可能性がある入力の比較
    public static bool EqualsOrdinalIgnoreCase(string? a, string? b)
    {
        // ★NG: a.Equals(b) は a が null だと落ちる
        // ★OK: string.Equals は null を許容し、比較条件も指定できる
        return string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
    }

    public static string NormalizeUserInput(string? text)
    {
        // ★OK: 入力直後(起点)で null と空白を整える
        // - null を空文字へ寄せる
        // - 前後空白はTrimする
        return (text ?? "").Trim();
    }
}

3-2. 検索/比較は StringComparison を明示する

結論: 比較条件を明示すると、文化差とレビューの揺れが減る。

なぜ悪い(具体):

  • Contains(value) の既定挙動は 比較条件がコードから読めない(OrdinalなのかCultureなのかが不明)
  • 文化依存が混ざると、端末設定やOS環境で「一致/不一致」が揺れる形が残る

なぜ良い(具体):

  • StringComparison.Ordinal(…IgnoreCase) を指定すると 内部用途の比較が安定する(同じ入力なら同じ結果)
  • CurrentCulture を選ぶ場面も「表示用」という意図が残り、選択理由を説明できる
using System;

public static class StringRules
{
    // 用途: 内部ID/キー/プロトコル文字列の検索
    // 方針: 文化依存を避け、比較条件を明示する
    public static bool ContainsOrdinalIgnoreCase(string? text, string value)
    {
        // ★NG: text.Contains(value) は比較条件が見えない
        // ★OK: OrdinalIgnoreCase は「内部用途の大小無視」に寄せやすい
        return text?.Contains(value, StringComparison.OrdinalIgnoreCase) == true;
    }

    public static int IndexOfOrdinalIgnoreCase(string? text, string value)
    {
        // ★OK: 見つからないは -1(慣れた取り扱いへ寄せる)
        return text?.IndexOf(value, StringComparison.OrdinalIgnoreCase) ?? -1;
    }
}

3-3. コレクションのキー比較は StringComparer を生成時に渡す

結論: キー用途の大小無視は、値変換よりComparerで揃える。

なぜ悪い(具体):

  • 値側で .ToLower() などを使うと 変換用の新しいstringが毎回発生し、割り当てが増える
  • 変換の比較方針が散らばり、片側だけ変換されて「見つからない」形が出やすい
  • 文化依存の大小変換が混ざると、内部キー用途で意図しない揺れが残る

なぜ良い(具体):

  • Dictionary/HashSetComparerでハッシュ計算と等価判定を行うため、生成時に渡すのが一番筋が良い
  • 変換用の中間文字列が減り、キー比較の方針が1点に寄る
using System;
using System.Collections.Generic;

public static class KeyRules
{
    public static Dictionary<string, int> CreateMapForKey()
    {
        // ★NG: 既定Comparer。用途によっては大小無視が効かず詰まる
        // var map = new Dictionary<string, int>();

        // ★OK: キー用途の大小無視を生成時に決める
        // - ハッシュ計算も等価判定もComparerで揃う
        var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
        return map;
    }
}

3-4. 分割は「空要素」と「トリム」を先に決める

結論: Splitは空要素が本体。列として扱うなら消さない、値リストなら消す。

なぜ悪い(具体):

  • RemoveEmptyEntries を何も考えず付けると、"A,,C"空列が詰められて列番号がずれる
  • 列ズレは「Indexで拾っている値」が別物になり、後段のバリデーションや集計が壊れやすい

なぜ良い(具体):

  • 「列用途/値用途」を先に決めると、空要素の扱いが説明できる(列なら残す、値なら消す)
  • TrimEntries を入れると、入力側の空白差を吸収できる(.NET 6+)
using System;

public static class SplitRules
{
    // 用途: "A, B ,C" のような「簡易」区切りを配列へ
    // 注意: 引用("...")やエスケープ規約があるならCSVパーサへ寄せる
    public static string[] ParseCsvLike(string text, bool keepEmptyColumns)
    {
        // ★OK: TrimEntries(.NET 6+)は入力の空白差を吸収できる
        // ★NG: 空要素を消す/残すが未決だと列ズレが起きやすい
        var options = StringSplitOptions.TrimEntries;

        if (!keepEmptyColumns)
        {
            // 値リスト用途: 空要素は除外してよい
            options |= StringSplitOptions.RemoveEmptyEntries;
        }
        else
        {
            // 列用途: 空要素も列として残す(RemoveEmptyEntriesは付けない)
        }

        return text.Split(',', options);
    }
}

3-5. 置換は「どこを変えるか」をコードに残す

結論: Replaceは全置換。部分置換は範囲を明示する。

なぜ悪い(具体):

  • Replace(old, new)一致した全箇所が対象になり、「一部だけ」のつもりでも広範囲に反映される
  • 置換範囲が曖昧だと、「どこが変わる仕様か」を説明できず、レビューと調査が伸びる

なぜ良い(具体):

  • 先頭だけ/1回だけ/範囲限定を明示すると、置換の意図がコードに残る
  • 仕様変更時も「この置換はここまで」という境界が追える
using System;

public static class ReplaceRules
{
    // 用途: 先頭だけ置換 / 最初の1回だけ置換
    // 狙い: 置換範囲がコードに残り、レビューで追える形へ寄せる

    public static string ReplacePrefix(string text, string prefix, string newPrefix)
    {
        // ★NG: text.Replace(prefix, newPrefix) は全箇所が対象になる
        // ★OK: 先頭限定の意図がそのまま残る
        return text.StartsWith(prefix, StringComparison.Ordinal)
            ? newPrefix + text[prefix.Length..]
            : text;
    }

    public static string ReplaceFirst(string text, string oldValue, string newValue, StringComparison comparison)
    {
        // ★OK: 最初の1回だけ置換。どこを変えたかが明確になる
        var index = text.IndexOf(oldValue, comparison);
        if (index < 0) return text;

        return text.Substring(0, index)
             + newValue
             + text.Substring(index + oldValue.Length);
    }
}

大小無視の置換(最後の手段):

using System;
using System.Text.RegularExpressions;

public static class ReplaceIgnoreCaseRules
{
    public static string ReplaceIgnoreCase(string src, string patternLiteral, string replacement)
    {
        // ★OK: IgnoreCase置換はRegexへ寄せる
        // - パターンがリテラルなら Escape で意図しないメタ文字を防ぐ
        // - 外部入力が混ざるならタイムアウトを入れる
        var pattern = Regex.Escape(patternLiteral);

        return Regex.Replace(
            input: src,
            pattern: pattern,
            replacement: replacement,
            options: RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
            matchTimeout: TimeSpan.FromMilliseconds(200));
    }
}

3-6. 結合は「配列はJoin」「ループはStringBuilder」

結論: ループ連結は割り当てとコピーが増えやすい。用途で道具を変える。

なぜ悪い(具体):

  • string は不変。s += x は毎回 新しいstringを割り当て、旧stringの内容を 全コピーしてから追記する
  • 反復回数が増えるほどコピー総量が積み上がり、CPU時間が伸びる(サイズ増加でコピー量が増えるため)
  • 中間文字列が大量に発生し、世代0/1の回収が増える。負荷が高い場面では待ちが出やすい

なぜ良い(具体):

  • StringBuilder は内部バッファへ追記し、最後に ToString() で最終文字列を1回生成できる
  • string.Join は配列/リスト結合の意図が明確で、最終サイズをまとめて確保しやすい
using System;
using System.Text;

public static class JoinRules
{
    // 用途: 配列/リストを区切って結合(1回で終わる)
    public static string JoinComma(string[] values)
    {
        // ★OK: 配列結合の第一候補。読みやすく速い
        // - 事前に最終サイズを見積もり、まとめて確保しやすい
        return string.Join(",", values);
    }

    // 用途: ループで多量に連結(ログ行/CSV組み立て/バッファ生成)
    public static string BuildCsv(int[] values)
    {
        // ★OK: ループ連結はStringBuilderへ寄せる
        // - 中間文字列を大量に作らず、内部バッファへ追記する
        var sb = new StringBuilder(capacity: 64);

        for (int i = 0; i < values.Length; i++)
        {
            if (i > 0) sb.Append(',');
            sb.Append(values[i]);
        }

        return sb.ToString();
    }
}

補間(少数の連結なら可読性優先):

var id = 123;
var name = "Alice";

// ★OK: 少数なら読みやすさを優先してよい
var line = $"id={id}, name={name}";

3-7. Regexは「読みやすさ」と「タイムアウト」をセットにする

結論: 外部入力にRegexを当てるなら、固まりやすい形を避ける前提を入れる。

なぜ悪い(具体):

  • Regexはパターンによって 最悪ケースで探索が爆発する形がある(バックトラックが増える)
  • 外部入力に無制限Regexを当てると、実行が長引き「応答が返らない」状態が出やすい
  • パターンが読めないと、仕様変更やバグ修正が止まりやすい

なぜ良い(具体):

  • matchTimeout を入れると、最悪ケースを上限で止められる
  • 命名グループを使うと、抽出結果の意味がコードに残る
using System;
using System.Text.RegularExpressions;

public static class RegexRules
{
    // 用途: 仕様が「パターン」で安定している入力の判定/抽出
    // 方針: 1) 読める(命名グループ) 2) 固まらない(タイムアウト) をセットにする
    public static bool TryExtractId(string input, out string id)
    {
        var re = new Regex(
            pattern: @"^ID=(?<id>[A-Z]{3}-\d{3})$",
            options: RegexOptions.CultureInvariant,
            matchTimeout: TimeSpan.FromMilliseconds(200));

        var m = re.Match(input);
        if (!m.Success)
        {
            id = "";
            return false;
        }

        // ★OK: 命名グループで意味を残す
        id = m.Groups["id"].Value;
        return true;
    }
}

4. 解説:用語の定義

結論: 比較条件とキーComparerを最初に揃えると、環境差の詰まりが減る。

  • 文化依存: 言語や地域設定により大文字小文字や並びの扱いが変わる要素
  • StringComparison.Ordinal: 文字のコード値で比較(内部ID/キー用途に寄せやすい)
  • StringComparison.CurrentCulture: 現在カルチャに従う比較(表示用の自然さに寄せやすい)
  • 空要素: "A,,C,"Split(',') したときに出る "" の要素
  • StringComparer: Dictionary/HashSetのキー比較を決めるComparer(ハッシュ計算と等価判定に関与)
  • Regexタイムアウト: 最悪ケースで長く回り続ける形を避けるための上限

5. 解説:評価規則(迷ったときの基準)

結論: 迷う箇所は「用途」で分ける。内部用途はOrdinal、表示用途はCulture寄りへ。

5-1. 比較/検索の基準(StringComparison)

狙い: StringComparison の選択が毎回変わる形を減らす。

  • 内部ID/キー/プロトコル: Ordinal / OrdinalIgnoreCase
  • UI表示用の自然な並び/比較: CurrentCulture / CurrentCultureIgnoreCase
  • .ToLower() / .ToUpper() で揃える: 変換コストと仕様解釈が混ざりやすい(Comparer/Comparisonへ寄せる)

5-2. キー比較の基準(StringComparer)

狙い: 大小無視が必要な用途を「値変換」で処理しない形へ寄せる。

  • キー用途: StringComparer.OrdinalIgnoreCase が第一候補
  • 表示名等のキー: StringComparer.CurrentCultureIgnoreCase が必要な場面もある(用途が明確なときだけ)

5-3. 分割の基準(Split)

狙い: 空要素を消す/残すが揺れない形へ寄せる。

  • 列用途(列数が意味を持つ): 空要素を残す(列として扱うなら空要素は消さない)
  • 値リスト用途(値だけが欲しい): 空要素を消す
  • トリム: 入力の空白差を吸収したいなら TrimEntries(.NET 6+)
  • CSV: 引用規約があるならSplitから撤退する(パーサへ寄せる)

5-4. 置換の基準(Replace)

狙い: 置換範囲が不明な形を減らす。

  • 全置換が仕様: Replace
  • 先頭だけ/1回だけ/特定範囲: IndexOf + 範囲明示(ReplaceFirst 等)
  • 大小無視の置換: Regexへ寄せるが、保守性と採用理由を先に確認する

5-5. 結合の基準(Join / StringBuilder)

狙い: 連結が遅い・メモリが増える理由を説明できる形へ寄せる。

  • 配列/リスト結合: string.Join
  • ループ連結: StringBuilder
  • 補間: 少数の連結で可読性優先に寄せる

5-6. Regexの基準

狙い: 強い道具を「強いまま」使う形へ寄せる。

  • 採用: 仕様がパターンとして安定、抽出が必要、手続きで書くと逆に読めない
  • 回避: StartsWith/Contains/Split で十分、仕様変更が頻繁、パターンが肥大化しやすい
  • 外部入力: タイムアウト必須、命名グループで意味を残す

6. 落とし穴(詰まりやすい形)

結論: ここが残ると「環境差」「null」「列ズレ」「置換暴発」「負荷」「固まり」が繰り返されやすい。

  • Contains(value) / Equals で比較条件が見えない
  • インスタンス呼び出しでnullが例外になる(比較が制御フローになる)
  • Split で空要素を消す/残すが場面で揺れる
  • Replace で「一部だけ」のつもりが全置換になる
  • ループで s += ... を続け、中間文字列が大量に発生する
  • Regexにタイムアウトがなく、外部入力で固まる形が残る

7. 禁書庫(決め打ちで読む)

結論: 迷ったらここへ戻すと、判断が短くなる。

禁書庫A: 逆引き(決め打ち)
  • 内部用途の検索/比較: Ordinal / OrdinalIgnoreCase
  • キー比較: StringComparer.OrdinalIgnoreCase を生成時に渡す
  • 列用途Split: 空要素を残す
  • 値リストSplit: 空要素を消す
  • 部分置換: 範囲明示(先頭/1回だけ)
  • ループ連結: StringBuilder
  • 外部入力Regex: タイムアウト必須
禁書庫B: 早見(代表API)
  • 検索: Contains(..., StringComparison) / IndexOf(..., StringComparison) >= 0
  • 比較: string.Equals(..., StringComparison)
  • 置換: 全置換はReplace、部分置換はIndexOf+範囲明示
  • 分割: Split は空要素とトリム方針が本体
  • 結合: 配列はstring.Join、ループはStringBuilder
  • Regex: 命名グループ+タイムアウト

8. 判例:混入点が多い順に潰す

結論: 例を見て終わりにせず、「影響」「理由」「直した後の意味」が説明できる形へ寄せる。

8-1. 比較条件が見えない Contains

狙い: 比較条件未指定が残す影響を言語化し、直し方の理由が説明できる形へ寄せる。

何が起きて悪いか(具体):

  • Contains("abc") は比較条件が見えず、文化依存/大小の扱いが暗黙になる
  • 後から「内部IDなのに文化依存だった」「大小無視のつもりが一致しない」の議論が起きやすい

なぜ直しが良いか(具体):

  • StringComparison を明示すると、比較方針がコード上の契約になる(レビューで揃う)

悪い例:

var text = "Abc-123";

// ★NG: 比較条件が見えない。文化依存/大小で揺れやすい
if (text.Contains("abc"))
{
}

直す:

var text = "Abc-123";

// ★OK: 内部用途の大小無視は OrdinalIgnoreCase へ寄せる
if (text.Contains("abc", StringComparison.OrdinalIgnoreCase))
{
}

ポイント:

  • 比較条件がコードに残る
  • 端末差を引き起こす要因が見える

8-2. Dictionaryのキー比較が既定のまま

狙い: 「大小無視が効かない」を、Comparerの仕組みで説明できる形へ寄せる。

何が起きて悪いか(具体):

  • Dictionary<string, ...> はComparerでハッシュと等価判定を行う
  • 既定Comparerのままだと、用途によっては "abc""ABC" が別キー扱いになり「見つからない」が起きる
  • 値側で .ToLower() に寄せると変換の割り当てが増え、変換漏れも混ざりやすい

なぜ直しが良いか(具体):

  • 生成時に StringComparer.OrdinalIgnoreCase を渡すと、比較方針が1点に寄り、割り当ても減る

悪い例:

using System.Collections.Generic;

var map = new Dictionary<string, int>();

// ★NG: 既定Comparer。用途によっては大小無視が効かず詰まる
map["abc"] = 1;

var ok = map.ContainsKey("ABC"); // false になりやすい

直す:

using System.Collections.Generic;

var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

// ★OK: キー用途の大小無視はComparerで決める
map["abc"] = 1;

var ok = map.ContainsKey("ABC"); // true

ポイント:

  • 値変換よりComparerで揃えた方が責任が明確
  • HashSetも同様に StringComparer を渡す

8-3. Split の空要素で列がズレる

狙い: 空要素の扱いが未決だと、どんな壊れ方になるかを説明できる形へ寄せる。

何が起きて悪いか(具体):

  • 列用途(位置が意味を持つ)で空要素を消すと、列番号が詰まり「別列の値を読み出す」形が起きる
  • 例: parts[1] が本来の列ではなくなり、後段のバリデーションや集計が壊れる

なぜ直しが良いか(具体):

  • 列用途/値用途を分け、空要素を残す/消すを最初に決めると説明が揃う

悪い例:

var src = "A,,C,";

// ★NG: 値リスト用途のつもりで空要素を消すと、列用途でズレる
var parts = src.Split(',', StringSplitOptions.RemoveEmptyEntries);

直す:

var src = "A,,C,";

// ★OK: 列用途なら空要素も残す
var columns = src.Split(','); // ["A", "", "C", ""]

// ★OK: 値リスト用途なら空要素を消す
var values = src.Split(',', StringSplitOptions.RemoveEmptyEntries); // ["A", "C"]

ポイント:

  • 列用途と値用途を混ぜない
  • 引用規約があるCSVはSplitから撤退する(パーサへ寄せる)

8-4. 「最初の1回だけ」のつもりで全置換

狙い: 全置換が残す影響を説明でき、範囲明示の理由が言える形へ寄せる。

何が起きて悪いか(具体):

  • Replace は全置換なので、「先頭だけ/1回だけ」のつもりでも想定外の箇所まで変わる
  • 変更範囲が説明できず、「どこが仕様か」が曖昧になりやすい

なぜ直しが良いか(具体):

  • 範囲を明示すると、置換意図がコードに残り、仕様変更時に追いやすい

悪い例:

var src = "abc-abc-abc";

// ★NG: 全置換。1回だけのつもりだと想定外の箇所も変わる
var dst = src.Replace("abc", "XYZ");

直す:

var src = "abc-abc-abc";

// ★OK: 最初の1回だけ置換。範囲が明確
var dst = ReplaceRules.ReplaceFirst(src, "abc", "XYZ", StringComparison.Ordinal);

ポイント:

  • 「どこを変えるか」がコードに残る
  • レビューで「置換範囲」が議論できる

8-5. ループ連結で割り当てが増える

狙い: 遅くなる理由とメモリ増加の理由を、割り当て/コピー/GCで説明できる形へ寄せる。

何が起きて悪いか(具体):

  • string は不変。s += x は毎回「新しいstring」を割り当て、旧stringの内容を丸ごとコピーする
  • 文字列が伸びるほどコピー量が増え、コピー総量が積み上がりやすい(反復が増えるほど重くなる)
  • 中間文字列が大量に発生し、GC回収頻度が増える。負荷が高い場面では待ちが出やすい

なぜ直しが良いか(具体):

  • StringBuilder は内部バッファへ追記し、中間文字列を作らずに最後に1回だけ最終stringを作れる
  • string.Join は配列結合の意図が明確で、まとめて確保しやすい

悪い例(毎回新しい文字列が作られ、内容コピーが積み上がる):

var s = "";

// ★NG: ループで + は反復ごとに新しいstringを作る
// - 旧sの内容 + 追加分 を毎回コピーし直す
// - 反復回数が増えるとコピー総量が二次的に増えやすい
for (int i = 0; i < 10000; i++)
{
    s += i.ToString();
}

直す(内部バッファへ追記し、割り当てとコピーを抑える):

using System.Text;

var sb = new StringBuilder(capacity: 256);

// ★OK: StringBuilderは内部バッファへ追記する
// - 中間文字列を大量に作らない
// - まとめてToString()で最終stringを1回作る
for (int i = 0; i < 10000; i++)
{
    sb.Append(i);
}

var s = sb.ToString();

ポイント:

  • ループ連結は「割り当て回数」と「コピー総量」が増えやすい
  • 中間文字列が増えるとGCが増え、スループットもレイテンシも悪化しやすい
  • 配列結合は string.Join が第一候補(意図が明確でまとめて確保しやすい)

8-6. Regexが固まる形が残る

狙い: 最悪ケースの挙動を説明でき、タイムアウトの理由が言える形へ寄せる。

何が起きて悪いか(具体):

  • パターン次第でバックトラックが増え、最悪ケースで長く回り続ける形がある
  • 外部入力に無制限Regexを当てると、応答が返らない状態が出やすい

なぜ直しが良いか(具体):

  • タイムアウトで最悪ケースに上限を入れ、固まりを避けられる
  • 命名グループで「何を抜いたか」を説明できる

悪い例:

using System.Text.RegularExpressions;

var re = new Regex(@"(a+)+$");

// ★NG: タイムアウト無し。最悪ケースで長く回り続ける形が残る
var ok = re.IsMatch("aaaaaaaaaaaaaaaaaaaa!");

直す:

using System;
using System.Text.RegularExpressions;

var re = new Regex(
    pattern: @"(a+)+$",
    options: RegexOptions.CultureInvariant,
    matchTimeout: TimeSpan.FromMilliseconds(200));

// ★OK: タイムアウトで最悪ケースを避ける
var ok = re.IsMatch("aaaaaaaaaaaaaaaaaaaa!");

ポイント:

  • 外部入力はタイムアウト前提
  • 命名グループで意味を残すと保守が止まりにくい

9. チェックリスト:レビューで見る所

結論: 表を通すだけで、詰まりの混入が減る。

観点 OK NG 見る場所
比較条件 StringComparison 明示 未指定 Contains/Equals/StartsWith/IndexOf
null耐性 string.Equals/起点で正規化 インスタンス呼び出し Equals/Trim/Length
キー比較 StringComparer 指定 既定Comparer Dictionary/HashSet生成
Split方針 空要素/トリム方針が明確 場面で揺れる Splitの引数/オプション
Replace範囲 部分置換は範囲明示 全置換が混入 Replace/IndexOf/Substring
連結手段 Join/Builderを用途で選ぶ ループで+ ループ/集計処理
Regex安全性 タイムアウト/意味が読める 無制限/肥大化 Regex生成/パターン

10. セルフチェック(5問)

結論: 5問に答えられると、迷い所が短くなる。

  1. 内部IDの比較で選ぶ StringComparison を理屈で言えるか
  2. Dictionaryのキー比較で StringComparer を渡す理由を言えるか
  3. Split で空要素を消す/残すの判断基準を言えるか
  4. 「最初の1回だけ置換」を Replace で書かない理由を言えるか
  5. ループ連結で + が遅くなりやすい理由を、割り当て/コピー/GCで説明できるか
回答例
  1. 内部ID/キー用途は Ordinal / OrdinalIgnoreCase に寄せやすい(文化依存を避ける)
  2. DictionaryはComparerでハッシュ計算と等価判定を行う。生成時に渡すと方針が揃い、値変換の割り当ても減る
  3. 列用途は空要素を残す、値リスト用途は空要素を消す
  4. Replace は全置換。部分置換は範囲を明示した方が意図が残る
  5. string は不変で、s += x は毎回新規文字列を割り当て、内容コピーが積み上がり、GCも増えやすい

関連トピック


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?