正規表現
目次
主要項目のみを表示しています。詳細な小見出しは本文内で確認できます。
- 概要
- 正規表現を使う場面
- 正規表現エンジンの違い
- マッチングの流れ
- まず文字そのものに一致させる
- 基本のメタ文字
- 量指定子
- greedyとnon-greedy
- 文字クラス
- グループとキャプチャ
- 後方参照
- lookaround
- アンカーと境界
- フラグとモード
- 検索・抽出・置換
- Pythonで使う
- grepとripgrepで使う
- JavaScriptで使う
- Unicodeと多言語テキスト
- 入力検証で使う
- ログ解析で使う
- ケーススタディ: access logを読む
- 読みやすい正規表現を書く
- デバッグ手順
- テスト戦略
- 理論背景: NFAとDFA
- backtrackingの内部動作
- 実装差分チートシート
- 置換テクニック集
- 実務レシピ集
- 設計レビュー用チェックリスト
- 運用時のインシデント対応
- 性能とReDoS
- 正規表現を使わない判断
- よくある落とし穴
- 発展問題
- 発展問題の解答例
- フレーバー横断の設計指針
- Unicode実装の深掘り
- パターン設計カタログ
- Python実践レシピ集(拡張)
- JavaScript実践レシピ集(拡張)
- grep/ripgrep運用レシピ集
- まとめ
- 参考文献
概要
正規表現は、文字列のパターンを表すための記法です。検索、抽出、置換、ログ解析、入力検証、エディタ操作、簡単なtokenizerなど、実務のあらゆる場所で使われます。
実務での正規表現は、単なる「検索ワードの強化版」ではなく、次の3つの役割を持つ小さな仕様言語として扱うと理解しやすくなります。
- 認識: この文字列は対象かどうかを判定する
- 分解: 対象文字列から意味のある部分を切り出す
- 変換: 対象部分を別の形へ置き換える
この3つは似て見えますが、設計の重点が異なります。
認識では誤受理・誤拒否、分解ではキャプチャの安定性、変換では副作用と可逆性が重要です。1つの正規表現で全部を解こうとすると、意図が混ざって保守性が落ちます。
また、正規表現は「短く書ける」ことが強みですが、短さは品質を保証しません。数文字の式でも、運用条件(入力長、失敗ケース、Unicode、実装差)を誤ると、遅延障害や検証漏れを起こします。逆に、少し長くても意図が明確でテスト可能な式は、長期運用で強いです。
正規表現は「文字列を扱う小さな言語」です。強力ですが、万能ではありません。どの正規表現エンジンで動くのか、入力がどれくらい大きいのか、ネストした構造を扱っていないかを意識すると、安全に使えます。
正規表現を使う場面
正規表現は、次のような場面に向いています。
- 特定の語を含む行を探す
- 日付、ID、ステータスコードなどを抽出する
- ログから条件に合う行を取り出す
- ファイル名やURLの形を大まかに確認する
- エディタで同じ形の文字列をまとめて置換する
- 簡単な字句解析の前処理をする
上の用途に共通する特徴は、「入力の局所パターンが比較的安定している」ことです。
たとえばログの status=500、チケットIDの ABC-123、タイムスタンプの桁構造などは、文法全体を理解しなくても局所ルールで扱えます。正規表現はこの領域で非常に高い生産性を発揮します。
一方で、次のような場面には向きません。
- HTMLやXMLの完全な解析
- Markdownの完全な構文解析
- 括弧が深くネストする構造の厳密な検査
- 文脈依存の文法を持つ入力の検証
- セキュリティ上重要な構文解析
「向かない場面」をもう少し具体化すると、次のようになります。
- 開始タグと終了タグの対応関係を厳密に追う必要がある
- エスケープ規則が多層で、局所ルールだけでは意味が決まらない
- 正誤判定の誤差がそのまま事故につながる(課金・権限・監査)
正規表現は、規則的な文字列の「表面」を扱う道具です。構造を理解する必要があるなら、parserを使う判断が重要です。
実務で迷ったときの簡易判断:
- 1行で説明できる形ならregex候補
- 文法図が必要ならparser候補
- 誤判定が許されないなら専用ライブラリ優先
正規表現エンジンの違い
正規表現は1つの共通言語に見えますが、実装によって機能と挙動が異なります。
| 系統 | 例 | 特徴 |
|---|---|---|
| POSIX BRE | grep の基本形 |
伝統的。+ や ? をそのまま使えないことがある |
| POSIX ERE | grep -E |
` |
| PCRE系 | Perl、PCRE、grep -P |
lookaroundなど高機能。環境差と性能に注意 |
Python re |
Python標準ライブラリ | Perl風。raw stringと一緒に使うのが基本 |
| JavaScript RegExp | ブラウザ、Node.js | Webでよく使う。フラグやUnicodeの扱いに注意 |
| Rust regex系 | ripgrepなど | backtrackingを避ける設計。高機能構文は一部使えない |
「この正規表現は正しいか」ではなく、「この実装で、この入力に対して、意図通りに動くか」を確認します。
たとえば、grep、Python、JavaScript、エディタ検索では、同じ \b や \w でもUnicodeやlocaleの扱いが違うことがあります。教材や記事からパターンを持ってくる場合は、実行環境を必ず確認します。
理論的には、正規表現(正則言語)は有限オートマトンで線形時間に判定できます。Russ Coxの有名な解説が示す通り、a?^n a^n 型の病的入力ではbacktracking型とThompson NFA型で実行時間が桁違いになります。実務では「機能が多いエンジン」ほど遅いのではなく、「どの機能を使ったときにどのアルゴリズムへ落ちるか」が重要です。
実装差を扱う実践ルール:
- まず最小機能で書く(文字クラス、量指定子、アンカー)
- 必要なときだけ拡張機能(lookaround, backreference)を使う
- 多環境運用では「一番弱い実行環境」でテストする
- 速度要件が厳しい箇所は、線形時間特性を持つ実装を優先する
さらに運用で効く観点として、エンジン差分は「対応/非対応」だけでなく、次の4軸で見ます。
- 表現力: どこまで書けるか
- 予測可能性: 最悪時の実行時間が読めるか
- 移植性: 他環境へ持っていけるか
- デバッグ容易性: 失敗時に原因を追えるか
たとえば rg 既定エンジンは表現力を一部制限する代わりに予測可能性を取りやすく、PCRE系は表現力が高い代わりに性能監視が重要になります。どちらが優れているかではなく、要件に対してどちらの失敗モードを受け入れるかの問題です。
マッチングの流れ
正規表現を理解するときは、「パターンが入力上をどう進むか」を図で見ると分かりやすくなります。
コード例は、そのまま写すためだけのものではありません。直前の本文で「何を確かめる例か」を押さえ、直後の説明で「どの性質が見えるか」を確認してください。実務では、ここに入力の境界、失敗時の挙動、依存する実行環境を足して読むと判断しやすくなります。
検索では、エンジンが入力文字列の位置をずらしながら、パターンが成立する場所を探します。match、search、fullmatch の違いは、この探索範囲の違いとして理解できます。
| 操作 | 見る範囲 | 典型用途 |
|---|---|---|
match |
先頭から一致するか | 行頭の形式確認 |
search |
どこかに含まれるか | ログ検索、抽出 |
fullmatch |
全体が一致するか | 入力検証 |
これは ERROR のような固定文字列に近い単純な例です。量指定子や分岐が増えると、エンジンは候補を保持したり戻ったりします。そこが正規表現の強さであり、ReDoSのようなリスクにもつながります。
ここをもう一段具体化すると、エンジンは概ね次の順に動きます。
- 現在位置でパターンを試す
- 成立すれば一致を返す
- 失敗すれば開始位置を1つ進める
- 末尾まで繰り返す
search はこの探索を行い、match は先頭固定、fullmatch は全体固定です。
この違いを意識すると、入力検証で search を使ってしまう事故を避けられます。
また、バックトラックが発生するのは「どの分岐を選ぶか」が1通りに決まらないときです。
言い換えると、曖昧性が少ない正規表現ほど速く、挙動も読みやすいということです。
まず文字そのものに一致させる
最も単純な正規表現は、文字列そのものです。
error
これは error という連続した文字に一致します。大文字小文字を区別するかどうかは、フラグやツールの設定によります。
grep "error" app.log
grep -i "error" app.log
正規表現の学習では、いきなり複雑な構文へ進むより、まず「普通の文字」「特別な意味を持つ文字」「位置に一致する記号」を分けると理解しやすくなります。
基本のメタ文字
メタ文字は、文字そのものではなく特別な意味を持つ文字です。
| 記法 | 意味 |
|---|---|
. |
任意の1文字 |
\d |
数字 |
\w |
単語構成文字 |
\s |
空白 |
| ` | ` |
() |
グループ |
[] |
文字クラス |
^ |
行頭または文字列先頭 |
$ |
行末または文字列末尾 |
\ |
エスケープ |
. は多くの実装で改行に一致しません。Pythonでは re.DOTALL を使うと改行にも一致します。
file\.txt
. を文字そのものとして扱いたい場合は、\. のようにエスケープします。
量指定子
量指定子は、直前の要素が何回出るかを表します。
| 記法 | 意味 |
|---|---|
* |
0回以上 |
+ |
1回以上 |
? |
0回または1回 |
{n} |
n回 |
{n,} |
n回以上 |
{n,m} |
n回以上m回以下 |
日付形式の例です。
\d{4}-\d{2}-\d{2}
これは 2026-04-29 のような形に一致します。ただし、2026-99-99 のような存在しない日付にも一致します。正規表現は形の検査には向きますが、意味の検査は別の処理が必要になることがあります。
量指定子の設計で重要なのは「許可しすぎない」ことです。
.* のような広い許可は短く書けますが、誤一致と性能劣化を同時に招きやすくなります。
実務的な順序は次の通りです。
- まず最小/最大長を決める
- 次に許可文字集合を決める
- 最後に区切りや順序を決める
例として、ユーザー名要件が「3〜32文字、英数字と _ - のみ」の場合は、先に [A-Za-z0-9_-]{3,32} と書いてから境界(全体一致)を付ける方が設計意図を保ちやすくなります。
また、Python 3.11+ や一部エンジンで使える所有的量指定子は「戻らない」量指定子です。性能問題がある箇所では有効ですが、意味を変えることがあるため、通常の量指定子の意図が明確になってから導入するのが安全です。
greedyとnon-greedy
多くの正規表現エンジンでは、* や + は可能な限り長く一致しようとします。これをgreedyと呼びます。
<.*>
<b>one</b><i>two</i> に対して使うと、最初の < から最後の > まで一致することがあります。
短く止めたい場合は、non-greedyな量指定子を使える実装があります。
<.*?>
ただし、これはHTML parserの代わりではありません。タグの入れ子、属性内の >、コメント、scriptのような例外を正確に扱うにはparserが必要です。
greedyを避けるもう1つの方法は、より具体的な文字クラスを使うことです。
<[^>]+>
「任意の文字」より「> 以外の文字」の方が、意図が明確で、余計な探索も減りやすくなります。
文字クラス
角括弧は、許可する文字の集合を表します。
[abc]
これは a、b、c のどれか1文字に一致します。
[a-zA-Z0-9_]
これは英数字とunderscoreに一致します。
否定もできます。
[^0-9]
これは数字以外に一致します。
範囲を書くときはlocaleやUnicodeに注意します。英数字だけを想定するなら、明示的に [A-Za-z0-9] のように書く方が分かりやすいことがあります。
よく使う短縮記法です。
| 記法 | 典型的な意味 |
|---|---|
\d |
digit |
\D |
digit以外 |
\s |
whitespace |
\S |
whitespace以外 |
\w |
word character |
\W |
word character以外 |
ただし、\w がどの文字を含むかは実装やUnicode設定によります。ユーザー名や識別子の検証では、許可したい文字を明示する方が安全です。
グループとキャプチャ
丸括弧は、部分一致をまとめたり取り出したりするために使います。
(\d{4})-(\d{2})-(\d{2})
年、月、日を別々に取り出せます。
名前付きキャプチャを使うと、意味が分かりやすくなります。
(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})
Pythonでの例です。
import re
pattern = re.compile(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})")
match = pattern.search("released=2026-04-29")
if match:
print(match.group("year"))
print(match.groupdict())
キャプチャが不要なら、非キャプチャグループを使います。
(?:GET|POST|PUT|DELETE)\s+
非キャプチャグループは、番号付きキャプチャのずれを避けたいときにも便利です。
グループ設計でよく起きる問題は、「抽出用途のグループ」と「構文上のまとまり」が混在してしまうことです。
実務では次のルールにすると崩れにくくなります。
- 値として取り出すものだけをキャプチャする
- まとまりだけが目的なら
(?:...)を使う - 3個以上のキャプチャがあるなら名前付きキャプチャを優先する
これを徹底すると、後でパターンに分岐を追加しても group(2) が別の意味になる事故を減らせます。
また、キャプチャは「抽出APIとの契約」でもあります。たとえばログ抽出で (?P<timestamp>...), (?P<level>...) を決めたら、後段コードはそのキー名に依存します。正規表現の修正時には、名前・意味・単位(文字列/数値)を互換性として扱うと、安全に運用できます。
後方参照
後方参照(backreference)は、前にマッチしたグループと同じ内容を再利用する機能です。重複語の検出、区切り記号の整合性チェック、同じquoteで囲まれているかの判定などで使います。
\b(\w+)\s+\1\b
この例は the the のような重複語に一致します。
import re
text = "This is is a test."
match = re.search(r"\b(\w+)\s+\1\b", text, re.IGNORECASE)
print(match.group(0) if match else None)
置換でも使えます。
import re
text = "name=alice, name=alice"
collapsed = re.sub(r"(name=\w+),\s+\1", r"\1", text)
print(collapsed)
後方参照は便利ですが、使いすぎると読みづらくなります。意味のある名前付きキャプチャと併用するか、前処理で値を分ける方が保守しやすい場合があります。
後方参照は「同一性を保証する」用途で特に有効です。
典型例は次のようなケースです。
- 開始と終了の引用符が同じであることを保証する
- 2回出現するトークンが同一であることを保証する
- 区切り記号の整合性を保つ
一方で、後方参照を使うと非正則拡張に入り、エンジンによっては性能や互換性が悪化します。公開APIや大規模バッチで使う場合は、次を確認してください。
- 本当に正規表現内で同一性保証が必要か
- 後段コードで比較する設計に分離できないか
- 最悪入力で実行時間が悪化しないか
lookaround
lookaroundは「前後の条件を見るが、文字自体は消費しない」機能です。抽出対象を汚さずに条件を課したいときに有効です。
| 記法 | 意味 | 例 |
|---|---|---|
(?=...) |
正の先読み | \w+(?=:) |
(?!...) |
負の先読み | foo(?!bar) |
(?<=...) |
正の後読み | (?<=\$)\d+ |
(?<!...) |
負の後読み | (?<!\w)cat |
\b\w+(?=:)
key:value から key だけ取りたいときに使えます。
(?<=\$)\d+(?:\.\d{2})?
$19.99 の数値部分だけを抜き出す例です。
実装差には注意が必要です。特に後読み(lookbehind)は古い環境や一部エンジンで制限があります。互換性が重要なコードでは、lookaroundを避けてキャプチャで書き直せないか検討します。
lookaroundの設計では、RexEggが強調する「位置は進まない」を常に意識します。(?=A)(?=B) のように連鎖しても、入力消費はゼロです。この特性は検証用途に強力ですが、乱用すると可読性と性能が落ちます。
実務のコツ:
- 複数条件の検証では「失敗しやすいlookaheadを先に置く」
n条件の検証では、n-1個のlookaround + 1つの本体マッチへ圧縮できないか確認する- lookaroundだけで検証して終える場合、最終的に全体一致を明示する
lookaroundの読みやすさを上げるには、「何を消費する式か」を明確に分けると効果的です。
例としてパスワード検証では、lookaheadで条件を検査し、最後の本体で全体を消費する構成にすると、条件追加時の影響範囲を限定できます。
\A(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d]{8,64}\z
この形は「条件検査」と「対象範囲」を分離しているため、レビューしやすいです。
ただし条件が増えすぎると可読性が落ちるため、3〜4条件を超える場合は前処理ロジックとの分担を検討します。
性能面では、論理的に順序が等価でも実行時間は等価とは限りません。失敗しやすい条件を前に置く design-to-fail の原則が有効です。
アンカーと境界
アンカーは、文字そのものではなく位置に一致します。
^ERROR
行頭の ERROR に一致します。
\.md$
行末が .md の文字列に一致します。
入力全体を検証したい場合は、先頭と末尾を明示します。
^[A-Za-z0-9_-]{3,32}$
単語単位で一致させたいときは、単語境界を使います。
\bCPU\b
ただし、単語境界もUnicodeや実装によって挙動が変わります。日本語や記号を含む語では、思った通りに区切れないことがあります。
アンカーは「対象範囲を固定する」機能であり、検証用途では最重要です。
実務での典型事故は、search と未アンカーの組み合わせで部分一致を誤受理することです。
悪い例:
[A-Za-z0-9_-]{3,32}
この式は @@alice@@ の中の alice に一致してしまいます。
入力検証なら、^...$ または fullmatch を使って「文字列全体が条件を満たす」ことを必ず明示します。
境界(\b)は便利ですが、実装が想定する「単語文字」に依存します。英数字中心のIDには有効でも、多言語テキストでは誤分割が起こりやすいため、必要なら明示的な区切り記号や前後条件で代替します。
フラグとモード
正規表現の挙動は、フラグで変わります。
| フラグ | Pythonでの名前 | 意味 |
|---|---|---|
i |
re.IGNORECASE |
大文字小文字を区別しない |
m |
re.MULTILINE |
^ と $ を各行にも効かせる |
s |
re.DOTALL |
. を改行にも一致させる |
x |
re.VERBOSE |
空白やコメントで読みやすく書く |
a |
re.ASCII |
\w などをASCII寄りに扱う |
MULTILINE は、複数行文字列の各行に対して ^ と $ を効かせます。DOTALL は . の意味を変えます。名前が似ていても役割は違います。
import re
text = "ERROR one\nINFO two\nERROR three"
print(re.findall(r"^ERROR.*", text, re.MULTILINE))
フラグは「式の意味そのもの」を変えるため、パターン文字列と同じくらい重要です。
同じ ^ERROR.* でも、m の有無で一致対象が全く変わります。
実務での推奨は次の通りです。
- パターンとフラグを常にセットで定義する
- 関数引数でフラグを散らさず、
compile済みオブジェクトで管理する - inline flag と外部フラグを混在させない(読み手の負荷を下げる)
import re
LOG_ERR = re.compile(r"^ERROR.*", re.MULTILINE)
また、i(ignore case) は便利ですが、localeやUnicodeで期待とズレることがあります。IDやトークン判定のように厳密性が必要な場面では、大文字小文字変換を先に明示してから比較する設計も検討します。
検索・抽出・置換
正規表現には、主に3つの使い方があります。
| 用途 | 例 |
|---|---|
| 検索 | 条件に合う箇所があるか調べる |
| 抽出 | 一致した部分やキャプチャを取り出す |
| 置換 | 一致した部分を別の文字列へ変える |
検索の例です。
import re
if re.search(r"\bERROR\b", "2026-04-29 ERROR failed"):
print("found")
抽出の例です。
import re
text = "status=200 status=500 status=404"
codes = re.findall(r"status=(\d{3})", text)
print(codes)
置換の例です。
import re
text = "token=abc123"
masked = re.sub(r"token=[A-Za-z0-9]+", "token=***", text)
print(masked)
置換では、ログや個人情報のマスクに使うことがあります。ただし、漏れが許されない用途では、正規表現だけに頼らず、データ構造を理解した処理を使います。
Pythonで使う
Pythonでは re モジュールを使います。正規表現リテラルはraw stringで書くのが基本です。
import re
pattern = re.compile(r"\b[A-Z]{2,5}-\d+\b")
match = pattern.search("ticket: CS-123")
if match:
print(match.group(0))
raw stringを使わないと、Pythonの文字列リテラルとしての \ と、正規表現としての \ が衝突して読みづらくなります。
r"\d+\s+\w+"
長いパターンでは re.VERBOSE を使います。
import re
log_pattern = re.compile(r"""
^
(?P<date>\d{4}-\d{2}-\d{2})
\s+
(?P<level>INFO|WARN|ERROR)
\s+
(?P<message>.*)
$
""", re.VERBOSE)
line = "2026-04-29 ERROR failed to deploy"
match = log_pattern.match(line)
print(match.groupdict() if match else None)
ユーザー入力を正規表現に埋め込む場合は、re.escape を使います。
import re
keyword = "C++"
pattern = re.compile(re.escape(keyword))
print(bool(pattern.search("C++ guide")))
re では str パターンと bytes パターンを混在できません。I/O境界で型を揃えると不具合を減らせます。
import re
pat_s = re.compile(r"\d+")
pat_b = re.compile(rb"\d+")
assert pat_s.search("id=123")
assert pat_b.search(b"id=123")
Python 3.11+ では所有的量指定子(*+, ++, {m,n}+)やatomic group ((?>...)) が使え、不要なbacktracking抑制に有効です。
grepとripgrepで使う
grep は正規表現検索の基本ツールです。
grep -n "ERROR" app.log
grep -E "\\b(500|503)\\b" access.log
GNU grepでは、基本正規表現(BRE)、拡張正規表現(ERE)、Perl互換正規表現(PCRE)をオプションで切り替えられます。
| コマンド | 意味 |
|---|---|
grep PATTERN |
基本正規表現 |
grep -E PATTERN |
拡張正規表現 |
grep -F STRING |
正規表現ではなく固定文字列 |
grep -P PATTERN |
Perl互換正規表現。環境差に注意 |
文字列そのものを探すなら、grep -F が読みやすく安全です。
grep -F "C++" docs.txt
rg は大きなリポジトリで便利です。
rg "TODO|FIXME"
rg -n "\\bclass\\s+User\\b"
rg はGit ignoreを尊重し、検索対象を絞りやすいのが強みです。一方で、PCREの高機能構文を使いたい場合はオプションや環境を確認します。
結果が出ないときは、除外ルールを疑って --debug や -u 系を使うと切り分けが速くなります。
rg --debug "pattern"
rg -u "pattern"
rg -uu "pattern"
rg -uuu "pattern"
固定文字列検索なら grep -F / rg -F を優先すると、エスケープ事故を減らせます。
CLIでの正規表現運用は、パターンを書くことより「探索範囲を制御すること」が重要です。
同じパターンでも、対象ファイル数が10倍になると実行時間やノイズが一気に増えます。まずは次の順序で絞り込みます。
- 対象ディレクトリを限定する
- ファイル種別を限定する(
-g,--type) - 固定文字列で候補を粗く絞る
- 最後に正規表現で精密抽出する
例:
rg -g "*.py" -F "status=" src
rg -g "*.py" -n "status=(4\\d\\d|5\\d\\d)" src
grep -w や -x は意図の明確化に有効です。^...$ を毎回書かなくても、コマンドオプションで「単語単位」「行全体一致」を表せます。運用スクリプトでは、可読性のためにオプションで意図を表す方が保守しやすいことが多いです。
また、調査系ワンライナーでよく起きる事故は「検索結果を真実と誤認する」ことです。ignoreやバイナリ除外で取りこぼしている可能性があるため、最終確認では -u 系や対象限定再検索を行い、結果の信頼度を上げます。
JavaScriptで使う
JavaScriptでは RegExp オブジェクト、または /.../flags のリテラルを使います。
const text = "ticket: CS-123";
const m = text.match(/\b[A-Z]{2,10}-\d{1,6}\b/);
console.log(m?.[0] ?? null);
置換時にはコールバックが便利です。
const line = "status=500 latency=123ms";
const masked = line.replace(/status=(\d{3})/g, (_, code) => `status=${code[0]}xx`);
console.log(masked);
主要なフラグです。
| フラグ | 意味 |
|---|---|
g |
全一致を反復して取得 |
i |
大文字小文字を無視 |
m |
^ $ を行単位で扱う |
s |
. を改行に一致させる |
u |
Unicodeモード |
y |
sticky match(前回位置からのみ) |
g を使った exec ループは lastIndex の状態を持つため、使い回し時のバグに注意します。副作用を避けたいときは matchAll や新しいRegExp生成を使う方が安全です。
const re = /\w+/g;
for (const m of "a b c".matchAll(re)) {
console.log(m[0]);
}
動的パターンを構築する場合は、入力のエスケープを必須にします。
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\{{CONTENT}}amp;");
}
const reSafe = new RegExp(escapeRegExp(userInput), "g");
JavaScriptで実務利用するときは、APIの返り値仕様を揃えておくとバグが減ります。
test, match, matchAll, exec は似ていますが、戻る情報量と状態管理が異なります。
- 存在判定だけなら
test - 最初の一致だけなら
match(非g) - 全件と位置情報が必要なら
matchAll - 低レベル制御が必要なときだけ
execループ
exec は強力ですが、g/y と組み合わせると lastIndex の状態を持つため、関数境界を跨ぐ再利用で不具合を起こしやすくなります。チーム開発では「exec を使う理由」をコメントや関数名で明示し、通常ケースは matchAll へ寄せる方が安全です。
さらに、フロントエンドでは正規表現がユーザー入力の都度実行されることが多いため、次のガードを入れておくと体感劣化を防げます。
- 入力長の早期チェック
- 高コストregexのdebounce
- 失敗しやすい条件を先に判定
Unicodeと多言語テキスト
ASCII中心のパターンは、日本語・絵文字・合成文字を含むデータで壊れやすくなります。多言語入力を扱う場合は、Unicode特性と正規化を意識します。
import re
import unicodedata
raw = "Cafe\u0301" # e + 結合アクセント
norm = unicodedata.normalize("NFC", raw)
print(bool(re.search(r"Café", norm)))
JavaScriptのUnicode property escapesは、文字種ベースの分類に便利です。
const words = "東京 Tokyo 123";
const tokens = words.match(/\p{Script=Han}+|\p{Alphabetic}+|\p{Number}+/gu);
console.log(tokens);
\b や \w は「英語圏の単語」に最適化された挙動を持つ実装が多く、日本語の単語境界には向きません。日本語テキスト処理では、形態素解析器やICUベースのsegmenterを併用する方が堅実です。
Unicode TR18の観点では、Unicode対応には段階があります。要点は次の2つです。
- Level 1: Unicode code point を扱える最低限の対応
- Level 2: grapheme cluster、語境界、正規化同値まで考慮した拡張対応
実務で見落としやすい点:
- 見た目が同じ文字列でも、正規化形(NFC/NFD)が違うと不一致になる
\uXXXXと\u{...}など、エンジンごとにUnicodeエスケープ記法が異なる- Unicodeのバージョン差で、
\\p{...}のマッチ対象が将来変わる
国際化要件があるシステムでは、「どのUnicodeバージョンを前提にした判定か」を仕様に明記すると、後方互換の議論がしやすくなります。
運用で役立つ実践手順:
- 保存時正規化の有無を決める
- 検索時に同じ正規化を適用する
- Unicode property 利用時は実行環境バージョンを固定する
- 日本語/絵文字/合成文字を含む回帰テストを作る
「ASCIIで十分」と「国際化が必要」の中間状態が最も事故を起こします。
たとえばユーザー名はASCII前提なのに、表示名は多言語、検索キーは未正規化という構成だと、重複判定や検索一致で不整合が起きやすくなります。仕様を明確に分離してください。
入力検証で使う
入力検証では、「含まれているか」ではなく「全体が条件を満たすか」を見ます。
^[A-Za-z0-9_-]{3,32}$
これは、3〜32文字の英数字、underscore、hyphenだけを許可する例です。
悪い例です。
[A-Za-z0-9_-]{3,32}
この形だと、入力の一部だけが一致しても成功扱いになることがあります。検証では ^ と $、またはPythonの fullmatch を使います。
import re
username = "alice_01"
ok = re.fullmatch(r"[A-Za-z0-9_-]{3,32}", username) is not None
print(ok)
ただし、メールアドレスやURLの完全な検証を正規表現だけで行うのは難しいです。必要に応じて標準ライブラリや専用parserを使います。
入力検証での実務原則は「段階防御」です。
- 第1段階: 正規表現で形を絞る
- 第2段階: 意味妥当性をコードで検証する
- 第3段階: 必要なら外部仕様(RFC等)準拠のライブラリで最終判定する
メールアドレスを例にすると、regexだけで完全準拠を目指すより、
「UIでは簡易チェック」「保存前にライブラリ検証」「送信失敗時に再確認」の方が総合的に堅牢です。
また、検証用regexは将来変更される前提で、次を一緒に残すと保守しやすくなります。
- 許可する例
- 拒否する例
- 仕様上の非目的(何を保証しないか)
ログ解析で使う
ログ解析では、最初から完璧な正規表現を書くより、段階的に絞り込む方が安定します。
grep "ERROR" app.log | grep "timeout"
Pythonで構造化する例です。
import re
pattern = re.compile(r"""
^
(?P<timestamp>\S+)
\s+
(?P<level>INFO|WARN|ERROR)
\s+
(?P<component>[A-Za-z0-9_.-]+)
\s+
(?P<message>.*)
$
""", re.VERBOSE)
with open("app.log", encoding="utf-8") as f:
for line in f:
match = pattern.match(line.rstrip("\n"))
if match and match.group("level") == "ERROR":
print(match.groupdict())
ログの形式がJSONなら、正規表現ではなくJSON parserを使います。
jq 'select(.level == "ERROR")' app.jsonl
正規表現は、形式が曖昧なテキストや暫定調査には強いですが、構造化ログでは構造を活かす方が壊れにくくなります。
ログ解析での正規表現は、調査フェーズと運用フェーズで役割が違います。
- 調査フェーズ: 仮説検証を速く回すため、多少粗いパターンを許容
- 運用フェーズ: 定常レポートに使うため、誤抽出を減らし仕様化する
調査で使ったワンライナーをそのまま本番バッチへ流用すると、仕様化不足で取りこぼしや誤集計が発生します。移行時は次を必ず追加します。
- 非一致行の件数計測
- パターン別ヒット率の監視
- フォーマット変更時に落ちる回帰テスト
また、ログ行を1本の巨大正規表現で解くより、段階パースの方がトラブルシュートしやすくなります。
例として「先頭トークン抽出 -> レベル判定 -> 詳細フィールド抽出」に分割すると、どの段で壊れたかを観測しやすくなります。
ケーススタディ: access logを読む
Webサーバーのaccess logを例に、正規表現を段階的に組み立てます。ここでは、次のような行を想定します。
203.0.113.10 - - [29/Apr/2026:12:34:56 +0900] "GET /docs/index.html HTTP/1.1" 200 12345
いきなり完全な正規表現を書くのではなく、まず部品に分けます。
| 部品 | パターン | 意味 |
|---|---|---|
| IP | (?P<ip>\S+) |
空白までをIPとして取る |
| 日時 | \[(?P<time>[^\]]+)\] |
[ から ] まで |
| request | "(?P<method>[A-Z]+) (?P<path>\S+) (?P<protocol>[^"]+)" |
method, path, protocol |
| status | (?P<status>\d{3}) |
3桁のステータス |
| bytes | `(?P |
-)` |
全体をつなげると次のようになります。
^(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] "(?P<method>[A-Z]+) (?P<path>\S+) (?P<protocol>[^"]+)" (?P<status>\d{3}) (?P<bytes>\d+|-)$
構造を図にすると、1行を左から順番に消費していることが分かります。
Pythonで集計する例です。
import collections
import re
pattern = re.compile(
r'^(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] '
r'"(?P<method>[A-Z]+) (?P<path>\S+) (?P<protocol>[^"]+)" '
r'(?P<status>\d{3}) (?P<bytes>\d+|-){{CONTENT}}#x27;
)
counts = collections.Counter()
with open("access.log", encoding="utf-8") as f:
for line in f:
match = pattern.match(line.rstrip("\n"))
if not match:
continue
status = match.group("status")
counts[status] += 1
print(counts.most_common())
この正規表現は実用的ですが、完全ではありません。request pathに空白が含まれる特殊なケース、ログ形式の違い、引用符のエスケープなどがあると壊れます。調査用なら十分でも、課金や監査のような厳密性が必要な処理では、ログ形式に対応したparserや構造化ログを使います。
ケーススタディとして重要なのは、完成した1本の式ではなく「段階的に固める手順」です。
手順A: まず行を通す
最初は厳密性より、主要列が抜けることを確認します。
ここで厳密にしすぎると、フォーマット揺れで全件不一致になりやすくなります。
手順B: 失敗行を分類する
不一致行を捨てる前に、失敗理由を分類します。
- 引用符崩れ
- フィールド欠損
- 形式バージョン違い
この分類がないと、「抽出0件」がパターン不具合なのかデータ異常なのか判断できません。
手順C: 目的に合わせて厳密化
- 可視化用途: 多少の不一致を許容し可用性優先
- 監査用途: 不一致を許容せず、parser移行を含め厳密化
この切り分けを明示すると、同じログでも複数の処理パスを正当化できます。
読みやすい正規表現を書く
正規表現は短く書けますが、短さを優先すると読みづらくなります。
読みやすくするコツです。
- 役割ごとにグループ化する
- 名前付きキャプチャを使う
.*より具体的な文字クラスを使う- 入力全体の検証では境界を明示する
- 長いパターンではverbose modeを使う
- テストケースを残す
コメント付きの例です。
import re
ticket = re.compile(r"""
\b
(?P<project>[A-Z]{2,10})
-
(?P<number>\d{1,6})
\b
""", re.VERBOSE)
テストケースも重要です。
cases = {
"CS-123": True,
"A-1": False,
"CS-": False,
"CS-1234567": False,
}
for text, expected in cases.items():
actual = ticket.fullmatch(text) is not None
assert actual == expected, text
正規表現は、仕様書の一部として扱うと保守しやすくなります。
デバッグ手順
複雑なパターンが期待通りに動かないときは、次の順で切り分けると早く解決できます。
- まず固定文字列まで削って最小パターンで再現する
- 量指定子を1つずつ戻す
- キャプチャ結果を出力し、どこでズレたか確認する
- 成功例だけでなく失敗例・境界値でも検証する
- エンジン差(PCRE/Python/JavaScript)を切り分ける
Pythonでのデバッグ例です。
import re
pattern = re.compile(r"(?P<name>[A-Za-z_]\w*)=(?P<value>.+)")
line = "timeout=30s"
m = pattern.fullmatch(line)
if m:
print(m.groupdict())
else:
print("no match")
re.VERBOSE で段階的にパターンをコメント化し、レビューしやすくするのも有効です。
テスト戦略
正規表現は「短いコード」ですが、壊れやすい境界条件を多く持ちます。小さくても自動テストを用意する価値があります。
推奨する観点です。
- 正常系: 期待する代表入力
- 異常系: 似ているが許可しない入力
- 境界値: 最小長、最大長、空文字
- 攻撃的入力: 極端に長い文字列、失敗で遅くなる入力
- 互換性: 実行環境ごとの差分
import re
pat = re.compile(r"^[A-Za-z0-9_-]{3,32}{{CONTENT}}quot;)
def is_valid_username(s: str) -> bool:
return pat.fullmatch(s) is not None
def test_username():
assert is_valid_username("alice_01")
assert not is_valid_username("ab")
assert not is_valid_username("alice!")
assert not is_valid_username("a" * 1000)
CIでテストを回すと、将来のパターン修正による意図しない緩和・厳格化を検知できます。
理論背景: NFAとDFA
正規表現の挙動を深く理解するには、NFA(非決定性有限オートマトン)とDFA(決定性有限オートマトン)の違いを知ると役立ちます。
| 観点 | NFA系(多くのPerl互換実装) | DFA系(一部の検索エンジン) |
|---|---|---|
| 探索方式 | 分岐候補を持ちながら進む | 状態集合を決定的に遷移 |
| 後方参照 | 扱えることが多い | 原理的に難しい |
| 最悪時性能 | 悪化しやすい | 線形に近い |
| 表現力 | 高機能拡張を載せやすい | 拡張は制約が多い |
実務で大切なのは「どちらが優れているか」ではなく、用途に応じて選ぶことです。
- 高度な抽出(lookaround、後方参照)が必要ならNFA系
- 大規模テキスト検索や安全性重視ならDFA寄り実装
- 公開APIで外部入力を直接評価するなら線形時間を優先
backtrackingの内部動作
backtracking型エンジンは、失敗時に「直前の分岐へ戻って別ルートを試す」ことで柔軟性を実現します。これが正しく使われると強力ですが、曖昧な繰り返しと組み合わさると爆発的に遅くなります。
^(ab|a)+$
入力 aaaaaaaaaaaaaaaa! に対しては、(ab|a) の選び方が大量に発生します。失敗地点が末尾にあるほど、戻り回数が増えやすくなります。
簡易イメージです。
- まず可能な限り
+で進む - 最後で失敗する
- 直前の選択を巻き戻す
- 別の選択を試す
- これを繰り返す
回避策:
- 分岐を具体化する(曖昧な重なりを減らす)
- 不要な入れ子量指定子を除く
- 先に固定プレフィックスで絞る
- 入力サイズを制限する
- タイムアウト可能な実行環境を使う
実装差分チートシート
同じ見た目でも、実装差で動かないことがあります。移植時の観点を表にまとめます。
| 機能 | Python re |
JavaScript | grep ERE | ripgrep既定 |
|---|---|---|---|---|
| 後方参照 | 対応 | 対応 | 制限あり | 非対応 |
| 先読み | 対応 | 対応 | 非対応 | 非対応 |
| 後読み | 対応(制約あり) | 対応(環境依存あり) | 非対応 | 非対応 |
| 名前付きキャプチャ | 対応 | 対応 | 非対応 | 非対応 |
| Unicode property | 非標準(別モジュールで強化) | uで対応 |
非対応 | 既定は限定的 |
補足:
rg -Pを使うとPCRE2機能が使える環境がありますが、常時使える前提にしない- CIで複数環境をまたぐなら、最小共通機能で書く
- 「動いた」ではなく「全環境で再現した」を完了条件にする
置換テクニック集
置換は検索以上に事故を起こしやすいため、パターンと置換式をセットで管理します。
キャプチャを使った並べ替え
import re
text = "2026-04-29"
out = re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", text)
print(out) # 29/04/2026
関数置換で条件分岐
import re
def mask(m: re.Match[str]) -> str:
key, val = m.group("key"), m.group("val")
if key in {"token", "password"}:
return f"{key}=***"
return f"{key}={val}"
line = "user=alice token=abc123 timeout=30"
print(re.sub(r"(?P<key>\w+)=(?P<val>\S+)", mask, line))
段階置換
1回で複雑な変換を狙うより、2〜3段階へ分割すると安全です。
- 危険語をプレースホルダ化
- 構造変換
- 必要なら復元
実務レシピ集
以下は現場で再利用しやすいレシピです。要件に合わせて境界条件を追加してください。
1) バージョン文字列
\bv?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?\b
2) Gitブランチ名の簡易検査
^(?:feature|fix|chore|docs|refactor)\/[a-z0-9][a-z0-9._-]{2,63}$
3) IPv4の形チェック(意味検証は別)
^(?:\d{1,3}\.){3}\d{1,3}$
4) HTTPステータス抽出
\bstatus=(1\d\d|2\d\d|3\d\d|4\d\d|5\d\d)\b
5) Markdown見出し抽出
^(#{1,6})[ \t]+(.+)$
6) TODOタグ抽出
\b(?:TODO|FIXME|HACK|NOTE)\b[: ]?(.*)
7) クレデンシャル漏洩の簡易検知
\b(?:api[_-]?key|token|secret|password)\b\s*[:=]\s*["']?[A-Za-z0-9_\-]{8,}["']?
この用途は誤検知が出ます。検知結果は人間がレビューし、削除ではなくマスク運用を原則にします。
設計レビュー用チェックリスト
レビュー時は「短いか」ではなく「安全か・保守可能か」で評価します。
- 目的が検索/抽出/検証のどれか明確か
- 全体一致が必要な箇所で
^...$またはfullmatchを使っているか .*が不要に広くないか- 実装差で壊れる構文を使っていないか
- 失敗ケースと境界値のテストがあるか
- 長大入力に対する上限/タイムアウトがあるか
- 可観測性(失敗ログ、件数、遅延計測)があるか
最低限のレビューテンプレート
[目的] この正規表現は何を保証するか:
[非目的] 何は保証しないか:
[実行環境] Python/JS/grep/rg など:
[入力上限] 最大長:
[危険入力] 失敗時に遅い入力例:
[テスト] 正常/異常/境界/長大:
運用時のインシデント対応
本番で「急に遅い」「CPUが張り付く」が起きた場合、正規表現が原因のことがあります。対応の優先順位を決めておくと復旧が早くなります。
- まず入力サイズを制限して被害を止める
- 該当パターンをfeature flagで無効化できるなら即時停止
- 直近デプロイで変更されたパターンを特定
- 失敗入力を再現し、計測付きで最小再現を作る
- パターンを単純化して再デプロイ
- 事後でテストとガードレールを追加
観測しておくべき指標
- 正規表現評価時間のP95/P99
- タイムアウト件数
- 入力サイズ分布
- パターン別ヒット率と失敗率
これらをダッシュボード化すると、ReDoSだけでなく仕様ズレ(過検知・取りこぼし)も早期に発見できます。
性能とReDoS
OWASPが説明するReDoS(Regular Expression Denial of Service)は、正規表現の評価に非常に長い時間がかかる入力を与えることでサービスを止める攻撃です。
危険になりやすい例です。
^(a+)+$
aaaaaaaaaaaaaaaa! のように、途中まで一致しそうで最後に失敗する入力では、backtracking型のエンジンが大量の組み合わせを試す可能性があります。
危険な形の目安です。
- 曖昧な繰り返しが入れ子になっている
.*と別の繰り返しが重なっている- 失敗する入力で探索が増える
- ユーザー入力を正規表現として実行している
- 入力サイズの上限がない
Regular-Expressions.info の catastrophic backtracking 解説で示される通り、(x+x+)+y のような「内側と外側が同時に伸びる」構造は、失敗ケースで指数的に悪化します。実際の業務では、^(.*?,){11}P のように「いかにも自然な書き方」が問題になるため、.*? を安易に繰り返さない方針が重要です。
改善原則:
例:
^(.*?,){11}P
^([^,\r\n]*,){11}P
前者は失敗ケースで探索が膨らみ、後者は区切り記号を跨げないため挙動が安定します。
対策です。
- 入力サイズに上限を設ける
- パターンを具体的にする
.*を避け、文字クラスで範囲を絞る- 失敗ケースを含めてベンチマークする
- ユーザー入力は
re.escapeする - 必要ならbacktrackingを避けるエンジンを使う
- タイムアウトやキャンセルが可能な実行環境を使う
OWASPが挙げる危険な形(Evil Regex)の典型:
(a+)+$
([a-zA-Z]+)*$
(a|aa)+$
(a|a?)+$
また、Regex Injection(ユーザー入力をそのまま正規表現として解釈させる問題)はReDoSと別の入り口で成立します。re.escape や RegExp エスケープを最低ラインとして適用します。
「設計してから測る」だけでなく、「測ってから設計を戻す」運用が有効です。ベンチマークは一致ケースだけでは不十分で、非一致ケース・長大入力・境界値の3種類を最低限入れてください。これはOWASPの攻撃観点と、Russ Coxのアルゴリズム観点を接続する実務上の要点です。
正規表現の安全性は、見た目の短さでは判断できません。短いパターンでも危険なことがあります。
この図は、線形に近いパターンと、曖昧なbacktrackingを起こすパターンの相対的な増え方を示しています。実際の速度はエンジンや入力に依存しますが、「少し長い入力で急に遅くなる」タイプの正規表現がある、という直感を持つことが大切です。
危険なパターンは、成功する入力では気づきにくいことがあります。失敗する入力、境界値、長い入力をテストに含めるのが重要です。
性能設計の観点では、正規表現は「平均速度」より「最悪時の上限」が重要です。
通常入力で速くても、失敗入力で急減速する式は公開APIやバッチで障害源になります。
実務での評価軸:
- 一致ケースの中央値
- 非一致ケースのP95/P99
- 入力長に対する増加率(線形/非線形)
- timeout到達率
さらに、ReDoS対策は単一のregex改善だけでは不十分です。次の多層防御で考えます。
- 入口制御: 入力長・文字種を制限
- 実行制御: timeout/キャンセル
- パターン制御: 曖昧性を削減
- 運用制御: 指標監視と自動アラート
この4層を揃えると、個別パターンの見落としがあっても全体停止のリスクを下げられます。
正規表現を使わない判断
正規表現は便利ですが、使わない方がよい場面もあります。
| 対象 | よりよい選択 |
|---|---|
| JSON | JSON parser |
| HTML | HTML parser |
| XML | XML parser |
| CSV | CSV parser |
| URL | URL parser |
| 日付 | 日付ライブラリ |
| プログラミング言語 | lexer / parser |
たとえばCSVは、カンマでsplitすればよいように見えますが、引用符、改行、エスケープを考えるとすぐに壊れます。
"Alice, A.","hello
world"
このような入力を正規表現だけで扱うより、CSV parserを使う方が安全です。
「使わない判断」は消極的ではなく、品質を守るための設計判断です。
特に次の条件が2つ以上当てはまるなら、regexを主役にしない方がよいです。
- ネスト構造を正確に扱う必要がある
- エスケープ規則が複数ある
- フォーマット仕様が長く更新される
- 誤判定コストが高い
実務でよくある折衷案は、regexを前段フィルタに限定し、確定判定はparserへ委譲する構成です。
この構成なら、性能と保守性のバランスを取りやすくなります。
よくある落とし穴
正規表現でよく起きる問題です。
.が改行に一致しないことを忘れる^と$の意味がmultilineで変わる- greedyによって想定より長く一致する
- 入力検証で全体一致ではなく部分一致を使う
- エスケープがPython文字列と正規表現で二重になる
- UnicodeとASCIIの違いを見落とす
\wや\bの実装差を見落とす- 複雑な構文解析に使いすぎる
- ReDoSを考えずに外部入力へ適用する
悪い例です。
.*foo.*
目的によっては十分ですが、巨大な入力では無駄が増えます。より具体的なパターンにできないか考えます。
^[^,]*foo[^,]*$
これは例としての改善であり、常に正解ではありません。大切なのは、入力の構造と失敗時の挙動を考えることです。
発展問題
次は、実装差分や性能まで含めて設計する問題です。
- 1行ログから
trace_idとspan_idを抽出し、どちらか欠けていれば不一致にする key=valueの列を抽出する。ただし value は引用符付き空白を含んでもよい- CSV風テキスト(厳密CSVではない)から先頭3列だけ抜く
- HTML断片から
<a href="...">のURLを抽出する(完全解析でない前提) - 1MB入力で最悪時性能が劣化しないように、曖昧な入れ子量指定子を含むパターンを改善する
- PythonとJavaScriptの両方で動く共通パターンへ書き換える(lookbehind禁止)
- ユーザー指定キーワードを安全に埋め込んで検索式を生成する
- 監査ログのマスキング規則を作る。
emailとtokenは隠し、user_idは残す
発展問題の解答例
以下は1つの解き方です。要件次第で別解が成立します。
1) trace_id と span_id
^(?=.*\btrace_id=([A-Fa-f0-9]{16,32})\b)(?=.*\bspan_id=([A-Fa-f0-9]{16})\b).+$
2) key=value 列(引用符対応)
([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"\\]*(?:\\.[^"\\]*)*)"|(\S+))
3) 先頭3列抽出(簡易)
^([^,]*),([^,]*),([^,]*)(?:,.*)?$
4) href 抽出(簡易)
<a\b[^>]*\bhref=(["'])(.*?)\1[^>]*>
5) 性能改善の方向
悪い例:
^(.*a.*)+$
改善例:
^[^a\n]*a[^a\n]*$
6) Python/JS共通(lookbehind回避)
lookbehind:
(?<=\$)\d+
共通化:
\$(\d+)
7) キーワード埋め込み
import re
def make_safe_keyword_pattern(keyword: str) -> re.Pattern[str]:
return re.compile(re.escape(keyword))
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\{{CONTENT}}amp;");
}
const keyword = "C++";
const reSafe = new RegExp(escapeRegExp(keyword), "g");
8) 監査ログマスキング
import re
PAT = re.compile(r"(?P<key>email|token|user_id)=(?P<val>\S+)")
def mask_audit(line: str) -> str:
def repl(m: re.Match[str]) -> str:
key = m.group("key")
if key in {"email", "token"}:
return f"{key}=***"
return m.group(0)
return PAT.sub(repl, line)
この解答では、保守性を優先して対象キーを限定しています。対象キーの追加はコードレビューで管理し、誤マスクによる監査不能を避けます。
フレーバー横断の設計指針
参考文献を横断して読むと、正規表現設計は「構文テクニック」よりも「実行モデルの選択」が先だと分かります。ここでは、Regular-Expressions.info のフレーバー差分、Russ Cox のアルゴリズム視点、各言語公式ドキュメントの制約をまとめて、実装向けの意思決定手順に落とし込みます。
1) まず機能要件を3段階で分解する
- 必須機能: 文字クラス、量指定子、アンカーだけで書けるか
- 拡張機能: lookaround、名前付きキャプチャが必要か
- 非正則拡張: backreference 依存か
3に入ると線形時間保証を崩しやすく、移植性も大きく下がります。
2) エンジン選択の実務テンプレート
| 条件 | 推奨方針 |
|---|---|
| 大規模検索、CI、コード探索 | rg 既定エンジン中心 |
| 複雑抽出だが性能要件は中程度 | Python re / JS RegExp |
| 高度構文が必須 | PCRE2(依存を明記) |
| 入力が外部公開API経由 | 線形時間寄り + 入力制限 + timeout |
3) 「書ける」ではなく「運用できる」を完了条件にする
完了条件の例:
- 同一テストセットが Python/JS/CLI で一致する
- 非一致ケースでP95が閾値以内
- 最長入力時の実行時間が上限以内
- 仕様変更時の回帰テストがある
4) 互換性の段階的退避
あるフレーバー依存の構文を使う場合は、必ず退避案を持ちます。
- lookbehind を使う → キャプチャ抽出への書き換え案を用意
- Unicode property 依存 → ASCIIフォールバックまたは前処理で分類
- named backreference 依存 → 番号参照版の代替を残す
Unicode実装の深掘り
Unicode TR18 の要点を実務仕様へ落とすと、「見た目で一致するはず」が最も危険です。ここでは、正規化・境界・プロパティ・エスケープを明示的に扱います。
1) 正規化戦略を先に決める
同じ見た目でも内部表現が違う例:
é(U+00E9)e+◌́(U+0065 U+0301)
正規化方針:
- 検索前にNFCへ統一する
- 生データ保持が必要なら「保存時は原文」「検索時は正規化ビュー」
- 照合ログに「正規化前/後長」を残す
import unicodedata
def norm_nfc(text: str) -> str:
return unicodedata.normalize("NFC", text)
2) 境界判定を言語依存にしない
\b は実装差が大きいため、日本語・タイ語・絵文字列では期待とズレます。TR18 の Level 2 相当(語境界・grapheme)を必要とする領域では、regex単体で完結させない方針が安全です。
3) Unicode property は仕様バージョンを固定する
\\p{...} はUnicodeデータ更新で挙動が変わる可能性があります。仕様に次を明記します。
- Unicodeバージョン
- 実行環境バージョン
- 代表テスト文字集合
4) エスケープ記法の差分に注意
TR18 と各言語実装を合わせると、\u{...} / \x{...} / \U........ など表記ゆれが出ます。ドキュメントで「このプロジェクトでは何を使うか」を固定してください。
パターン設計カタログ
この節は、Regular-Expressions.info と RexEgg の設計原則(contrast、fail fast、曖昧さ削減)を、再利用しやすい形でまとめたカタログです。
A. 区切り構造
- 区切り文字が明確な場合
^[^,]*,[^,]*,[^,]*$
- キー値ペア列
\b[A-Za-z_][A-Za-z0-9_]*=(?:"[^"\\]*(?:\\.[^"\\]*)*"|\S+)
- URL query 抽出(簡易)
[?&]([A-Za-z_][A-Za-z0-9_]*)=([^&#]*)
B. 構文マーカー抽出
- Markdown 見出し
^(#{1,6})[ \t]+(.+)$
- コードブロック開始
^```([A-Za-z0-9_+-]+)?$
- TODOタグ
\b(?:TODO|FIXME|HACK|NOTE)\b[: ]?(.*)
C. 識別子・ID
- チケットID
\b[A-Z]{2,10}-\d{1,6}\b
- セマンティックバージョン(簡易)
\bv?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?\b
- ブランチ名
^(?:feature|fix|chore|docs|refactor)\/[a-z0-9][a-z0-9._-]{2,63}$
D. ログ分析
- HTTPステータス
\bstatus=(1\d\d|2\d\d|3\d\d|4\d\d|5\d\d)\b
\b(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})\b
- trace/span
\btrace_id=([A-Fa-f0-9]{16,32})\b.*\bspan_id=([A-Fa-f0-9]{16})\b
E. マスキング
- token系
\b(token|api[_-]?key|secret|password)=\S+
- メール(簡易)
\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b
- クレカ様式(誤検知前提)
\b(?:\d[ -]*?){13,19}\b
本番ではLuhn検証など後段処理が必須です。
F. アンチパターン置換表
| 避けたい書き方 | 置き換え候補 |
|---|---|
.*foo.* |
^[^\\n]*foo[^\\n]*$ (行単位) |
^(.*?,){11}P |
^([^,\r\n]*,){11}P |
(.+)+ |
目的に応じて単純化 |
| 無条件 lookbehind | キャプチャで代替可能か検討 |
Python実践レシピ集(拡張)
1) コンパイル済みパターンの集中管理
import re
PATTERNS = {
"ticket": re.compile(r"\b[A-Z]{2,10}-\d{1,6}\b"),
"status": re.compile(r"\bstatus=(\d{3})\b"),
}
2) マッチAPIの使い分けを固定
fullmatch: 入力検証search: 含有判定finditer: 全抽出(位置情報あり)
def extract_statuses(text: str) -> list[str]:
return [m.group(1) for m in re.finditer(r"\bstatus=(\d{3})\b", text)]
3) 置換関数で安全に分岐
import re
pat = re.compile(r"(?P<k>email|token|user_id)=(?P<v>\S+)")
def repl(m: re.Match[str]) -> str:
if m.group("k") in {"email", "token"}:
return f"{m.group('k')}=***"
return m.group(0)
4) 失敗ケースを含むベンチマーク
import re
import time
bad = re.compile(r"^(a+)+{{CONTENT}}quot;)
samples = ["a" * 20 + "!", "a" * 22 + "!", "a" * 24 + "!"]
for s in samples:
t0 = time.perf_counter()
bad.match(s)
print(len(s), time.perf_counter() - t0)
5) Python 3.11+ の枝刈り機能
import re
pat = re.compile(r"^(?>[^,\r\n]*,){11}P")
(?>...) は設計意図が明確な場面で使うと、読みやすさと性能を両立しやすくなります。
6) bytes 処理の明示
import re
pat = re.compile(rb"\b[A-Z]{2,10}-\d{1,6}\b")
print(bool(pat.search(b"ticket=CS-123")))
7) 例外設計
正規表現の失敗は「不一致」と「正規表現自体の誤り」を分離します。
- 不一致: 通常フローで処理
re.error: 監視対象として扱う
JavaScript実践レシピ集(拡張)
1) test と exec の分離
const hasError = /\bERROR\b/.test(line);
const re = /\bstatus=(\d{3})\b/g;
let m;
while ((m = re.exec(text)) !== null) {
console.log(m[1], m.index);
}
2) lastIndex 汚染の回避
function allMatches(text, source, flags = "g") {
const re = new RegExp(source, flags);
return [...text.matchAll(re)];
}
3) Unicode property を使った分類
const tokenRe = /\p{Script=Han}+|\p{Alphabetic}+|\p{Number}+/gu;
console.log("東京 Tokyo 123".match(tokenRe));
4) 動的パターンの安全化
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\{{CONTENT}}amp;");
}
5) バリデーションの全体一致
const userRe = /^[A-Za-z0-9_-]{3,32}$/;
console.log(userRe.test("alice_01"));
6) 置換コールバックで選択マスク
const masked = line.replace(/\b(email|token|user_id)=(\S+)/g, (_, k, v) =>
k === "user_id" ? `${k}=${v}` : `${k}=***`
);
7) 失敗優先の順序設計
RexEgg の design-to-fail 原則通り、否定されやすい条件を先に置くと平均処理時間を下げやすくなります。
grep/ripgrep運用レシピ集
1) 出力形式を目的別に固定する
- 存在確認:
-q - 件数:
-c - 抽出:
-o - 最初の1件:
-m 1
grep -q "FATAL" app.log
grep -c "ERROR" app.log
grep -oE "status=[0-9]{3}" app.log
grep -m 1 "panic" app.log
2) 範囲を絞ってから複雑化する
rg -n "ERROR" src
rg -n "ERROR.*timeout" src
3) ignoreが原因の見落としを診断
rg --debug "TODO"
rg -u "TODO"
rg -uu "TODO"
4) PCRE2依存を明示
rg -P "(?<=token=)[A-Za-z0-9]+"
-P 依存は README やスクリプトコメントに残します。
5) 大規模ログの段階抽出
rg "ERROR" app.log | rg "timeout|deadline" | rg -o "trace_id=[A-Fa-f0-9]+"
6) null区切りと安全な連携
ファイル名に空白や改行がある環境では、null区切りオプションを優先し、後段ツールと安全連携します。
ReDoS監査プレイブック
OWASP、Regular-Expressions.info、Russ Cox の知見を踏まえ、監査を「チェック可能な作業」に落とします。
フェーズ1: 収集
- 外部入力へ適用される正規表現一覧
- タイムアウト設定の有無
- 入力最大長
- エンジン種別
フェーズ2: 静的判定
危険シグナル:
- 入れ子量指定子
- 重なり分岐
(a|aa)+ .*と曖昧分岐の組み合わせ- backreference を含む複雑式
フェーズ3: 動的試験
最小セット:
- 正常一致
- 正常不一致
- 長文一致
- 長文不一致(攻撃系)
測定項目:
- 実行時間
- メモリ
- タイムアウト発生率
フェーズ4: 改善
改善順序:
- 入力長制限
- 曖昧部を否定文字クラス化
- atomic / possessive 適用
- エンジン変更
- アーキテクチャ変更(parser化)
監査テンプレート
[regex_id]
用途:
入力源:
最大長:
エンジン:
危険シグナル:
試験入力:
P95/P99:
改善案:
大規模演習セット
以下は、参考文献の観点を実装へ落とすための長文演習です。解答は1つではありません。
セットA: 設計
- 監査ログ仕様を読み、抽出・検証・マスキングの3種類の正規表現を分離設計する
- 同じ要件を Python / JS / rg で実装し、共通最小機能セットを定義する
- Unicodeを含むユーザー名仕様を定義し、TR18 Level 1/2 どちらで扱うか決める
- lookbehind禁止環境で同等抽出を設計する
- 置換処理で情報を残しつつ秘匿するポリシーを作る
セットB: 性能
^(.*?,){11}Pと^([^,\r\n]*,){11}Pの失敗ケースベンチマークを比較する(.+)+系の病的入力を作り、タイムアウト基準を決める- エンジン別(既定/PCRE)で同一式の遅延差を計測する
- 「失敗しやすい条件を先に置く」順序最適化を定量化する
- 入力長制限の有無でリスクがどう変わるか報告する
セットC: 運用
- 既存システムの正規表現一覧を収集し、危険度ランクを付ける
- CIに正規表現回帰テストを導入する
- 本番障害を想定し、feature flagでregex切替する手順書を作る
- 監視ダッシュボードにregex関連指標を定義する
- ReDoSインシデントのポストモーテム雛形を作る
セットD: 実装
- Python 3.11+ で atomic group による改善版を実装する
- JSで
matchAllベース抽出とexecループ抽出を比較する re.escape/escapeRegExpの利用漏れ検知ルールを作る- 正規化(NFC)を導入し、検索一致率の変化を計測する
- parserへ移行すべき境界を定義し、置換計画を提案する
演習の評価基準
- 正確性: 意図した入力だけを扱えるか
- 移植性: 複数環境で同じ結果か
- 性能: 失敗ケースで劣化しないか
- 安全性: ReDoS / Injection 対策があるか
- 保守性: テストと仕様が同時更新されるか
この演習群を継続的に回すと、正規表現は「個人の勘」から「チームの再現可能な設計資産」へ変わります。
逆引きクックブック 60
要件から最初の一手を引けるよう、短いレシピを60本まとめます。すべて「出発点」です。実運用では入力上限、失敗ケース、実装差分テストを追加してください。
テキスト抽出
- 行頭コメント抽出
^\s*#\s*(.*)$
- 括弧内抽出(単純)
\(([^()]*)\)
- 二重引用符内(エスケープ対応)
"([^"\\]*(?:\\.[^"\\]*)*)"
- 先頭キー抽出
^([A-Za-z_][A-Za-z0-9_]*)=
- 行末数値抽出
(-?\d+(?:\.\d+)?)$
- 英単語列挙
\b[A-Za-z]+\b
- UUID(バージョン緩め)
\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\b
- 日時
YYYY-MM-DD hh:mm:ss
\b\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}\b
- 16進数リテラル
\b0x[0-9A-Fa-f]+\b
- HTMLタグ(簡易)
<([A-Za-z][A-Za-z0-9:-]*)(?:\s+[^>]*)?>
検証
- ユーザー名(ASCII)
^[A-Za-z0-9_-]{3,32}$
- 郵便番号(日本 7桁)
^\d{3}-?\d{4}$
- 電話番号(日本の緩い形)
^0\d{1,4}-?\d{1,4}-?\d{3,4}$
- Slack風チャンネル名
^[a-z0-9][a-z0-9_-]{1,79}$
- 短縮コード
^[A-Z]{2}\d{4}$
- 環境名
^(dev|stg|prod)$
- IPv4(形のみ)
^(?:\d{1,3}\.){3}\d{1,3}$
- MACアドレス
^(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$
- ハッシュ(sha256)
^[0-9a-f]{64}$
- 2桁国コード
^[A-Z]{2}$
置換
- 連続空白を1つに
[ \t]{2,}
- 末尾空白除去
[ \t]+$
- 先頭空白除去
^[ \t]+
- 改行正規化(\r\n -> \n)
\r\n?
foo_barをfooBarへ(検索側)
_([a-z])
- 大文字境界に
_挿入(検索側)
([a-z0-9])([A-Z])
- ログのtokenマスク
\btoken=\S+
- emailマスク(簡易)
\b([A-Z0-9._%+-])[A-Z0-9._%+-]*(@[A-Z0-9.-]+\.[A-Z]{2,})\b
- URL query削除
\?.*$
- 複数空行を1行へ
\n{3,}
ログ/監視
- ERROR/WARN抽出
\b(?:ERROR|WARN)\b
- HTTP 5xx行
\bstatus=5\d\d\b
- latency閾値超え
\blatency_ms=(\d{4,})\b
- trace_id抽出
\btrace_id=([A-Fa-f0-9]{16,32})\b
- SQLエラー語
\b(?:deadlock|timeout|duplicate key|syntax error)\b
- 例外クラス名
\b[A-Za-z_][A-Za-z0-9_]*(?:Exception|Error)\b
- panic検知
\bpanic\b
- retry回数
\bretry=(\d+)\b
- endpoint抽出
\b(?:GET|POST|PUT|DELETE)\s+(\S+)
- user_id抽出
\buser_id=(\d+)\b
開発/CI
- TODO強調
\bTODO(?:\([^)]+\))?:\s*(.*)
- FIXME強調
\bFIXME:\s*(.*)
- PR番号抽出
#(\d+)\b
- Conventional Commits判定
^(feat|fix|docs|refactor|test|chore)(\([^)]+\))?: .+
- Issueキー抽出
\b[A-Z][A-Z0-9]+-\d+\b
- SemVerタグ判定
^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$
- 危険文字検知(制御文字)
[\x00-\x08\x0B\x0C\x0E-\x1F]
- シークレット候補行
\b(api[_-]?key|token|secret|password)\b\s*[:=]\s*\S+
- 依存バージョン行
\b[a-zA-Z0-9._-]+@[~^]?\d+\.\d+\.\d+
- Docker imageタグ
\b[a-z0-9._/-]+:[A-Za-z0-9._-]+\b
Unicode/多言語
- ひらがな列
[\u3040-\u309F]+
- カタカナ列
[\u30A0-\u30FF]+
- 漢字列(概略)
[\u4E00-\u9FFF]+
- 全角スペース検知
\u3000
- 絵文字(概略)
[\u{1F300}-\u{1FAFF}]
- 混在スクリプト警告(概略)
(?=.*[A-Za-z])(?=.*[\u3040-\u30FF\u4E00-\u9FFF]).+
- 合成文字を含む可能性
[\u0300-\u036F]
- 先頭末尾の不可視文字
^[\u200B-\u200D\uFEFF]+|[\u200B-\u200D\uFEFF]+$
- 非ASCII検知
[^\x00-\x7F]
- Unicode property (JS,
u必須)
\p{Alphabetic}+
用語集と判断基準
用語集
- regex flavor: 実装ごとの構文・挙動の方言
- engine: パターンを実行する処理系
- backtracking: 分岐失敗時に候補へ戻る探索
- Thompson NFA: 状態集合を同時に進める線形時間寄りの実行方式
- catastrophic backtracking: 失敗ケースで探索が指数的に増える状態
- lookaround: 入力消費せず前後条件を確認するアサーション
- atomic group: グループ内部の戻り候補を捨てる構文
- possessive quantifier: 戻りを許さない量指定子
- grapheme cluster: ユーザーが1文字と感じる文字単位
- canonical equivalence: 正規化で同値とみなされる文字列関係
判断基準(設計レビュー時)
- これは本当にregexで解くべき問題か
- 全体一致が必要か、部分一致でよいか
- 実装依存構文が混ざっていないか
- 失敗ケースの実行時間を測定したか
- 入力長制限はあるか
- ユーザー入力をパターンに直埋めしていないか
- Unicode正規化方針は決まっているか
- テストに境界値と攻撃系入力があるか
- 可観測性(遅延、失敗率、timeout)はあるか
- 将来のメンテナが読める説明になっているか
最低限の品質ゲート
- 正常系 5ケース以上
- 異常系 5ケース以上
- 境界値 3ケース以上
- 長文不一致 3ケース以上
- 実行時間閾値違反 0件
この品質ゲートを満たせない正規表現は、短くても本番投入しない方針を推奨します。
ケーススタディ集 12
この節は「どの構文を使うか」より「どの順序で判断するか」に焦点を当てたケース集です。
1) 監査ログの秘匿と検索性を両立する
課題:
email,tokenは秘匿user_id,trace_idは検索可能に残す- 1日数千万行
方針:
- 抽出regexと置換regexを分離
- 置換は関数置換でキーごと分岐
- マスク後も
trace_idは完全保持 - 失敗ケース(壊れたkey=value)を別カウンタで観測
2) CSV風データでの遅延障害
症状:
- 一部ファイルだけ処理時間が急増
- CPUが高止まり
原因:
^(.*?,){11}Pが失敗系で爆発
改善:
^([^,\r\n]*,){11}Pへ変更- 入力長上限を追加
- P99遅延をダッシュボード化
3) 多言語ユーザー名の仕様衝突
症状:
- 見た目同じユーザー名が重複登録
原因:
- NFC/NFD差と境界判定差
改善:
- 登録時NFC正規化
- 表示は原文保持
- 検索キーは正規化済み列
4) JSでの lastIndex バグ
症状:
- 同じ入力で抽出件数が揺れる
原因:
g付きRegExpインスタンス再利用
改善:
matchAllへ統一- または毎回
new RegExp(...)
5) grep運用での見落とし
症状:
- 明らかにある文字列が
rgで見つからない
原因:
.gitignore/ hidden / binary 除外
改善:
rg --debugrg -u/-uu/-uuu段階診断
6) lookbehind非対応環境への移植
症状:
- ローカルでは動くがCIで失敗
原因:
- CI側エンジンがlookbehind制限
改善:
(?<=\$)\d+を\$(\d+)へ置換- 後段でキャプチャ参照
7) ReDoSテストが機能していない
症状:
- テスト全通過なのに本番で遅延
原因:
- 一致ケースのみテスト
改善:
- 非一致・長文・攻撃入力を追加
- SLA違反時にテスト失敗
8) parserへの移行判断
課題:
- HTML断片抽出が複雑化
判断:
- タグ入れ子・属性エスケープ・script混在を扱う必要がある
- regex保守コスト > parser導入コスト
結論:
- regexは前段フィルタのみに縮退
- 本抽出はHTML parserへ移行
9) セキュリティレビューでの誤解
症状:
- 「
re.escapeしているから安全」と判断
盲点:
- 入力長無制限
- timeout未設定
- 複数regex連鎖で総時間増大
対策:
- escape + length cap + timeout + 観測をセットで実装
10) 正規表現の仕様変更が追跡不能
症状:
- なぜこのパターンになったか不明
改善:
- 変更時に「目的・非目的・代表入力」をPRに必須化
- regexごとにテストIDを採番
11) 置換で監査不能になる
症状:
- すべて
***にして原因追跡不能
改善:
- 機密キーだけマスク
- 構造キー・追跡IDは保持
- マスク前後件数を比較
12) 高速化の誤最適化
症状:
- 先にatomic/possessive導入して可読性悪化
改善順:
- 曖昧部の削減
- 文字クラス具体化
- 失敗順最適化
- 最後にatomic/possessive
RexEggの設計原則とRegular-Expressions.infoの実測例は、どちらも「まず曖昧性を消す」ことを示しています。
正規表現の性能とセキュリティ
正規表現は強力ですが、不適切な使用は重大なセキュリティ問題を引き起こします。
正規表現 DoS (ReDoS)
正規表現エンジンの入力に対する時間計算量が指数関数的に増加する現象を、正規表現 DoS (Regular Expression Denial of Service) と呼びます。
悪意あるパターンの例
# パターン: (a+)+b
# 入力: "aaaaaaaaaaaaaaaaaaaaaaaac"
# 結果: バックトラッキングが指数爆発
このパターンに長い ‘a’ 文字列が続き、‘b’ が見つからない場合、マッチ失敗までの時間が O(2^n) になります。
このような問題を避けるには、ネストした量指定子、曖昧な繰り返し、失敗時に長く戻るパターンを避けることが重要です。
ReDoS の一般的なパターン
# 危険: ネストした量指定子
(a+)+
([a-z]+)*
(.*)*
(a|a)*
(a|ab)*
# より安全: 交互の最小化
a+
([a-z])+
.*
[ab]*
(a|b)*
対策
-
バックトラッキング最小化
# 悪い例 ^([a-z])*$ # 良い例 ^[a-z]*$ # キャラクタクラスは暗黙の非キャプチャグループ -
タイムアウト設定
import signal def timeout_handler(signum, frame): raise TimeoutError("Regex timeout") signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(2) # 2秒でタイムアウト try: result = re.match(pattern, text) finally: signal.alarm(0) # タイマー解除 -
入力長の制限
// JavaScriptの例 const MAX_INPUT = 1000; if (text.length > MAX_INPUT) { throw new Error("Input too long"); } const result = pattern.test(text);
Unicode セキュリティ
Unicode 正規表現は追加のセキュリティリスクを引き起こします。
Unicode に関連する脆弱性
# 例: UTF-8 でのマルチバイト文字処理
# 不正な文字列でバリデーション回避
# 例えば、メールアドレスの検証で:
# ユーザー: "admin@example.com"
# 攻撃者: "admin@example.com" # Unicode の @
OWASP では、正規表現ベースのセキュリティチェック(CSRF トークン、SQLi 検出)が Unicode 正規化に対して脆弱性を持つことを指摘しています。
対抗策
import unicodedata
def normalize_input(text):
# NFKC 正規形で統一(分解と互換性の統一)
return unicodedata.normalize('NFKC', text)
def validate_email(email):
normalized = normalize_input(email)
# 正規化後にパターンマッチ
return re.match(r'^[a-z0-9+\-_.]+@[a-z0-9+\-_.]+{{CONTENT}}#x27;, normalized, re.I)
RFC 6531 (Internationalized Email) では、Unicode ドメイン名(Punycode)の扱いが規定されていますが、これは正規表現では扱いきれません。
正規表現エンジンの比較
異なる言語や環境の正規表現エンジンは、機能と性能が大きく異なります。
エンジンタイプ
| エンジンタイプ | 特徴 | 例 |
|---|---|---|
| DFA(決定性有限オートマトン) | 高速、バックトラッキングなし | GNU grep, Perl の /o 修飾子 |
| NFA(非決定性有限オートマトン) | 機能豊富、バックトラッキング | Perl, Python, Java, JavaScript |
| Hyrbid | DFA と NFA の組み合わせ | PCRE, Rust regex crate |
言語別の比較表
| 言語 | エンジン | 先読み | 後読み | キャプチャ性能 |
|---|---|---|---|---|
| Python | SRE(NFA) | ✓ | ✓(3.8+) | 中 |
| JavaScript | V8(NFA) | ✓ | ✓(非標準) | 低(最適化不足) |
| Java | java.util.regex | ✓ | ✓ | 高 |
| Perl | Oniguruma | ✓ | ✓ | 高 |
| Rust | regex crate | ✓ | ✓(1.7+) | 高(スピード重視) |
| Go | regexp | × | × | 中(DFA) |
パフォーマンス例(実測値)
入力: 100万文字の英数字
パターン: [a-z]+@[a-z]+\.[a-z]+
Rust (regex): ~5ms
Python (re): ~50ms
JavaScript (native): ~200ms
Java (java.util.regex): ~100ms
Perl (with /o): ~20ms
Rust の regex crate は DFA + NFA ハイブリッドで、バックトラッキング回避と ReDoS 耐性を両立させています。
正規表現の最適化テクニック
大規模テキスト処理では、正規表現の最適化が重要です。
量指定子の効率性
# 低効率: 全選択肢をカバー
[a-zA-Z0-9_-]+
# 高効率: より限定的
[a-zA-Z0-9_-]+ # キャラクタクラス内での最適化
# エンジンに依存
(\w|-)+ # \w はメタクラス
[a-zA-Z0-9_-]+ # リテラルリスト
実装ごとに最適化の効き方は違いますが、入力を短く制限する、曖昧な分岐を減らす、必要ならバックトラッキングしないエンジンを選ぶ、という方針は共通です。
アンカーと開始位置
# 悪い例: 文字列全体をスキャン
pattern = r"id=\d+" # 全テキストで検索
# 良い例: 既知位置から検索
pattern = r"^id=\d+" # 行頭からのみ
pattern = r"(?<=key:)id=\d+" # 後読みで位置限定
キャプチャグループの最小化
# 不要なキャプチャあり
pattern = r"(\d+)-(\d+)-(\d+)" # 3グループ
matches = re.match(pattern, "2024-01-15")
# 最適化: 非キャプチャグループ
pattern = r"(?:\d+)-(?:\d+)-(?:\d+)"
# キャプチャが不要ならこちらが高速
Unicode と正規表現の詳細
Unicode の複雑さは、正規表現に大きな影響を与えます。
Unicode フラグ
# Python 3 の例
import re
text = "Café"
# ASCII のみ(デフォルト)
re.findall(r'\w+', text) # ['Caf']
# Unicode 対応
re.findall(r'\w+', text, re.UNICODE) # ['Café']
正規化の重要性
import unicodedata
# NFD(分解形): é = e + ´
text1 = "Café" # é は U+00E9(合成形)
# NFC(合成形)
text2 = unicodedata.normalize('NFC', text1)
# パターンマッチングは正規化後に
pattern = r"Café"
re.search(pattern, unicodedata.normalize('NFC', text1))
GNU grep と高度な正規表現
GNU grep は、POSIX 基本正規表現(BRE)、拡張正規表現(ERE)、Perl 互換正規表現(PCRE)をサポートします。
GNU grep のフラグ別の挙動
# BRE(Basic Regular Expression)
grep 'word\|other' file # \| が選択
# ERE(Extended Regular Expression)
grep -E 'word|other' file # | が選択
# PCRE
grep -P '(?<=prefix)\w+' file # 後読み対応
実践的な正規表現チェックリスト
正規表現を実装する際のセキュリティ・パフォーマンスチェックリスト:
□ ReDoS テスト: 長い入力でタイムアウトしないか
□ Unicode 対応: 多言語入力で動作するか
□ 入力長チェック: 過剰に長い入力を制限しているか
□ タイムアウト: 無限ループを防ぐ設定があるか
□ キャプチャ最小化: 必要な分だけキャプチャしているか
□ アンカー活用: 全文スキャンを避けているか
□ テスト: エッジケース(空文字列、特殊文字)をテストしたか
□ パフォーマンス: ベンチマークで閾値を超えていないか
まとめ
正規表現は、文字列の規則的な部分を検索、抽出、置換するための実用的な道具です。重要なのは、メタ文字を暗記することではなく、入力の形、実行エンジン、境界、失敗時の挙動を意識することです。
小さな検索や抽出には非常に強力ですが、構造化データやネストした文法にはparserを使うべき場面があります。正規表現を「短い魔法」ではなく「保守するコード」として扱うと、読みやすく安全に使えます。
参考文献
公式・標準
- Unicode Technical Standard #18: Unicode Regular Expressions
- MDN Web Docs: Regular expressions
- Python Documentation: re
- Python Documentation: Regular Expression HOWTO
- OWASP: Regular Expression Denial of Service
講義・記事
- GNU grep Manual: Regular Expressions
- Regular Expressions Flavor Reference
- Regular Expression Matching Can Be Simple And Fast (Russ Cox)
- Regular-Expressions.info
- Runaway Regular Expressions: Catastrophic Backtracking
- Regular Expressions Tutorial (Regular-Expressions.info)
- ripgrep User Guide (raw)
- RexEgg: Regex Tutorial
- ripgrep User Guide