DevKitLab Logo DevKitLab
正規表現 / 先読み / JavaScript

消費せずにマッチさせるには?正規表現の先読みと後読み

マッチはしたのに match[0] から欲しかった部分が抜けている——あるいはパスワードチェックで三つの条件を一度にかけたい。どちらも行き着く先は一つの考えです。先読みと後読みは、テキストを検査するけれど消費しない。ゼロ幅アサーションが実際にどう働くか、そしてそれが簡単にしてくれるいくつかの仕事を解説します。

はじめてだと、ほぼ誰もが引っかかる小さな謎があります。px の直前にある数字が欲しくて \d+(?=px) と書き、12px に適用すると、12 にマッチする。よし。ところが返ってきたものをよく見ると——12 だけ。パターンに自分で書き込んだ px は、結果のどこにもいません。消費されず、キャプチャもされず、ただ消えている。予期していなければ、正規表現が自分の書いたパターンの一部をこっそり食べてしまったように感じます。

食べていません。(?=px)先読みで、先読みは正規表現の小さな一族——アサーション——に属します。テキストを検査するけれど、取り込まない。この一つの考え、「調べるが取らない」が、すべての勘どころです。これが腑に落ちると、先読みと後読みは Stack Overflow から貼り付けてくる小難しい構文であることをやめ、道具箱でもっとも切れる二本になります——値を、周りの記号を付けずに抜き出す。ある正確な位置に、原文を乱さず文字を差し込む。そして複数の独立した規則を一度にかける——これは複数のパスワード要件を一つのパターンに収める、よくあるやり方です。

これはシリーズ第三回です。第一回はバックトラッキングエンジンがテキストをどう歩くかを、頭の中のモデルとして組み立て、第二回はそのモデルで、なぜ一部のパターンが CPU を焼き切るのかを説明しました。ここでも同じモデルに寄りかかります。だから「エンジンはカーソルを持って左から右へ歩く」がもう腑に落ちているなら、この記事はすんなり読めるはずです。

ゼロ幅:すべてを解く、たった一つの考え

第一回のようにエンジンを思い描いてください。カーソルが文字と文字のあいだに座り、左から右へ進んでいく。正規表現のほとんどの部品は消費します——一つ以上の文字にマッチし、カーソルをその先へ引きずっていく。\d+12px に適用すると 1、次に 2 にマッチし、カーソルを p の直前に置く。この二文字はもう「使い切った」。パターンの残りは、そこから引き継いで進みます。

アサーションは、根本的に違うことをします。カーソル周りのテキストを見て、通るか通らないかを決め、そしてカーソルをぴたりと元の場所に残す。マッチするのは位置であって文字ではない——だから専門用語でこれをゼロ幅と呼びます。(?=px) はカーソルの位置で「次の二文字は px か?」という Yes/No を問い、答えが Yes なら、一歩も進まないまま成功する。あの px は一目検査され、そのまま返された。消費されていないので、match[0] には決して入りません。消えたはずの幽霊文字は、もともと失われてなどいなかった。ただ、取られなかっただけなのです。

安心できる話を一つ。あなたはほぼ間違いなく、この語彙を知らないまま何年もゼロ幅アサーションを使ってきています。^$ は文字にマッチしません——「文字列の先頭/末尾の位置」にマッチします。\b は「単語文字と非単語文字のあいだの継ぎ目」にマッチします。先読みと後読みは、その同じ手品の扉を開け放ったものです。エンジンが備え付けで持つわずかな位置の代わりに、条件の中に任意の部分パターンを入れてよい。(?=px) は、好きなものを何でも調べられるほどに育った \b にすぎません。

四つのアサーション

ちょうど四つ、二つの軸で分かれます。を見る(カーソルの後ろに続くもの)か後ろを見る(カーソルの前にあるもの)か。そしてそれが在る(肯定)か無い(否定)かを断言する。

アサーション名前通るのは…
(?=...)肯定先読みすぐ後ろが ...マッチするとき
(?!...)否定先読みすぐ後ろが ...マッチしないとき
(?<=...)肯定後読みすぐ前が ...マッチするとき
(?<!...)否定後読みすぐ前が ...マッチしないとき

四つそれぞれの具体例、すべてゼロ幅です——どの場合も、断言された側はマッチの外に留まることに注目してください:

\d+(?=px)        "12px"      → 12 にマッチ    ("px" が後ろに続く数字の並び)
foo(?!bar)       "foobaz"    → foo にマッチ   (後ろが "bar" でない "foo";"foobar" では失敗)
(?<=\$)\d+       "$100"      → 100 にマッチ   (前が "$" の数字)
(?<!\$)\d+       "€100"      → 100 にマッチ   (前が "$" でない数字)

否定の対は二度読む価値があります。「そのものが無いときにマッチする」は取り違えやすいからです。foo(?!bar) は「bar でない何かが後ろに続く foo」という意味ではありません——「foo、そしてこの位置で bar が始まらない」という意味です。foobar では即座に失敗し、文字列末尾の foo ではむしろ成功します。そこには bar になりうるものが何も無いからです。「無い」には「そもそも何も無い」も含まれます。

これらを抽象でなくしたいなら、一つずつ正規表現テスターに貼って、ハイライトを見てください。テスターは実際に消費された部分だけに色を付けます——だからすぐ隣のアサーション部分は色が付かないまま残る。この対比——条件がそこに色なしで居座り、本当のマッチだけが灯る——が、「ゼロ幅」について得られるいちばん明快な一枚の絵です。

ゼロ幅を働かせる

「調べるが取らない」から、日常的な使い道が三つ出てきます——matchreplacesplit に一つずつ——そしてこの三つこそ、先読みと後読みを学ぶ価値がある理由です。

区切り記号を付けずに値を返す

<a href="/products/12"> からリンク先を抜き出すとしましょう。いちばん素直なパターンはキャプチャグループです:

href="([^"]*)"

これで動きますが、マッチ全体に href=" と閉じ " が入り、本当に欲しい値は 1 番目のグループに埋まります——手を伸ばして match[1] を読む必要がある。先読みと後読みなら、マッチ全体をちょうど値そのものにできます:

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

左から右へ読みます。後読みは「私のすぐ前に href=" がある」と断言し、先読みは「私のすぐ後ろに " がある」と断言し、真ん中で唯一消費する部分 [^"]* が値そのものを掴む。引用符は条件であって中身ではないので、マッチには決して入りません。match[0]/products/12 として、きれいに、後から切り出す手間なく返ってくる。(これは第一回が題材に使ったのと同じ二つのリンクの断片です——あそこではキャプチャグループで解きました。先読み・後読みは、マッチそのものが答えになる版です。)

その場所を乱さずに差し込む

ここがゼロ幅の、ほとんど魔法じみてくるところです。定番の課題:12345671,234,567 にする。どの数字も置き換えたくはない——数字と数字の隙間にカンマを滑り込ませたい。そしてゼロ幅マッチとは、まさに一つの隙間です:

"1234567".replace(/\B(?=(\d{3})+(?!\d))/g, ",")   // → "1,234,567"

ここでは一文字も消費されません。(?=(\d{3})+(?!\d)) は、「右側にちょうど整数個の三桁グループがあり、その後ろに余分な数字がない」位置——つまり桁区切りカンマがあるべき場所ちょうど——にマッチします。\B(非境界、\b の反対)が、カンマが先頭に落ちるのを防ぎます。どのマッチも空なので、replace は何も取り除かず、マッチした各隙間に , を落とすだけ。このパターンを正規表現テスターに貼ってみてください——ゼロ幅マッチには色を塗るものがないので、証拠はハイライトではなくマッチ一覧のほうに現れます。各ヒットはその位置に、長さ 0 として並びます。文字ではなく、文字と文字の隙間を占めるマッチです。その「長さ 0」が二つの数字のあいだに座っているのを見ることが、「位置にマッチする」を、一つの言い回しから、指させる何かへと変えてくれます。

区切りを食べずに分割する

split はふつう、分割の対象そのものを消します——"a-b-c".split("-") はハイフンを捨てる。でもゼロ幅マッチで分割すれば、消すものが何もないので、その境界は残ったままになる。こうして camelCase を、一文字も失わずに単語へ割れます:

"camelCase".split(/(?=[A-Z])/)   // → ["camel", "Case"]

(?=[A-Z]) は各大文字のすぐ手前の空の位置にマッチします。split はそこで切りますが、マッチが何も消費していないので、大文字は後ろのかたまりに付いていく。同じゼロ幅の考え、三つめの文字列メソッド——match は値を抜き、replace は一つ差し込み、split はそのあいだを切った。三つとも、触れてはいけない文字には一つも触れていません。

決め技:先読みを積み重ねて「かつ」を作る

さて、これ一つのために先読みを学ぶ価値がある、という使い方です。パスワード規則が欲しいとします:少なくとも八文字、そして数字・小文字・大文字を一つずつ含むこと——順序は問わない。「どこかに数字かつどこかに小文字かつどこかに大文字が、どんな並びでもいい」を、ふつうの左から右へのパターンで表そうとすると溺れます。一回のパスは何らかの順序に決め打ちするしかないからです。先読みはこの難題を溶かします:

^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$

仕組みは一度見れば美しく、そして純粋にゼロ幅です。^ の後、カーソルは位置 0 にいます。(?=.*\d) が前方を走査し——.* が末尾まで走り、それから数字が文字列のどこかに見つかるまでバックトラックする——成功すると、何も消費していないのでカーソルを 0 に戻します。次に (?=.*[a-z]) が、また位置 0 から走る。次に (?=.*[A-Z]) が、また 0 から。どの先読みも文字列全体に対する独立した Yes/No のテストで、どれもカーソルを動かさないので、論理的なかつとして積み上がります:「数字が在り、かつ小文字が在り、かつ大文字が在る」。三つすべてが通って初めて、.{8,}$ がようやく消費し——長さを課し、末尾まで走ります。

ここから、これを果てしなく実用的にする二つのことが落ちてきます。順序は問いません——先読みはどれも同じ地点からテストするので、条件はどんな順で並べてもいい。そして規則の追加や削除は、先読みを一つ足すか外すかだけ:(?!.*\s) で空白を禁じ、(?=.*[!@#$%^&*]) で記号を要求する。各節は文字列全体についての、それ自体で完結したアサーションで、独立にパチンと留めていける。

では、これは実際いつ割に合うのか?正直なところ、アプリケーションのコードでは、三つに分けた .test() のほうがたいてい読みやすく、そちらに手を伸ばすべきです。先読みの積み重ねという形が本領を発揮するのは、一つのパターンだけが許され、周りにコードを書けない場面です——HTML の <input pattern="…"> 属性、JSON Schema の pattern、設定ファイルの検証ルール、あるいは単一の正規表現しか受け取らないフレームワーク。そこでは「一つの式に複数の独立した条件」は余興ではなく——たいてい、それを言い表せる唯一の手段なのです。

人を噛む注意が一つ:. は既定で改行にマッチしないので、.*\d は最初の改行までしか見えません。入力が複数行にわたりうるうえ、規則を全体にかけたいなら、s(dotall)フラグを足すか、.[\s\S] のように明示的なものに置き換えてください。(そして .{8,} が実際に何を数えるかはフラグ次第です:u なしなら各 . は一つの UTF-16 コード単位——サロゲートペアで組まれた絵文字は二つと数えられます——一方 u ありなら . は一つのコードポイントにマッチし、その絵文字は一つと数えられる。どちらも書記素——ユーザーが目にする一文字——ではないので、絵文字だらけのパスワードは、予想と違う数え方になりえます。第一回で触れたのと同じ Unicode のしわです。)

JavaScript 固有の話と、いくつかの落とし穴

先読みは JavaScript にずっと前からあります。後読みはもっと若く——ES2018 で登場し、しばらくはブラウザ対応で最後まで残った一つでした(Safari が最後の到着)。なので現代のランタイムでは普遍的に使えますが、よほど古いものを支える必要があるなら一目確かめておくといいでしょう。

JavaScript が並外れて気前がいいのは可変長後読みです。後読みは固定長でなければならない、と言い張るエンジンもあります——いちばん出くわしやすいのは Python の re でしょう——後ろ向きに照合するときは、どれだけ戻ればいいか正確に分かっていたほうが単純だからです。JavaScript は後読みを任意の長さにできます:(?<=\w+)(?<=\d{1,3},)(?<=<[a-z]+>) はどれも合法。別の言語で「後読みは固定長パターンが必要」というエラーを踏んで、それが普遍の掟だと思い込んでいたなら、JS はそれをそっと外してくれます。

ただ、知っておく価値のあるしわが一つ:JavaScript は後読みを右から左へ、カーソルから後ろ向きに照合します。たいていは気づきませんが、後読みの内側の量指定子やキャプチャグループは、その逆向きに解決される——なので、あるグループが捕まえるものは、鏡写しの先読みから左から右へ読んで予想するものと違いうる。内側にグループのある後読みが意外な挙動をしたら、たいていこれが理由です。推測せず、テストしてください。(これは第二回の「自分がどのエンジンにいるかが、何ができるかを決める」という点に戻ってきます。)

もう二つ、小さいが知っておく価値のあること:

  • アサーション内部のキャプチャグループもちゃんとキャプチャします。 /(?<=(\d+))px/ は、後読み自体は match[0] に何も寄与しないのに、1 番目のグループに数字を詰めます。便利で、たまに驚かされる。
  • ゼロ幅は重なり合うマッチを解禁します。 ふつうのマッチは消費するので、二つのマッチは決して重なれません。でも本当の仕事を先読みで包めば、どのマッチもゼロ幅になる——そして全体反復(matchAll、あるいは g フラグ)は空マッチのあと一つ先へ進むので、先読みののキャプチャが、重なり合う窓を順に一つ残らず収穫します:/(?=(\d{2}))/g1234 に適用すると、1 番目のグループは 122334 と続く。一度の matchAll から重なり合うマッチを得る、ほかにきれいな手はありません。

一つの注意:アサーションもれっきとした正規表現

先読み・後読みを軽い注釈のように扱いたくなりますが、(?=...)に入れたものは何であれ、ほかのすべてとまったく同じバックトラッキング規則の下で走ります。アサーションそれ自体が指数的リスクを足すわけではありません——パスワード検査のいくつかの .* 先読みは、それぞれが独立した線形の走査で、だからあの種のパターンはおおむね無事です。でも自己重複する繰り返しをアサーションの中に入れた瞬間、あなたは条件の中へ爆弾を持ち込んだことになり、それは表に出ているのと寸分違わず破滅的です。第二回の ReDoS の掟は、先読みの括弧の前で止まりはしません。アサーションだらけのパターンも、ほかのパターンと同じようにテストしてください——テスターの ReDoS チェックは、その中まで覗きます。

好奇心のある人へ、精密な点を一つ。先読みは評価されている最中は、ほかの部分パターンと同じように内部でバックトラックします。でも成功した瞬間、エンジンはそれを決着済みとして扱う:パターンの後ろのどこかが失敗しても、エンジンは別の内部マッチを試すためにその先読みの中へ戻りはせず、その中のグループが捕まえたものも成功の瞬間に凍りつきます。アサーションは、見て、確定して、先へ進む——これがときに、「あなたが得たキャプチャ」と「得られると思い込んでいたキャプチャ」との差になります。

キャプチャグループのほうが正解のとき

先読み・後読みが常に正解とは限らず、反射的にそれへ手を伸ばすこと自体が小さな誤りです。

  • コードで値が欲しいだけなら、キャプチャグループのほうがたいてい明快です。 href="([^"]*)" として match[1] を読むほうが、後読みと先読みのサンドイッチより、次に読む人にとって分かりやすく、しかもどのエンジンでも動きます——後読みがまったく無いものも含めて。先読み・後読みは、match[0] そのものが値でなければならないときのために取っておく——$& を使う replacesplit、あるいはマッチ全体しか渡してくれない工具や API。
  • これらで本物の HTML を解析しないこと。 href の例が成り立つのは、それが小さく統制された文字列だからです。任意のマークアップ——属性の順序は自由、引用符は単一も二重も、散らばった空白、コメント——に正規表現を向ければ、それは静かに取りこぼします。ブラウザでは DOM を読み(element.getAttribute("href"))、サーバーでは本物の HTML パーサを使う。正規表現は、あなたがすでに把握している文字列ののためのもので、文法のためのものではありません。

手元に置ける早見表

  1. ゼロ幅こそが考えのすべて。 アサーションは位置をテストし、カーソルをそのままに残す。中身は検査されるだけで消費されないので、match[0] には決して現れません。
  2. 四つ: (?=) / (?!) は前を見る。(?<=) / (?<!) は後ろを見る。! 付きの二つは、そのものが無いときに通る——そして「無い」には「そこに何も無い」も含まれる。
  3. 区切りを付けずに値を返す: (?<=開き)値(?=閉じ) で、マッチ全体を値そのものにする。
  4. その場所を乱さず手を加える: replace の中のゼロ幅マッチは削らずに挿入し(桁区切りカンマ)、split の中では区切りを食べずに切る(camelCase の分割)——何も消費していないので、何も失われない。
  5. 複数の独立した規則をかける: 先読みを ^ のすぐ後ろに積む。どれも同じ地点からテストし、順序を問わずかつで結合する。
  6. JavaScript は後読みを可変長にできる——ほかのエンジンで知った固定長の制限を当然と思わないこと。
  7. アサーションの中身もやはりバックトラックする——ReDoS の掟を頭に置き、テストすること。

三つの記事、一つの機械。第一回は歩いてバックトラックするカーソルを教え、第二回はそれがバックトラックしすぎたときに何が起きるかを見せました。そして先読み・後読みは、結局のところ、その同じカーソルが踏み出さず見ることを求められているだけなのです。アンカーはずっとそれをやっていた。いまや、あなたはそれを何にでも向けられる——そして見返りは毎回、同じ静かな手品です:何が条件で何が中身かを決め、マッチを、あなたが本当に欲しかったものちょうどに、それだけに、させること。