- 技術 💻
正規表現の落とし穴8選|ReDoS・\dと全角・先読み・gフラグ
正規表現って、「動いたからヨシ」で通り過ぎがちなんですよね。でも実は、世界的なサービスを止めた実績のある、危険物でもあります。
今回は、知っていると書き方とレビューが変わるものを8つ集めました。その1行、サーバーを止める力があります。1つでも知らなかったら儲けものです。
① 正規表現は、実際に世界を止めたことがあります
2019年、Cloudflareが世界規模の障害を起こしました。原因は、たった1つの正規表現です。
このとき問題になったのは、バックトラッキングが爆発する形のパターンでした。代表例が (a+)+ のような、量指定子を入れ子にしたパターン。マッチに失敗したとき、エンジンが「別の区切り方なら成功するかも」と組み合わせを爆発的に試し直すことがあるんです。文字列が少し長いだけで、CPUが張り付きます。
ReDoSと呼ばれる現象で、Stack Overflowも過去に同じ理由で止まっています。正規表現は、入力次第で計算量が爆発しうるコード。「動いた」の裏に、最悪ケースが隠れているんです。
② \d は、「0-9」とは限りません
数字にマッチする \d。半角の0-9だけだと思っていませんか。言語によって、違うんです。
# Python
re.match(r"\d", "3") # → マッチする(全角の3)
// JavaScript
/\d/.test("3") // → false
Pythonの \d は、既定でUnicodeの数字ぜんぶにマッチします。全角数字も、アラビア文字の数字も。JavaScriptは半角0-9だけ。Javaは指定次第で切り替わります。
「数字だけ許可したはずの入力チェックを、全角がすり抜けた」——日本語のシステムでは、実害に直結する差なんです。
③ 「.」は改行に負けます。でも [^x] は改行を食べます
「. は改行以外の任意の1文字」。ここまでは有名ですよね。見落とされがちなのが、その裏側です。
否定の文字クラス [^x] は、改行にマッチします。「x以外ぜんぶ」なので、改行も「x以外」に含まれるんですよね。
だから ".*" を "[^"]*" に書き換えた瞬間、複数行をまたいでマッチする挙動に変わることがあります。同じ意図のつもりの書き換えで、範囲が変わる。ドットの常識だけ知っていると、ここで足をすくわれるんです。
④ 先読みは、1文字も消費しません
パスワードチェックでよく見る、この呪文。
^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,}$
なぜ条件を3つ並べられるのか。秘密は、先読み (?=...) が「幅ゼロ」だからです。
先読みは「この先にこれがあるか」を確認するだけで、読み取り位置を1文字も進めません。だから同じ場所から何個でも条件を重ねられる。ANDの無い正規表現で、実質のAND条件を作れるんですよね。
仕組みを知ると、あの呪文が「その場で3回振り返っているだけ」だと分かって、自分でも組めるようになります。
⑤ 選択肢は、「左」が勝ちます
cat|category というパターンで "category" を検索すると、どうなるか。
多くのエンジン(Perl系・JavaScript・Python)では、cat がマッチして終わります。選択肢は左から試して、最初に成功したものが採用されるからです。長い方が勝つわけではないんですよね。だから交替を書くときは、長いものを左に置くのが基本です。
さらに「ほぉー」なのが、POSIX準拠のエンジン(grepの系譜)では逆に最長マッチが勝つ規則だということ。同じパターンでも、道具によって答えが変わるんです。
⑥ 正規表現は、「数を数えられません」
「HTMLを正規表現でパースするな」と言われる理由、説明できますか。感覚論ではなく、理論的な限界なんです。
正規表現が扱える言語には、「対応する括弧の入れ子を、いくらでも深く数える」能力がありません。タグの開き閉じの対応は、まさにこの「数える」問題なんですよね。
だからHTMLやJSONの構文解析は、パーサーの仕事です。正規表現の出番は、その手前の「切り出し」まで。なお、再帰に対応した拡張を持つ方言も存在しますが、そこまで行くともう「正規」表現ではない、というのがオチです。
⑦ ReDoSしないエンジンは、「引き換え」に何かを捨てています
①の対策として、そもそもReDoSが起きない設計のエンジンがあります。RE2という系統で、Go言語の標準の正規表現もこれです。どんな入力でも計算量が爆発しない保証つき。じゃあ全部これでいいのでは?
引き換えに、後方参照(\1)が使えません。「前にマッチしたものと同じもの」を参照する機能は、爆発の火種と根が同じなので、安全設計では原理的にサポートできないんですよね。
速さと安全を取るか、表現力を取るか。エンジン選びは機能のトレードオフなんです。
⑧ JavaScriptの正規表現は、「状態」を持つことがあります
最後は、JSで全員が一度は首をかしげるやつです。
const r = /a/g;
r.test("abc"); // → true
r.test("abc"); // → false !?
同じ呼び出しなのに、結果が交互に変わります。gフラグ付きの正規表現は、前回どこまで読んだかを覚えている(lastIndex)からです。2回目は前回の続きから探すので、見つからずfalse。3回目は先頭に戻ってtrue。
「テストがたまに落ちる」「1回おきに挙動が変わる」の正体がこれだったとき、人は正規表現を少し嫌いになります。
番外編:役に立たないけど、痺れる話
正規表現で、素数判定ができます
^(11+?)\1+$
1をn個並べた文字列がこれにマッチしたら、nは素数ではありません。「同じ長さの塊で割り切れるか」を後方参照で試させる仕掛けです。実務では絶対に使いませんが、発想が美しいんですよね。
メールアドレスの「完全な」正規表現は、数千文字あります
仕様(RFC)に完全準拠しようとした正規表現は、画面を埋め尽くす長さになることが知られています。実務は「ほどほどのチェック+確認メール送信」が正解、という良い教訓です。
エディタの置換でも、正規表現は使えます
VSCodeなどの置換で (\d+)円 → ¥$1 のようにキャプチャを使えます。コードを書かない日でも、正規表現は毎日使える道具なんです。
RJCでも、正規表現と付き合っています
入力チェック、ログの抽出、データ移行時の変換。RJCが携わる業務システムの世界では、正規表現は「たまに書く便利技」ではなく、品質に直結する部品です。
だから①のような危険パターンの知識は、そのままレビュー観点として共有しています。書ける人より、危ないと気づける人が重宝される道具なんです。
よくいただくご質問
Q. まずどれから押さえるべきですか?
A. ①のReDoSかなと思います。明日のレビューから「量指定子の入れ子、大丈夫?」と言えるようになります。
Q. 言語ごとの違いなんて、覚えきれません…
A. 覚えなくて大丈夫です。「エンジンで挙動が違う」と知っていることが本体で、移植のときに検証する習慣さえあれば守れるんですよね。
Q. 安全に書くコツはありますか?
A. 3つあります。長い選択肢を左に置く。量指定子の入れ子を避ける。そしてテスターで可視化しながら書く。この3つでだいぶ違います。
Q. 未経験からでも、使えるようになりますか?
A. なります。まずエディタの検索・置換で使い始めるのが最短です。毎日の作業がそのまま練習になるんですよね。
「動いた」と「正しい」が、いちばん遠い道具です
今日の8つを並べてみて思ったのが、これでした。正規表現は、手元のテストケースで動くことと、あらゆる入力で正しく安全に動くことの距離がとても遠い道具です。
だからこそ、仕組みを少し知っているだけで、書くときもレビューするときも、見える景色が変わるのかなと思います。
みなさんが正規表現で踏んだ事故や名パターン、よかったら教えてください。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。