はじめに
文字列処理は日常的に書くコードだからこそ、非効率な書き方や環境依存の挙動が見過ごされやすい領域です。ループ内の文字列連結や正規表現の書き方は、データ量が少ない開発環境では問題にならず、本番の大量データで処理時間やメモリ使用量として顕在化します。また、Shift_JISのような文字コードは.NETのバージョンによって既定の扱いが異なり、移行時に突然例外になることがあります。本記事では、実務でよく遭遇する3つの落とし穴を扱います。
前提知識 / 用語整理
-
イミュータブル:
stringは一度作成すると内容を変更できない。連結や置換のたびに新しい文字列が生成される -
StringBuilder: 内部バッファに文字列を追加していく可変の文字列構築クラス - バックトラッキング: 正規表現エンジンがマッチに失敗した際、別のパターンを試すために後戻りする動作
-
コードページ:
Shift_JIS(932)などの旧来の文字コード体系。.NET Core以降は既定で含まれない
落とし穴1: ループ内で+=による文字列連結を繰り返す
再現コード
// ❌ ループのたびに新しい文字列が生成される
public string BuildCsv(IEnumerable<Order> orders)
{
var csv = "";
foreach (var order in orders)
{
csv += $"{order.Id},{order.Name},{order.Amount}\n";
}
return csv;
}
なぜ起きるか
stringはイミュータブルなため、+=のたびに「それまでの内容+追加分」を格納する新しい文字列を確保し、古い文字列は破棄されます。N件のループでは、毎回これまでの全文字をコピーするため、処理量が件数の2乗に比例して増えます。数百件のテストデータでは差が出ませんが、数万〜数十万件のCSV出力では、処理時間の増大とGCの頻発(特に大きな文字列はLOH=ラージオブジェクトヒープに確保される)として現れます。
正しい書き方
// ◎ StringBuilderでバッファに追記する
public string BuildCsv(IEnumerable<Order> orders)
{
var sb = new StringBuilder();
foreach (var order in orders)
{
sb.Append(order.Id).Append(',')
.Append(order.Name).Append(',')
.Append(order.Amount).Append('\n');
}
return sb.ToString();
}
(ただし、ループを伴わないa + b + cのような1つの式での連結は、コンパイラがstring.ConcatにまとめるためStringBuilderは不要です。出力先がファイルやネットワークの場合は、文字列に溜め込まずにStreamWriterへ直接書き込む方が、メモリ使用量の面でさらに有利です)
落とし穴2: 正規表現の壊滅的バックトラッキングと生成コスト
再現コード
// ❌ 入れ子の量指定子を含むパターン
var pattern = @"^(a+)+$";
// ❌ 呼び出しのたびにRegexインスタンスを生成
public bool IsValid(string input) =>
new Regex(pattern).IsMatch(input);
IsValid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"); // 非常に長い時間がかかる
なぜ起きるか
(a+)+のように量指定子が入れ子になったパターンは、マッチに失敗する入力に対して、組み合わせ爆発的にバックトラッキングが発生します。入力の長さが少し増えるだけで処理時間が指数的に増え、1つのリクエストでCPUを占有し続けることになります。ユーザー入力を正規表現で検証するAPIでは、これを悪用されるとサービス拒否(ReDoS)につながります。また、new Regex(...)をメソッド呼び出しのたびに生成すると、パターンの解析コストが毎回かかります。
正しい書き方
// ◎ タイムアウトを設定し、Regexを再利用する
private static readonly Regex AlphaOnly = new(
@"^[a-z]+$", // 入れ子の量指定子を避けたパターンにする
RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100)); // マッチ時間の上限
public bool IsValid(string input)
{
try
{
return AlphaOnly.IsMatch(input);
}
catch (RegexMatchTimeoutException)
{
return false;
}
}
// ◎ .NET 7以降: バックトラッキングをしないエンジンを選択する
private static readonly Regex Safe = new(@"^(a+)+$", RegexOptions.NonBacktracking);
// ◎ .NET 7以降: ソースジェネレーターでコンパイル時にコードを生成する
public static partial class Validators
{
[GeneratedRegex(@"^[a-z]+$", RegexOptions.None, matchTimeoutMilliseconds: 100)]
public static partial Regex AlphaOnly();
}
RegexOptions.NonBacktrackingは処理時間が入力長に比例しますが、後読み・後方参照など一部の構文が使えない制限があります。外部からの入力に正規表現を適用する場合は、パターンの見直しに加えて、matchTimeoutを必ず設定してください。
落とし穴3: .NET Core以降でShift_JISが使えず例外になる
再現コード
// ❌ .NET Framework時代のコードをそのまま移行
var encoding = Encoding.GetEncoding("shift_jis");
var text = File.ReadAllText("legacy.csv", encoding);
// => ArgumentException: 'shift_jis' is not a supported encoding name.
なぜ起きるか
.NET Framework(Windows専用)ではOSのコードページを利用できましたが、.NET Core以降はクロスプラットフォームのため、既定で含まれるエンコーディングはUTF-8・UTF-16・ASCIIなどに限られます。Shift_JIS(コードページ932)のような旧来のエンコーディングは、追加のプロバイダーを登録しないと使えません。また、エンコーディングを指定せずにFile.ReadAllTextを呼ぶと、UTF-8として読み込むため、Shift_JISのファイルは文字化けします。
正しい書き方
// ◎ アプリケーション起動時に一度だけプロバイダーを登録する
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var encoding = Encoding.GetEncoding("shift_jis");
var text = File.ReadAllText("legacy.csv", encoding);
CodePagesEncodingProviderはSystem.Text.Encoding.CodePagesパッケージに含まれます(.NET Core 3.0以降は共有フレームワークに含まれるため、通常は追加インストール不要です)。登録はProgram.csの冒頭などで1回行えば、以降のコードからEncoding.GetEncodingで利用できます。なお、shift_jisとWindows-31J(windows-31j、コードページ932)は同じコードページ932を指すため、Encoding.GetEncoding(932)のようにコードページ番号で指定しても同じエンコーディングを取得できます。
ベストプラクティスまとめ
-
ループ内で文字列を連結する場合は
StringBuilderを使う(出力先がストリームなら直接書き込む) -
正規表現は
static readonlyで再利用するか、[GeneratedRegex]を使う -
外部入力に適用する正規表現には
matchTimeoutを設定し、入れ子の量指定子を避ける -
.NET 7以降では
RegexOptions.NonBacktrackingの利用を検討する -
Shift_JIS系のファイルを扱うアプリでは、起動時にCodePagesEncodingProviderを登録する - ファイルの読み書きでは、エンコーディングを明示的に指定する
おわりに
文字列処理の問題は、データ量や実行環境が本番に近づいて初めて表面化するものが多く、書いた時点では気づけません。連結にはStringBuilder、正規表現にはタイムアウトと再利用、レガシーな文字コードには明示的なプロバイダー登録、という3点を習慣にしておくと、運用開始後のトラブルを減らせます。