基礎●●○○○

正規表現

Regular Expression ・ せいきひょうげん

文字列のパターンを小さな記法で表現し、検索・抽出・置換を行う道具。ほぼすべての言語とツールに載っている。

✦ 30秒で試す — 航海入力チェックのたった1行が、世界中のサーバを30分止めたa を20個に増やすと、試す回数は?予想してみる

概要

正規表現は、「数字4桁、ハイフン、数字2桁…」のような文字列のパターンを、記号を組み合わせた小さな式で表現する記法です。たとえば \d{4}-\d{2}-\d{2} と書けば「2026-07-18 のような日付らしき文字列」にマッチします。この式ひとつで、検索(含まれているか)、検証(形式が正しいか)、抽出(該当部分を取り出す)、置換(別の文字列に書き換える)ができます。

活躍の場は驚くほど広く、エディタの検索置換、シェルから使う grep や sed などのコマンド、フォームの入力検証、ログの解析、Web フレームワークのルーティングで URL のパターンを振り分ける処理まで、文字列を扱うほぼすべての場面に顔を出します。JavaScript・Python・Java など主要言語のすべてに標準装備されており、プログラミング言語をまたいで通用する数少ない「共通語」のひとつです。

一方で、記号の羅列は初見では呪文のように見え、書けても読めない・読めても保守できないコードの温床にもなりがちです。部品ごとの意味と、マッチングの裏側で何が起きているかを一度押さえてしまえば、呪文は「小さなプログラミング言語」として読み解けるようになります。

なぜ生まれたか

正規表現がなかった時代、文字列のパターン検索や形式チェックは、1文字ずつ読み進めるループと条件分岐を毎回手書きするしかありませんでした。「数字が4つ続いてハイフンが来て…」を素朴に書くとそれだけで数十行になり、パターンが少し変わるたびにループを書き直すことになります。処理の本質は「どんなパターンか」なのに、コードの大半は「どう照合するか」の手続きで埋まってしまう — この乖離が問題でした。正規表現は、パターンそのものを宣言的に(手順ではなく形として)記述する小言語を用意し、照合の手続きはエンジンに任せる、という分業で解決します。

系譜をたどると出発点は意外にも純粋数学で、1951年に数学者スティーヴン・クリーネが「正規言語」という文字列集合のクラスを定義したことに始まります。1968年にケン・トンプソンがこの理論をテキストエディタ QED の検索機能として実装し、そこから生まれた UNIX の grep が実用ツールとしての地位を確立しました。トンプソンの実装は、パターンを状態遷移の網(NFA)に翻訳し、ありうる状態を同時に進める方式で、入力の長さに比例した時間で必ず終わるものでした。1980年代には Perl が後方参照などの拡張を加えて実務の道具として磨き上げ、その方言(PCRE: Perl互換正規表現)が事実上の標準として各言語に広がっていきました。ただしこの拡張と引き換えに、多くのエンジンは「試してだめなら戻る」方式に移り、後で見る ReDoS の弱点を抱えることになります。

1951 クリーネの正規言語理論1968 トンプソンによるエディタ実装1973 UNIX の grep1980年代 Perl による拡張と普及現在 ほぼ全言語に標準装備
理論から共通語へ — 正規表現の系譜

詳細

基本部品 — 5種類覚えれば読める

正規表現の記法は多く見えますが、部品は5種類に整理できます。(1)リテラル: abc はそのまま文字列 abc にマッチします。(2)文字クラス: [0-9] は数字1文字、\d はその略記、. は任意の1文字です。(3)量指定子: 直前の部品の繰り返し回数で、* は0回以上、+ は1回以上、? は0か1回、{4} はちょうど4回です。(4)アンカー: ^ は文字列の先頭、$ は文字列の末尾という「位置」にマッチし、文字を消費しません。複数行モード(JavaScript の m フラグなど)を付けたときだけ、各行の行頭・行末にもマッチします。検証用途で ^...$ と囲み忘れると「一部だけ一致」を通してしまう定番のミスがあります。さらに、Python や Java の $ は末尾の改行の直前にもマッチするので、^\d+$ が 123\n(末尾に改行)を通してしまいます。厳密に全体を検証したいなら、Python の re.fullmatch や \Z、Java の matches() や \z を使います。(5)グループと選択: (...) はまとまりを作って抽出(キャプチャ)に使い、| は「または」を表します。

対象例: 2026-07-18^\d{4}-\d{2}-\d{2}$アンカー先頭の位置文字クラス + 量指定子数字がちょうど4回リテラルハイフンそのもの数字がちょうど2回月の部分リテラル + 繰り返し日の部分アンカー末尾の位置^と$で囲むと全体一致の検証に
パターンの分解 — 日付形式を検証する正規表現の部品と役割

貪欲と怠惰 — 「マッチしすぎる」問題

量指定子は既定で「貪欲(greedy)」、つまり可能な限り長くマッチしようとします。"a".*" のようなパターンを say "hi" and "bye" に適用すると、.* は最初の " から最後の " までを一気に飲み込み、"hi" and "bye" 全体にマッチしてしまいます。「最初の閉じ引用符まで」で止めたいときは .*? のように ? を付けて「怠惰(lazy)」にするか、[^"]*(引用符以外の繰り返し)と書きます。「思ったより広くマッチする」トラブルの大半はこの貪欲さが原因で、後者の否定文字クラスを使う書き方のほうが意図も明確で性能も安定するため、実務ではまずこちらを検討します。

バックトラッキングと ReDoS

この「マッチしすぎたら戻ってやり直す」動きをバックトラッキングと呼び、Perl 系エンジンのマッチングの基本戦略です。量指定子や | のところで「まず一方を試し、だめならもう一方」と分かれ道を覚えておき、先で失敗したら最後の分かれ道まで戻って別の道を試します。理論上、正規表現は有限オートマトン(状態遷移機械)に変換して文字列を1回走査するだけで照合できます。この理論はコンパイラの字句解析(ソースコードを単語に切り出す工程)の基礎でもあります。しかし後方参照などの拡張機能はオートマトンでは表現できないため、JavaScript・Java・Python・PCRE など多くのエンジンは、この試行錯誤方式を採っています。

この方式には落とし穴があります。(a+)+$ のように繰り返しを入れ子にしたパターンに aaaa...! のようなマッチしない入力を与えると、エンジンは a の並びを外側と内側の + で「どこで区切るか」の組み合わせを、失敗が確定するまで総当たりします。n 文字の a の区切り方は 2の(n−1)乗 通りあるので、1文字増えるごとに時間がほぼ倍になります。これが破滅的バックトラッキング(catastrophic backtracking)で、これを突いて1本の文字列でサーバの CPU を占有させる攻撃を ReDoS(正規表現サービス拒否攻撃)と呼びます。一致する入力なら最初の道で答えが見つかって速く終わるため、テストでは気づきにくいのも厄介な点です。

下の部品で確かめてください。「a を足す」を押して入力を1文字ずつ伸ばすと、エンジンのカーソルが進んでは戻る軌跡と、文字数ごとの試行回数の棒が描かれます。^(a+)+$ と ^a+$、バックトラックと線形のエンジンを切り替えて比べてみてください。

⚡ 体験: a を1文字ずつ足す — エンジンのカーソルが進んでは戻る
エンジン
aaaaaaaa!カーソルの軌跡(下へ行くほど後の試行)最初の 360 回だけ(全体 1,651 回)文字数ごとの試行回数(対数)101001千10千100千1百万10百万181624文字数 →
試行回数1,651結果不一致1文字足すと—エンジンバックトラック

^(a+)+$ に「a が並んで最後に ! 」の文字列を与えています。「a を足す」を何度か押して、下の軌跡と右の棒がどう伸びるかを見てください。

事故は実際に起きています。2016年には Stack Overflow が、投稿の前後の空白を取り除く正規表現が約2万文字の空白を含む投稿でバックトラックし続けたことで、30分あまり停止しました。2019年には Cloudflare が、WAF に追加したルールの正規表現 .*(?:.*=.*) が世界中のサーバの CPU を使い切り、約30分にわたって配下のサイトがエラーを返しました。入力を守るために置いた正規表現そのものが、サービスを止める側に回ったわけです。

対策は、繰り返しの入れ子や、同じ文字にマッチしうる選択肢の重なり((a|aa)+ など)を避けること、照合する入力の長さやマッチ時間に上限を設けること、そしてバックトラッキングを使わない線形時間のエンジンを選ぶことです。Google の RE2 や、同じ方式を採った Go の regexp、Rust の regex は、後方参照を諦める代わりに、どんなパターンと入力でも入力長に比例した時間で終わることを保証しています。

正規表現で解くべきでない問題

最後に、道具の限界を知っておくことが重要です。正規表現が扱えるのは本来「正規言語」、直感的には入れ子の対応を数えなくてよいパターンまでです。HTML や JSON のようにタグや括弧が任意の深さで入れ子になる構造は原理的に守備範囲外で、「HTML を正規表現でパースしようとするな、パーサを使え」は先人たちの傷跡が刻まれた格言です。また、メールアドレスの完全な検証のように仕様が複雑すぎるものも、簡易チェックに留めて確認メール送信など別の手段と組み合わせるのが現実解です。1行で書けそうに見えても、読み手が解読に10分かかる正規表現は負債になります。長くなったらコメント付きの冗長モードで書く、複数の単純な式に分割する、いっそ普通のコードで書く — 「書けるか」ではなく「保守できるか」で使いどころを判断するのが、この強力な小言語との正しい付き合い方です。

試問

フォームの入力検証に、正規表現 ^(a+)+$(a だけが1文字以上並んだ文字列か)を使っています。攻撃者から「a が20個並んで最後に !」という一致しない文字列が届きました。エンジンは JavaScript と同じバックトラック型で、入力は変えられませんが、パターンの欄は書き換えられます。下の軌跡はエンジンのカーソルが進んでは戻る様子、読み取りの「試行回数」は照合にかかった手数です。

✦ 命題挑戦中

入力検証に使う正規表現 ^(a+)+$ を、意味を変えずに書き直し、攻撃の入力(a が20個+!)の照合を試行1,000回以下で終わらせよ

  • パターンは「a だけが1文字以上並んだ文字列の全体」に一致する(意味を変えない)
  • 攻撃の入力の照合が、試行 1,000回以下で終わる(いま 5,000,000+回)
⚡ 体験: a を1文字ずつ足す — エンジンのカーソルが進んでは戻る
aaaaaaaaaaaaaaaaaaaa!カーソルの軌跡(下へ行くほど後の試行)最初の 360 回だけ(全体 5,000,000 回・打ち切り)文字数ごとの試行回数(対数)101001千10千100千1百万10百万181624文字数 →
試行回数5,000,000+結果打ち切り1文字足すと—エンジンバックトラック

入力は攻撃者が送ってきた「a が20個並んで最後に !」の文字列で、変えられません。エンジンは JavaScript と同じバックトラック型です。パターンの欄を書き換えて、試行回数がどう変わるかを見てください。