DevKitLab Logo DevKitLab
正規表現 / デバッグ / JavaScript

正規表現がマッチしないのはなぜ?まずエンジンの動きを理解する

「マッチしない」正規表現は、たいてい実はマッチしています——ただ、あなたが思った範囲ではないだけです。小技を暗記するより、エンジンが文字列をどう走査してバックトラックするかを理解しましょう。貪欲・遅延・アンカー・先読み・破滅的バックトラッキングは、すべて同じ仕組みの別の側面です。

正規表現を書いて、しばらくじっと眺める。見れば見るほど、これはマッチするはずだと確信が深まっていく。ところが結果は何もマッチしない——あるいは、欲しかったものとはかけ離れた何かにマッチする。そこであなたは付け足しはじめる。.* をもう一つ、\ をもう一つ、あのグループをもう一重の括弧で包む。付け足すたびに、正解からはむしろ遠ざかっていく。

たいていの正規表現チュートリアルが最初に言わないことがあります。エンジンは決して間違えていません。書いたとおり、一字一句そのまま実行しただけです——ただ、あなたが書いたものが、あなたの意図したものではなかっただけ。だから「なぜマッチしないのか?」という問いそのものがずれています。もっと役に立つ問いはこうです。エンジンは今どの位置にいて、何を試し、なぜそこで止まったのか? エンジンのように考えられるようになれば、あらゆる「怪奇現象」は予測可能な結果へと解けていきます。

この記事は小技の一覧ではありません。まず数分をかけてエンジンの動きを説明し、それから同じ一つの例を最後まで練り上げていきます——とんでもなく間違ったパターンから、正しく、堅牢で、しかも性能を潰さないパターンへ。貪欲と遅延、付け忘れたフラグ、結果を裏返しにする lastIndex の罠、アンカー、エスケープの二つの世界、ゼロ幅の先読み、Unicode の三つの層、そして最後に CPU を焼き切る破滅的バックトラッキング——これらがすべて同じ機械の別の側面だと分かるはずです。

エンジンは実際に何をしているのか

JavaScript は——PCRE、Python の re、Java など、日常的に触れるほとんどのエンジンと同じく——バックトラッキング型のエンジンです。その動き方は正直かなり素朴で、エンジンは「現在位置」を一つ持ち、文字列の左端から始めて一文字ずつ右へ読み進め、各位置でそこからパターンがマッチできるかを試します。

肝心なのは「試す」という言葉です。エンジンが *+? といった量指定子に出くわすたびに、それは選択を迫られます——もう一文字食べるか、ここで止まるか。貪欲な量指定子(既定の *+)は必ず「もっと食べる」を先に選び、食べられるだけ一気に飲み込みます——そのあいだ、通り過ぎた「ここで止まれたはずだ」という位置をすべてバックトラッキング用のスタックに控えておきます。パターンが先へ進んでどこかで行き詰まると、エンジンはすぐには諦めず、直近に控えた位置まで巻き戻して一文字吐き戻し、別の道を試します。現在位置での可能性を試し尽くしてなお駄目なときに初めて観念し、現在位置を一つ右へずらして、最初からやり直すのです。

この一つの動作——貪欲に食べ、行き詰まったらバックトラックして吐き戻す——が、正規表現の挙動を読み解くための万能鍵です。この先、何度もこれに出会います。貪欲がなぜ食べ過ぎるかといえば、先に飲み込んで後から返すから。遅延がなぜ正反対かといえば、迫られるまで我慢するから。先読みがなぜ文字を消費しないかといえば、覗き見るだけで実際には前へ進まないから。破滅的バックトラッキングがなぜ CPU を焼くかといえば、「別の道を試す」組み合わせの数が指数的に爆発するから。同じ機械、同じ規則です。

ついでに知っておく価値のある区別があります。バックトラックしない別の種類のエンジンもあるのです——Go の regexp や Rust の regexripgrep の裏側)などです。これらは有限オートマトンの上に作られ、マッチを一回の線形走査に変えるため、構造上、破滅的バックトラッキングに免疫があります——代わりに、後方参照のようなバックトラックを本質的に必要とする機能は捨てています。これで、よくある謎が説明できます。同じ危険なパターンが ripgrep では猛烈に速いのに、Node.js ではプロセスをハングさせうる、という謎です。あなたのエンジンがどちらの種類かで、そもそもどの落とし穴が自分に当てはまるかが決まります。 この記事が扱うのは JavaScript のバックトラッキングエンジンです。

直したい文字列

この記事はひとつの標的に狙いを定めます。現実にありそうなもの——隣り合う二つのリンクです。

<a href="/products/12">Boots</a> <a href="/products/34">Hat</a>

目標は控えめです。二つの href の値を両方とも取り出す、つまり /products/12/products/34 です。一発で決められそうなほど単純なのに、これが先ほどのエンジンの癖を一つ残らず引き出してくれます。手を付ける前に一つだけ覚えておいてください。正規表現をデバッグする第一歩は、パターンを変えることでは決してなく、今それが実際にどこにマッチしているかを見ることです。パターンとフラグとテスト用の文字列を一緒に置き、マッチした各範囲を元のテキスト上にそのままハイライトさせる——それがまさに正規表現テスターの役目です。抽象的なパターンを、目で読める色付きのテキストに変えてくれます。以下の各節では、その節のパターンを貼り付けて、実際にどの範囲にマッチしたかを自分の目で確かめられます。

貪欲:マッチし損ねたのではなく、バックトラックが遅すぎた

ほとんど誰もが一度は書く第一版がこれです。

<a href="(.*)">

(.*)/products/12 を囲むと期待するでしょう。ところが実際に囲むのは——

/products/12">Boots</a> <a href="/products/34

最初の href=" の直後から、最後の "> の手前まで一気に食べます。エンジンのモデルで見れば、これは少しも不思議ではありません。.* はここから行末までのすべてを貪欲に一口で飲み込み、そのあとエンジンはパターンにまだ "> が一つ残っていることに気づいて、バックトラックを始めます——右端から左へ、一文字ずつ吐き戻していくのです。最後の "> まで戻ったところでパターンが満たされて成功を宣言するので、そこで止まり、それ以上は何も返しません。「そんなに食べたかった」のではなく、「バックトラックが遅すぎて、最初に閉じられる場所に届くのが遅れた」のです。

直し方は、量指定子に ? を付けて、貪欲から遅延(非貪欲)へ裏返すことです。

<a href="(.*?)">

.*? は既定の選択を逆にします——各ステップでまず「食べない」を選び、次のステップがそうしないと進めないときにだけ、しぶしぶ一文字飲み込みます。だから空から始めて少しずつ広がり、最初の "> で止まって、(.*?) はちょうど /products/12 を囲みます。この二つのパターンを順に正規表現テスターへ貼り付ければ、ハイライトが一気に縮むのが見えます——貪欲と遅延の違いを、これ以上ないほど直に見られる瞬間です。

とはいえ、いちばん切れ味のよい書き方は貪欲でも遅延でもなく、そもそもエンジンにバックトラックの余地を与えないものです。

<a href="([^"]*)">

[^"]* は「二重引用符以外の任意の文字」という意味です。閉じ引用符にそもそも届かないので、その手前で自然に止まります——食べ過ぎて返す必要も、バックトラックも一切ありません。これは速いだけでなく、より安全です。そして、早めに知っておく価値のある、JavaScript の動かしがたい制約があります。エンジンによっては、アトミックグループ (?>...)独占的量指定子(possessive quantifier) a++ を備えていて、「これを食べたら二度と返すな」とエンジンに明示的に伝え、バックトラックを根元から封じられます。JavaScript は今日に至るまで、そのどちらも持っていません。ですから JS では、バックトラックを制御する唯一の手立てが、まさにこれ——曖昧さのない形にパターンを書き直すこと、.*? に頼らず否定文字クラスで境界を打ち込むことなのです。これを覚えておいてください。破滅的バックトラッキングの節で、これが処方箋になります。

フラグと状態:パターンは正しい、狂っているのはエンジンのスイッチと記憶

<a href="([^"]*)"> に移ったのに、最初のリンクしかマッチせず、二つ目の /products/34 がどうしても出てこない、としましょう。パターンに問題はありません——問題はフラグです。既定でオフ、付け忘れても文句を言わず、こっそり半分の結果を手渡してくる、あのスイッチ群です。

  • g(global)——replace にすべてのヒットを処理させ、matchAll の前提にもなります。String.prototype.match() の戻り値も変わります。g なしなら最初のマッチとそのキャプチャグループが、g ありならすべての完全マッチが返ります。各ヒットとそのグループを確実に反復するなら、matchAll がいちばん明快です。先ほど最初の一つしかマッチしなかったのは、g がオフだったからです。
  • i(ignore case)——/hat/Hat にマッチしません。i をオンにすれば HATHathat はすべて同等です。
  • m(multiline)——^$ の意味だけを変えます。次節で。
  • s(dotall)——既定では .改行にマッチしません。パターンに .* があって内容が行をまたぐと、. は改行で止まります。s をオンにすれば、改行もマッチ対象として扱います。

ここまでは常識です。ですが g にはもっと深い罠があります。g(あるいは y)付きの正規表現は状態を持つのです。同じ RegExp オブジェクトが lastIndex——前回のマッチが終わった位置——を覚えていて、次回はそこから続けます。同じオブジェクトを .test().exec() に繰り返し渡すと、一見あり得ない結果になります。

const re = /href/g;
re.test('href');  // true —— lastIndex が 4 まで前進
re.test('href');  // false —— 位置 4 から探すので、もう何もない
re.test('href');  // true —— 末尾に到達、lastIndex が 0 に戻り、また最初から

同じ文字列、同じ正規表現なのに、testtruefalse のあいだで裏返り続けます。犯人は、こっそり書き換えられたあの lastIndex です。g 付きの正規表現を使い回すこと自体は間違いではありません——exec() で意図的にマッチを一つずつたどるときは、まさにそうします——けれど、位置を続ける必要のない .test() 判定の前には、lastIndex = 0 にリセットするか、新しい正規表現を作るべきです。とりわけ、モジュールレベルの定数に格上げして、あちこちで test するのは避けてください。各マッチとそのグループを読みたいなら、String.prototype.matchAll() を優先しましょう。元の正規表現からイテレータを派生させるので、そのオブジェクトの lastIndex には手を触れません。ひとつ余談を。当サイトの正規表現テスターは、見やすさのためにマッチ一覧にすべてのヒットを列挙します。一方で置換プレビューは、あなたが g をオンにしているかどうかに従って実行されます。

アンカーと境界:マッチするのは位置であって、文字ではない

アンカーはバックトラッキングモデルの中の特別な存在です——ゼロ幅で、文字そのものではなく、文字と文字のあいだの「継ぎ目」にマッチします。ここがいちばん直観に反するところです。

^$ は既定で文字列全体の先頭と末尾にアンカーされ、各行ではありません。この複数行のテキストを見てください。

error: disk full
error: timeout

^error: と書いて各行の先頭にマッチさせたいのに、既定では ^ は文字列全体のいちばん先頭という一つの位置しか認めないので、最初の行しかヒットしません。^$各行の先頭・末尾で効かせるには、m(multiline)をオンにします。この二つはほとんど常にセットで、m の付け忘れが「なぜ最初の行しかマッチしないのか」の第一の原因です。

\b(単語境界)も、よく誤読されるゼロ幅アサーションです。「単語文字と非単語文字のあいだ」の継ぎ目にマッチします。\bcat\ba cat satcat を囲みますが、categorycat にはマッチしません——cat の直後に e が続いていて、そこに境界がないからです。多くの人が \b を「空白」と捉えますが、句読点や行頭・行末も境界に数えられます。そして、もっと深い罠があります。\b は「何を単語文字とみなすか」を ASCII の定義([A-Za-z0-9_])で判断します——u をオンにしていても、です。だから日本語や、アクセント付きのラテン文字に対して、\b の挙動はあなたの直観とずれます——漢字はその目には「単語文字」ではないので、境界が誤った場所に落ちるのです。

エスケープ:一つのドット、二つの世界

正規表現では . + * ? ( ) [ ] { } ^ $ | \ はすべて特別な意味を持ち、それらそのものにマッチさせるには \ でエスケープしなければなりません。いちばんよくある犠牲者はドットです。バージョン番号の . にマッチさせたくて \d+\.\d+ と書く。横着して \d+.\d+ と書くと、その裸の . は「任意の文字」になり、1X21 21a2 がすべてマッチします——望んだよりずっと緩く、エラーも出さず、いつか汚れたデータが通った日に、マッチすべきでないものにこっそりマッチするのです。

けれど本当に人を苛立たせるのは、エスケープの二つの世界です。テスターの入力欄が受け取るのは生の正規表現で、バックスラッシュ一つは \ 一つです。ところがコード中の正規表現は文字列の中に書かれることも多く、その文字列自身も \ をエスケープに使うので、同じパターンが二つの世界で違う書き方になります。

// 正規表現リテラルなら、バックスラッシュ一つで足ります:
/\d+\.\d+/

// でも文字列から作るときは、バックスラッシュが倍になります——文字列が先に一層食べるからです:
new RegExp('\\d+\\.\\d+')

「テスターではちゃんと動くのに、コードに入れた途端に効かなくなる」の十中八九は、この一層のバックスラッシュがそろっていないことです——JSON やログのフィールドからコピーしてきた \\d は、テスターに貼るときは実は \d であるべきで、コードの文字列に戻すときにはまた倍にするのです。正規表現テスターのエクスポート機能は、この違いを代わりに処理して、対象言語でそのまま貼れる形を出してくれるので、手作業でバックスラッシュを数える手間が省けます。

さらに進んだ場面もあります。変数から正規表現を組み立てるとき——たとえばユーザー入力をマッチ対象の文字列リテラルとして扱うとき——その入力の中の .(? はどれもメタ文字として読まれ、よくてマッチ違い、悪くすると外部入力にマッチ処理を書き換えられてしまいます。RegExp.escape() は ES2025 で標準化され、文字列を安全にリテラルマッチ用の断片へ変えてくれます。現代のブラウザは対応済みですが、より古いブラウザやランタイムに対応する必要があるなら、まず対象環境を確認し、必要ならメンテナンスされている polyfill を使ってください。正規表現を動的に組み立てるときは、変数部分を必ず先にエスケープすること——外部の文字列をそのままパターンに落とし込んではいけません。

先読みと後読み:条件を確かめるだけで、文字を消費しない

ここまで来ると、もっと繊細なことがしたくなるかもしれません。href の値を、周りの引用符を付けずに取り出す、というように。先読みと後読みはまさにこのためにあり、そして、ほとんど誰もが引っかかる思い込みがあります。

エンジンのモデルに戻りましょう。先読み (?=...) はエンジンに「前方を覗き見」させ、続く部分が何かのパターンに合うかを確かめます——けれど覗いたあと、現在位置は前へ進みません。これはゼロ幅です——条件が成り立つかどうかは成否に影響しますが、確かめられた文字はマッチ結果には数えられません。だから、

\d+(?=px)

12px に対して 12 にマッチし、px を含みませんmatch[0] を出力して中に px がなくても、それはバグではなく、先読みの定義そのものです。同様に、否定先読み (?!...)、後読み (?<=...)、否定後読み (?<!...) もすべてゼロ幅で——文脈を枠取りするだけで、消費しません。

この「文字を占有しない」性質こそが、それらの価値です。引用符のあいだの値を、引用符を付けずに正確に抜き出すには、こう書けます。

(?<=href=")[^"]*(?=")

後読みが「私の前は href=" だ」と主張し、先読みが「私の後ろは " だ」と主張し、あいだの [^"]* だけが実際に消費されて返される部分です——引用符は条件でしかなく、結果には入らないので、あとから両端を slice する手間が省けます。正規表現テスターに貼り付ければ、はっきり見えます。条件の領域はハイライトされず、実際に消費された範囲だけが色付く——「ゼロ幅」という抽象語が、一目で具体になります。(JavaScript ならではの利点をひとつ。その後読みは可変長のパターンに対応していて、固定長の後読みしか許さないエンジンもある中、(?<=\w+=") のような書き方が JS では有効です。)

Unicode の三つの層:ASCII、コードポイント、書記素クラスタ

テキストに日本語や絵文字、あるいは非 ASCII の文字が含まれた途端、まるまる一層の落とし穴が待っています——しかも、たいていの人が思うより深い。「Unicode 対応」は実は三つの層に分かれていて、そのどれもがあなたに噛みついてきます。

第一の層。\d\w が非 ASCII を分かっている、という前提を捨てましょう。 JavaScript では、\d は既定で ASCII の 0-9 だけに、\w[A-Za-z0-9_] だけにマッチするので、\w+ は漢字一文字も囲めません。Unicode のカテゴリでマッチさせるには、u フラグをオンにして、\p{...} プロパティエスケープに切り替えます。\p{L} は任意の言語の文字、\p{N} は数字、\p{Han} は漢字専用で、/\p{L}+/u でようやく日本語の語にマッチできます。ここに極めて直観に反する細部があります。u をオンにしても、\d\w\b が Unicode を理解するようにはなりません——依然として ASCII の意味のままです。全角やアラビア・インド数字を含む「任意の数字」にマッチさせたいなら、u\d を格上げしてくれるのを当てにせず、\p{Nd} と書く必要があります。

第二の層。u はエンジンを、UTF-16 コード単位ではなくコードポイント単位で歩かせます。 u なしでは、二つのコード単位を占める文字(多くの絵文字など)が . に真っ二つに切られ、切り出したものは文字化けになります。u ありなら、. は完全なコードポイントを扱い、\u{1F600} のような表記もようやく認められます。

第三の層、そして最も見落とされやすいもの。コードポイントは依然として「一文字」と同じではありません。 人の目に一文字と映るものは、専門的には書記素クラスタ(grapheme cluster)で、複数のコードポイントから組み立てられうるものです——肌の色付きの絵文字、国旗、👨‍👩‍👧 のようにゼロ幅接合子で貼り合わせた家族の絵文字は、どれも複数コードポイントの組み合わせです。u をオンにしても、. がマッチするのはそのうち一つのコードポイントだけなので、絵文字を切り刻んでしまうことは依然としてありえます。「人が見るとおりの文字」として扱うには、二つの道があります。ES2024 の v フラグ(unicodeSets)は集合演算と、複合絵文字をひとまとまりでマッチできる \p{RGI_Emoji} のような文字列プロパティをもたらします。そして汎用の書記素分割には Intl.Segmenter を使います——これは正規表現ではありませんが、人間の認識に沿ってテキストを切り分けるにはこちらのほうが向いています。当サイトのテスターは現在 u を提供していますが、v の切り替えはまだ提供していません。v が必要なときは、対象環境で確認してください。絵文字や多言語のテキストを扱うときは、自分が欲しいのはどの層なのかを先に決めること——それだけで「半分の文字にマッチした」という類いの問題をまとめて避けられます。

正しくマッチするのに、ページを固める:破滅的バックトラッキング

最後の「おかしい」は、マッチ違いではなく——実行した途端に固まることです。タブが応答しなくなり、CPU が張り付く。これが破滅的バックトラッキング(catastrophic backtracking)で、ReDoS サービス拒否攻撃の仕組みでもあります。先ほどのエンジンのモデルがあれば、これを第一原理から理解できます。

典型的な形が入れ子の量指定子です。

(a+)+$

これを aaaaaaaaaaX(末尾がわざと望みどおりでない)に対して走らせます。内側の a+ と外側の + が同じ文字の並びにマッチできるので、この a の並びには指数的に多くのグループ分けの仕方があります——(a)(a)(a)…(aa)(a)…(a)(aa)…、といった具合に。そして末尾の $X の前では決して成り立たないので、エンジンは失敗を宣言する前に、あらゆる分け方をバックトラックで試し尽くさざるをえません。入力が少し長くなるだけで、マッチ失敗までの時間は急激に伸びます。どれくらいかかるかはブラウザ、ハードウェア、エンジンの実装次第なので、固定の文字数で断言することはできません。犯人の人相は決まっています——一つの量指定子がもう一つを包み、両者が同じ文字の並びにマッチできるもの——(a+)+(.*)*(\d+)* はどれもこの一族です。(.*?)* のように遅延を貪欲に入れ子にしたものも、同じくらい危険です。

これこそ、先ほどの伏線の回収です。貪欲のところで、なぜ .*? より [^"]* を使えと勧めたのか。否定文字クラスは境界を先に引くので、あらゆる位置のあいだを行きつ戻りつ探る必要がありません。JavaScript には現在、バックトラックを直接抑える二つの構文——アトミックグループと独占的量指定子——がないので、第一の手は曖昧さをなくすようパターンを書き直すことです。各分岐のマッチ範囲が重ならないようにし、適切な位置にアンカーを足し、境界のない .* を明示的な文字クラスに置き換える。当サイトの正規表現テスターは、潜在的な ReDoS リスクを非同期に検査します。リスクを検出したときは攻撃サンプルを表示し、判定を完了できないときはその旨をはっきり示します。問題を早く見つけるのには向いていますが、実際の入力規模での性能テストの代わりにはなりません。

そのまま使える点検手順

次に正規表現が言うことを聞かないときは、あわてて文字を積み増さないでください。エンジンの視点に戻り、この順で辿れば、ほぼ毎回、問題を突き止められます。

  1. まず、今どこにマッチしているかを見る。 パターンとテキストを正規表現テスターに貼り付けてハイライトを見る。当てずっぽうはやめる。十中八九、ここで問題が姿を現します。
  2. 食べ過ぎ? 貪欲な .* はバックトラックが遅すぎる——.*? に、もっとよいのは否定文字クラス [^…]* に変えましょう。
  3. 最初の一つ/最初の行しかマッチしない? たいていは gm の付け忘れです。
  4. 同じコードなのに、効いたり効かなかったり? g 付きの正規表現を使い回して lastIndex が悪さをしていないか確認しましょう。
  5. . ( [ がリテラルにマッチしない? 必要なものを \ でエスケープし、バックスラッシュが文字列の層で食べられたり倍になったりしていないか確かめましょう。
  6. 結果が思ったより多い/少ない? ゼロ幅の先読み・後読みを、ふつうのグループと取り違えていないか考えましょう。
  7. 日本語や絵文字がマッチしない、あるいは切り刻まれる? ASCII・コードポイント・書記素クラスタの三層を切り分ける——u をオンにし、\p{...} を使い、必要なら v フラグや Intl.Segmenter を持ち出しましょう。
  8. 実行した途端に固まる? 入れ子の量指定子を探す——それが破滅的バックトラッキングで、JS ではパターンの書き直しで解決します。

正規表現が正しく書けたところから、たいてい仕事が始まります。マッチしたものを別の形へ一括で置換するのは、テキスト置換ツールが正規表現のキャプチャグループで行う仕事です。二つのバージョンのテキストのあいだで何が変わったかを確かめるのは、テキスト比較ツールの仕事です。けれど、どの一歩も同じ場所から始まります——どこにマッチし、なぜその一歩を踏むのかがはっきり見える正規表現から。ただ睨んで当てずっぽうするしかない正規表現からではなく。