正規表現は、文字列のパターンを記号で書き表すための記法です。日本語の解説記事はメタ文字の一覧で終わることが多いのですが、受託開発の現場で実際に事故になるのは記法の暗記不足ではありません。別の言語へ移したら同じパターンが動かない、テスト環境では一瞬で終わった処理が本番の入力で数十秒かかる、レビューで誰も読めないまま通ってしまう。この3つが大半です。この記事では、基本記号を実行できるコードで確認したうえで、処理系ごとの方言差、バックトラッキングによる計算量の破綻とその防ぎ方、そして正規表現を使わないほうがよい場面までを、各処理系の公式ドキュメントの記述と対応させながら順に扱います。
まとめ|正規表現は書ける記法より止まらない書き方から先に決める
正規表現の記法そのものは半日で覚えられます。時間をかける価値があるのは、書いたパターンが想定外の入力でも一定時間で終わるかどうかの判断です。PCREやPerl、Python、JavaScript、Javaの正規表現エンジンはバックトラッキング方式で動くため、入れ子になった量指定子を書くと入力1文字ごとに実行時間が倍増する状態を簡単に作れます。Microsoft Learnの正規表現ベストプラクティスは、この状態を「比較的短い入力文字列を処理するのに数時間から数日かかる場合がある」と表現しています。
実装時に決めておく順番は3つです。第一に、外部から来る入力を扱うパターンには必ずタイムアウトと入力長の上限を付ける。第二に、先読みや後方参照を使っていないなら線形時間で動くエンジンへ寄せられないか検討する。第三に、メールアドレスやHTMLのように仕様が正規言語で書けない対象は、そもそも正規表現で処理しない。
移植で壊れるのは主に、単語境界の指定方法、後読みの可否、Unicodeプロパティの扱い、そしてエスケープが必要な文字の範囲です。同じパターン文字列をコピーして別言語へ持っていくと、エラーにならないまま結果だけが変わる場合があるので、移植時は必ず両方の環境で同じ入力を通して比較してください。
正規表現の基本記号をPythonとJavaScriptの実行例で確認する
正規表現は、そのまま文字として扱われるリテラルと、特別な意味を持つメタ文字の組み合わせで書きます。読み方の順序としては、まずどこがメタ文字なのかを見分けられるようになるのが先で、記号ごとの意味を覚えるのは後です。
メタ文字とリテラルの境界を押さえて誤マッチを減らすための読み方
メタ文字は処理系によって多少増減しますが、共通して特別扱いされるのは . ^ $ * + ? { } [ ] \ | ( ) の14文字です。この中で事故が多いのはドットとバックスラッシュの2つ。ドットは「改行以外の任意の1文字」なので、3.14 というパターンは 3x14 にも一致します。数値としてのピリオドを表したいなら 3\.14 とエスケープが要ります。
角かっこで囲む文字クラスの中では、メタ文字の意味が変わる点も押さえておきます。[.] の中のドットは単なるピリオドで、エスケープは不要です。逆にハイフンは文字クラスの中だけ範囲指定の意味を持つため、リテラルのハイフンを入れたいときは先頭か末尾へ置きます。文字クラスの外と中で読み方を切り替える、というのが最初の関門になります。
Pythonのreモジュールで名前付きグループを取り出す実行例
実際に動かして確認します。Pythonでは 標準ライブラリのreモジュールを使い、部分一致には re.search、全体一致には re.fullmatch を使い分けます。入力検証では全体一致のほうを選んでください。re.match は先頭からの一致しか見ないため、末尾に余計な文字が付いた入力を通してしまいます。
import re
# 全体一致で郵便番号を検証する(名前付きグループ (?P<name>...) で部品を取り出す)
PATTERN = re.compile(r"(?P<head>\d{3})-(?P<tail>\d{4})")
for value in ["123-4567", "123-4567abc", "1234567"]:
m = PATTERN.fullmatch(value)
if m:
print(value, "OK", m.group("head"), m.group("tail"))
else:
print(value, "NG")
# 出力
# 123-4567 OK 123 4567
# 123-4567abc NG
# 1234567 NG
名前付きグループの構文は処理系で分かれます。Pythonは (?P<name>...)、JavaScriptとJava、.NETは (?<name>...) です。MDNの正規表現リファレンスによれば、JavaScriptでは後方参照も \k<name> という名前付きの形で書けます。番号での参照は途中にグループを1つ足しただけで全部ずれるので、キャプチャが2つ以上あるパターンは最初から名前を付けておくほうが保守しやすい構成です。パターンで取り出した値をどう検証へつなぐかはバリデーションの設計で整理しています。
量指定子の貪欲と非貪欲がマッチ結果を変える仕組みを実例で追いかける
* + ? {m,n} は繰り返し回数を指定する量指定子です。既定ではできるだけ長く一致しようとする貪欲な動作をし、後ろに ? を足すとできるだけ短く一致する非貪欲な動作に変わります。この差は結果の正しさに直結します。
貪欲な量指定子がHTMLタグの抽出で行き過ぎる典型的な失敗例
よくあるのはタグの中身を取り出そうとする場面です。入力が1件だけのときは動くのに、同じタグが2つ以上並んだ瞬間に、1つ目の開始から最後の終了までを丸ごと拾ってしまう。原因は、ドットと * の組み合わせが行末まで飲み込んでから後戻りする動きにあります。
import re
text = "<b>first</b> and <b>second</b>"
greedy = re.findall(r"<b>(.*)</b>", text)
lazy = re.findall(r"<b>(.*?)</b>", text)
excl = re.findall(r"<b>([^<]*)</b>", text)
print(greedy) # ['first</b> and <b>second']
print(lazy) # ['first', 'second']
print(excl) # ['first', 'second']
非貪欲にすれば直る、と覚えている人は多いのですが、この場面での本命は3番目の書き方です。[^<]* のように「一致してはいけない文字」を明示すると、エンジンが後戻りする余地そのものが消えます。非貪欲は後戻りを1文字ずつ前へ進める方向に変えるだけで、後戻り自体は残ります。
非貪欲とアトミックグループでバックトラックを止める具体的な書き方
後戻りを完全に禁止したいときは、アトミックグループ (?>...) か所有量指定子 *+ ++ を使います。一度一致させた範囲をエンジンが手放さなくなるため、無駄な再試行が起きません。Pythonでは公式ドキュメントのとおり、どちらも3.11で追加されました。それより前のバージョンでは使えないので、実行環境のバージョンを確認してから採用してください。
import re
# Python 3.11 以降。アトミックグループと所有量指定子
print(re.fullmatch(r"(?>\w*)@example\.com", "[email protected]")) # None ではなく一致
# 所有量指定子は後戻りしないので a*+a は 'aaaa' に一致しない
print(re.fullmatch(r"a*+a", "aaaa")) # None
print(re.fullmatch(r"a*a", "aaaa")) # 一致する
Javaはもともと所有量指定子を持っており、java.util.regex.PatternのドキュメントにGreedy・Reluctant・Possessiveの3種類が並記されています。JavaScriptには2026年9月時点でどちらも入っていないため、同じ効果を得るには先読みを使った書き換えが必要です。この非対称さが、次の章で扱う移植の落とし穴になります。
言語ごとに違う正規表現エンジンの方言と移植で壊れる箇所を整理する
正規表現には単一の標準がありません。POSIXが定めた系統と、Perlが広げてPCREが受け継いだ系統があり、そこへ各言語の独自拡張が乗っています。パターン文字列をそのままコピーして別の環境へ持ち込むと、エラーが出ないまま結果だけ変わることがあります。
POSIXのBREとEREとPCREで書き方が変わる記法の対応関係
POSIXの正規表現はThe Open Groupの仕様書第9章で定義され、基本正規表現(BRE)と拡張正規表現(ERE)の2系統に分かれます。BREでは + ? | ( ) { } がリテラル扱いで、メタ文字として使うにはバックスラッシュの前置きが必要です。EREはこの関係が逆で、記号のまま特別な意味を持ちます。
| 書きたいこと | BRE | ERE / PCRE |
|---|---|---|
| 1回以上の繰り返し | \+ | + |
| グループ化 | \( \) | ( ) |
| 選択(または) | \| | | |
| 数字1文字 | [0-9] | \d |
| 単語境界 | \b(実装依存) | \b |
表の最下段のとおり、\d や \w といった省略記法はPOSIXの仕様には含まれず、Perl由来の拡張です。sedやgrepの既定モードで \d が数字に一致しないのはこのためで、PCRE2のパターン仕様を前提に書いたパターンをシェルへ持ち込むときは、まずこの層の違いを疑ってください。
RE2が先読みとバックリファレンスを持たない理由と移植時の注意点
GoやCloudflare Workers、BigQueryなどで使われるRE2は、設計思想が根本から違います。RE2のリポジトリは「照合時間が入力文字列の長さに対して線形であることが主要な保証のひとつ」と明記し、そのうえで「バックトラッキングによる解法しか知られていない構文は対応しない。したがって後方参照と先読み・後読みは対応していない」と述べています。
つまりRE2で動くパターンはPCREでもほぼ動きますが、逆は成り立ちません。PCRE前提で書いた (?=...) を含むパターンをGoへ移すとコンパイル時にエラーになります。これは実行時に静かに壊れるより扱いやすい失敗で、移植の判断材料としてはむしろ好都合です。先読みを使わずに書き直せるなら、線形時間の保証が付いてくると考えてください。
JavaScriptとJavaで同じパターンが違う結果になる代表的な差
同じPerl系統の中でも差は残ります。JavaScriptのフラグは d g i m s u v y の8種類で、MDNによれば v フラグは「u モードをアップグレードし、もっと多くのUnicode機能を使用可能にする」ものです。u や v を付けないと、絵文字のようなサロゲートペアを含む文字が2文字として数えられ、. が半分だけ拾う状態になります。
Javaでは Pattern.UNICODE_CHARACTER_CLASS を指定しない限り \w がASCIIの範囲だけを指すのに対し、Pythonの str パターンでは既定でUnicode全体が対象です。日本語を含む入力で \w を使っている箇所は、移植のたびに挙動を確認する対象になります。TypeScriptで型の側から誤りを防ぐ手もあり、その方向性はArkRegexの解説で扱っています。
ReDoSを再現して正規表現由来のサービス停止を防ぐ実装手順を示す
バックトラッキング型エンジンの弱点を突く攻撃がReDoSです。特別な権限も大量のリクエストも要らず、細工した1件の文字列だけでCPUを占有できるため、外部入力を正規表現に通しているアプリケーションはすべて対象になります。
入れ子の量指定子が指数時間に化ける条件を実行コードで再現する手順
危険なパターンには特徴があります。OWASPのReDoS解説が挙げるとおり、繰り返しの中に繰り返しがあり、かつ内側と外側が同じ文字に一致できる構造が代表例です。(a+)+ や (\s*\w+)* がこれにあたります。手元で再現できます。
import re, time
pattern = re.compile(r"^(a+)+$")
for n in [20, 22, 24, 26]:
payload = "a" * n + "!" # 末尾の ! で必ず不一致にする
start = time.perf_counter()
pattern.fullmatch(payload)
print(n, round(time.perf_counter() - start, 3), "sec")
# 手元の実行例(環境で変動する)
# 20 0.06 sec
# 22 0.25 sec
# 24 1.02 sec
# 26 4.11 sec
入力が2文字増えるごとに時間が約4倍になっています。文字数を40まで伸ばせば数時間の単位に届く計算で、これが「入力文字ごとに実行時間が倍増する」と言われる状態です。攻撃の成立条件と被害の広がり方はReDoSの解説記事で詳しく扱っています。
タイムアウトと入力長の上限でReDoSの被害範囲を封じ込める設定
パターンを直すのが本筋ですが、既存コードすべてを審査するのは現実的でない場合があります。先に効くのは、入力長の上限を検証の手前で切ることと、照合そのものにタイムアウトを設けることの2点です。前者は正規表現に到達する文字列を短く保ち、後者は万一の暴走を打ち切ります。
using System.Text.RegularExpressions;
// タイムアウトを明示する(既定は Regex.InfiniteMatchTimeout = 打ち切らない)
var re = new Regex(@"^(a+)+$", RegexOptions.None, TimeSpan.FromMilliseconds(200));
try {
re.IsMatch(userInput);
} catch (RegexMatchTimeoutException e) {
// 一致しなかったものとして扱い、ログへ残す
logger.Warn($"regex timeout: {e.MatchTimeout.TotalMilliseconds} ms");
}
Microsoft Learnは、既定のタイムアウト間隔が Regex.InfiniteMatchTimeout であり「正規表現はタイムアウトしない」と明記しています。つまり何も指定しなければ打ち切りは効きません。Pythonの標準ライブラリにはタイムアウト引数がないため、別プロセスや別スレッドで実行して打ち切るか、次に述べる線形時間のエンジンへ寄せる判断になります。
線形時間のエンジンへ切り替えて危険なパターンを無害化する選択肢
後方参照と先読みを使っていないパターンなら、線形時間で動くエンジンへ移すと計算量の問題ごと消えます。.NETは7以降で RegexOptions.NonBacktracking を持ち、Microsoft Learnはこれを「入力の長さの線形時間処理が保証される」オプションとして説明しています。PythonやNode.jsではRE2のバインディングも選択肢です。
ただし同じドキュメントは、これらの機能について「開発者が作成したパターンでの偶発的なパフォーマンスの問題から保護するのに役立つ。悪意のあるパターンに対するセキュリティ境界として意図されていない」とも断っています。エンジンの切り替えは自作パターンの事故を防ぐ手であって、ユーザーが投稿したパターン文字列をそのまま実行してよい理由にはなりません。外部から受け取ったパターンを評価する設計は、そもそも避けるのが正解です。
grepとログ解析で正規表現を使うときのコマンドと書き分け方を確認する
サーバー上での調査は、正規表現がもっとも短時間で効果を出す場面です。ただしシェルのツール群はPOSIX系の記法を既定にしているため、アプリケーションコードと同じ感覚で書くと一致しません。
grepのBREとERE切り替えとPerl互換オプションの使い分け
GNU grepは既定でBRE、-E でERE、-P でPCRE互換の解釈に切り替わります。\d や後読みを使いたい場合は -P が要ります。-P はビルド構成によっては使えないため、移植性を優先するなら文字クラスで書くほうが安全です。
# BRE(既定)。+ はリテラル扱いなのでエスケープが要る
grep "ERROR.\+timeout" app.log
# ERE。アプリのコードに近い感覚で書ける
grep -E "ERROR.+timeout|WARN.+retry" app.log
# PCRE互換。\d や後読みが使える
grep -P "(?<=status=)\d{3}" access.log -o
# 大文字小文字を無視して該当行の前後2行も出す
grep -Ei -C 2 "connection (refused|reset)" app.log
抽出だけが目的なら -o を付けて一致部分だけを出し、そのまま sort や uniq -c へつなぐと件数集計になります。オプションの全体像と実務でよく使う組み合わせはgrepコマンドの解説にまとめてあります。
ログ抽出で正規表現を使う前に構造化ログへ寄せるかを判断する順序
ここで一段引いて考えたいのは、そのログを毎回正規表現で切り出す必要があるのかという点です。障害調査の一回きりならgrepで十分ですが、同じ抽出を監視やダッシュボードで繰り返すなら、出力側をJSON形式の構造化ログへ変えるほうが安く済みます。パターンはログのフォーマットが変わるたびに壊れますが、キー名で取り出す方式なら並び順の変更に影響されません。
判断の目安は「そのパターンを3回以上使い回すか」です。使い回すなら出力側を直す。一度きりならその場で書く。既存システムの調査でログ形式に手を入れられない場合だけ、名前付きグループを付けた読めるパターンを作り、テストとセットでリポジトリへ置いてください。
正規表現で書くべきでない処理を見極める判断基準と代替手段の選び方
正規表現で書けることと、正規表現で書くべきことは別です。ここを混同したまま作り込むと、後から誰も直せない数百文字のパターンが残ります。
メールアドレスとHTMLを正規表現だけで処理してはいけない理由
メールアドレスの仕様は入れ子のコメントや引用文字列を許すため、正規表現だけでは正確に検証できません。Microsoft Learnも、過剰なバックトラッキングを起こしやすく有効なアドレスを拒否する可能性があるとして、自作のメール検証パターンを使わないよう明示しています。実務では、形式は「@が1つあり前後が空でない」程度の緩い確認にとどめ、到達性は確認メールの送信で担保するのが定石です。
HTMLも同じ理由で対象外です。タグの入れ子は再帰的な構造で、正規言語では表現できません。パーサーを使ってください。何を正規表現で弾き、何を別の層で担保するかという切り分けは入力バリデーションの設計で整理しています。
パーサーと文字列関数へ置き換えたほうが速く安全になる具体的な場面
前方一致や後方一致の判定、固定文字列の分割、区切り文字での切り出しは、正規表現より標準の文字列関数のほうが速く読みやすくなります。str.startswith や split で足りる処理をパターンで書くのは、実行コストとレビューコストの両方で損です。
置き換えの判断は次の3条件で決めています。パターンに量指定子の入れ子があるなら書き換える。固定文字列だけを探しているなら文字列関数へ移す。構造が入れ子になっている対象なら専用のパーサーを使う。この3つに該当しない、平坦なパターンの抽出と検証こそが正規表現の守備範囲です。
受託開発で正規表現を保守できる状態に保つレビュー観点と運用ルール
正規表現は書いた本人でも半年後には読めません。チームで保守するなら、書き方そのものにルールを設ける必要があります。
コメント付き記法とテストで正規表現をレビュー可能な形に保つ工夫
Pythonの re.X(VERBOSE)フラグを使うと、パターン中の空白とコメントが無視されます。公式ドキュメントの例のように行ごとの意味を書けるので、20文字を超えるパターンはこの形で書くのを既定にすると読みやすい構成です。JavaScriptには同等のフラグがないため、部品ごとに変数へ分けて結合する形で代用します。
import re
# 意味を行ごとに書ける(re.X は空白と # 以降を無視する)
LOG_LINE = re.compile(r"""
^(?P<ts>\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}) # タイムスタンプ
\s+(?P<level>[A-Z]{4,5}) # ログレベル
\s+(?P<msg>[^\n]{1,500})$ # 本文(長さ上限つき)
""", re.X)
あわせて、パターンごとに一致する入力・一致しない入力・境界付近の入力をテストへ書いてください。上の例のように本文へ {1,500} と上限を入れておくと、長大な入力が流れ込んだときの照合コストも同時に抑えられます。上限のない * や + を外部入力へ向けないのが、レビューで最初に見る点です。
外部入力を扱う正規表現の受け入れ条件を決めて発注側と合意しておく
検証の厳しさは仕様の問題であり、実装者だけでは決められません。氏名欄に記号を許すのか、電話番号のハイフンを必須にするのか、旧字体をどう扱うのか。ここを曖昧にしたままパターンを書くと、リリース後に「正しい入力が弾かれる」という形で返ってきます。受け入れ条件を先に文書化し、テストケースの形で合意しておくのが確実です。
外部システムとの連携でデータ形式を変換する場面では、この判断がインターフェース仕様そのものになります。一創では入力検証とデータ変換の方針をあわせて設計するAPI開発・システム連携を承っており、既存システムのログ形式や項目仕様を確認したうえで、どこまでを正規表現で担保しどこからをスキーマ検証へ寄せるかを整理してから実装へ進みます。
よくある質問
正規表現はどの言語でも同じように書けますか?
基本的なメタ文字は共通ですが、拡張構文は分かれます。名前付きグループはPythonが (?P<name>...)、JavaScriptやJavaは (?<name>...) と書式が違い、RE2系のエンジンには先読みと後方参照そのものがありません。移植のときは、同じ入力を両方の環境へ通して結果を突き合わせるのが確実な確認方法になります。
貪欲マッチと非貪欲マッチはどちらを既定にすべきですか?
どちらでもなく、除外文字クラスを既定にするのをおすすめします。.*? は書きやすい代わりにエンジンの後戻りが残るため、[^,]* のように「一致してはいけない文字」を明示するほうが速く、意図も読み取りやすくなります。除外で書けない場合に限って非貪欲を使う、という順序で考えてください。
正規表現のパフォーマンスはコンパイルしておけば改善しますか?
パターン解析の分は減りますが、バックトラッキングによる計算量の問題は解決しません。Python公式ドキュメントも、直近のパターンはコンパイル済みのものがキャッシュされると注記しています。実行時間が入力長に対して急に伸びる場合、原因はコンパイルの有無ではなくパターンの構造にあると考えて、量指定子の入れ子を探してください。
ReDoSの危険があるパターンはどう見つければよいですか?
まず (a+)+ のように繰り返しが入れ子になっている箇所を機械的に洗い出します。次に、末尾を必ず不一致にした入力の長さを20から30まで増やしながら実行時間を測ると、指数的に伸びるかどうかを数分で判別可能です。CIへ組み込むなら、この計測をタイムアウト付きのテストとして置いておく方法があります。
正規表現をコードレビューで見るときの観点は何ですか?
4点に絞っています。外部入力に対して上限のない量指定子を使っていないか、量指定子が入れ子になっていないか、キャプチャに名前が付いているか、境界値のテストがあるか。この4つが通っていれば、細かい書き方の好みは指摘しなくて構いません。
関連記事
- ReDoSとは何か?正規表現によるサービス拒否攻撃の基本概要:攻撃の成立条件と被害の広がり方を詳しく確認したい場合に読む記事です。
- grepコマンドとは?基本構文・オプション・正規表現からログ検索まで:サーバー上での調査に使うオプションの組み合わせをまとめています。
- バリデーションとは?入力検証の目的・種類・実装パターンと設計:抽出したデータをどう検証へつなぐかの全体設計を扱っています。
- 入力バリデーションとは?防げる攻撃の範囲と許可リスト設計:正規表現で弾く範囲と別の層で担保する範囲の切り分けが分かります。
- ArkRegexとは何か?TypeScriptで型安全な正規表現を書くライブラリ:型の側からパターンの誤りを防ぐ手法を知りたいときの参考になります。