正規表現が遅い?「破滅的バックトラック」を防ぐ記述術

Stack Overflowからコピペした正規表現が動くことは確認した。しかし、なぜそのコードが動くのか、あるいは「なぜ特定の入力でサーバーのCPU使用率が100%に張り付くのか」を説明できるでしょうか?正規表現は単なる文字列検索ツールではなく、強力なステートマシンです。その挙動を深く理解せずに使うことは、時限爆弾をプロダクション環境に埋め込むことと同義です。今回は、基本の再確認から、多くのエンジニアが見落としがちな「パフォーマンスと安全性」に焦点を当てて解説します。

1. エンジニアが知るべき「見えないコスト」

多くの入門記事では「この記号は何にマッチするか」を教えますが、「エンジンがどう動いているか」は省略されがちです。しかし、実務で重要なのは後者です。

正規表現エンジン(特にNFA型)は、マッチに失敗すると「バックトラック(後戻り)」を行います。これは迷路探索と同じで、行き止まりに当たると分岐点まで戻り、別の道を試す処理です。単純なパターンなら問題ありませんが、書き方ひとつで計算量が指数関数的に爆発します。

ReDoS(Regular Expression DoS)の脅威
悪意のあるユーザーが、意図的に大量のバックトラックを引き起こす文字列を送信することで、サービスを停止させることが可能です。(a+)+ のようなネストされた量指定子は絶対に避けてください。

2. 量指定子の「貪欲」と「怠惰」を制御する

デフォルトの正規表現は「貪欲(Greedy)」です。つまり、条件を満たす限り、できるだけ長い文字列を飲み込もうとします。これがパフォーマンス低下の主因になることがあります。

以下のHTMLタグを抽出する例を見てみましょう。


// ターゲット文字列: "<div>コンテンツ</div><span>テスト</span>"

// 悪い例(貪欲): 最後の > まで一気にマッチしてしまう
const greedy = /<.*>/; 
// 結果: "<div>コンテンツ</div><span>テスト</span>" 全体が1つのマッチになる

// 良い例(怠惰): 最初の > を見つけた時点で止まる
const lazy = /<.*?>/;
// 結果: "<div>", "</div>", "<span>"... と個別にマッチする

? を量指定子の後ろにつけるだけで「最短一致(Lazy)」になります。しかし、これだけでは不十分な場合があります。そこで登場するのが「独占的量指定子(Possessive Quantifiers)」です。

独占的量指定子で「後戻り」を断つ

JavaやPHP(PCRE)などで利用可能な ++*+ は、「一度マッチしたら、何があってもその領域を手放さない(バックトラックしない)」という強い意志を持ちます。


// 通常の貪欲マッチ(バックトラックあり)
preg_match('/".*"/', '"abc"', $m); 
// エンジンは最後の " まで進み、一致を確認するために戻ってくる

// 独占的マッチ(バックトラックなし)
preg_match('/".*+"/', '"abc"', $m);
// パフォーマンスは向上するが、使い方を誤るとマッチすべきものもマッチしなくなるため注意

3. 「書き込み専用言語」からの脱却

正規表現が嫌われる最大の理由は「可読性の低さ」です。3ヶ月前の自分が書いた ^([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$ を見て、即座にバグ修正できますか?

多くの言語(Python, PHP, Ruby, JavaScript(ES2018+))では、x フラグ(Verboseモード)をサポートしています。これを使うと、パターン内に空白やコメントを含めることができます。


import re

# 従来の書き方
pattern_bad = r"^\d{3}-\d{4}$"

# 推奨される書き方(VERBOSEモード)
pattern_good = re.compile(r"""
    ^          # 行の先頭
    \d{3}      # 郵便番号の前半3桁
    -          # ハイフン
    \d{4}      # 郵便番号の後半4桁
    $          # 行の末尾
""", re.VERBOSE)
Pro Tip: チーム開発において、複雑な正規表現をコメント無しでコミットするのは「技術的負債」を増やしているのと同じです。必ずVerboseモードを使うか、コード上のコメントでロジックを説明しましょう。

4. 先読み・後読みの魔術(Lookarounds)

「ある文字を含みたいが、マッチ結果には含めたくない」という場合、先読み(Lookahead)と後読み(Lookbehind)が役立ちます。これは位置の検証だけを行い、カーソルを進めない「ゼロ幅アサーション」です。

  • 肯定的先読み (?=...): 直後に...がある位置
  • 否定的先読み (?!...): 直後に...がない位置

例えば、「パスワードポリシー(8文字以上、かつ数字を1文字以上含む)」を1行で書く場合、これらが必須になります。


const passwordRegex = /^(?=.*\d).{8,}$/;
// (?=.*\d) が「どこかに数字があるか」を先読みチェックし、
// 合格した場合のみ .{8,} の長さチェックを行う

結論:ツールを使いこなせ

正規表現は、ログ解析やデータクレンジングにおいて最強の武器ですが、素手で扱うには危険すぎます。実装する際は、必ず以下のステップを踏んでください。

  1. regex101.com などのオンラインテスターで挙動を確認する。
  2. デバッガー機能を使い、ステップ数(バックトラック回数)が爆発していないか確認する。
  3. 言語固有の仕様(JavaとJavaScriptでは微妙に挙動が違う)をドキュメントで裏付けする。

「動くからヨシ」ではなく、「効率的で安全だからヨシ」と言える正規表現を目指しましょう。

Post a Comment