【正規表現】先読みと後読みを理解する – iimon TECH BLOG

はじめに

こんにちは。

皆さんは正規表現、使っていますか。

正規表現には様々な記法があり、マッチパターンの過不足を防ぐにはどのような形が適切なのか考えるのはなかなか大変ですよね。

複雑な正規表現になると、読み解くのも一苦労です。

今回はそんな正規表現の一記法であり、より柔軟で正確なチェックを可能にする「先読み」と「後読み」について、私自身理解がかなり曖昧な状態でしたのでまとめました。

正規表現における先読みと後読みとは、簡単に説明してしまえば「マッチさせたい文字列の前後に特定のパターンが存在するかをチェックし、一致有無を判定」するというものです。

先読みは、対象文字列の後ろに指定したパターンが続くかをチェックし、後読みは対象文字列の前に指定したパターンが存在するかをチェックします。

また、それぞれに肯定的◯◯(前後に指定パターンが存在するパターン)と否定的◯◯(前後に指定パターンが存在しないパターン)という二つの判定方法があります。

全体として、以下4種類のパターンに大別することができます。

  • 先読みと後読み種別
名称 構文 説明
肯定的先読み A(?=B) Aの後ろにBが続く場合にAに一致
否定的先読み A(?!B) Aの後ろにBが続かない場合にAに一致
肯定的後読み (? Aの前にBがある場合にAに一致
否定的後読み (? Aの前にBがない場合にAに一致

文字だけではイメージがなかなか付きづらいと思いますので、一つずつ実例とともに見ていきます。

肯定的先読み:A(?=B)

これはAに続いてBのパターンが続く場合にマッチさせる方法です。
例えば、拡張子がjpgのファイルだけをマッチさせたいとしましょう。
この場合、肯定的先読みを用いて以下のように記述します。

const regex = /\w+(?=\.jpg)/

これに、’image.jpg’と’image.png‘の2つを適用してみましょう。

console.log('image.jpg'.match(regex))  -> ['image']
console.log('image.png'.match(regex))  -> null -- image以下の拡張子文字列が.jpgで続かないため、マッチせず

このように、Aに続いてBのパターンが続く場合のみマッチさせるのが肯定的先読みです。
また、上記のmatchを使った例で分かるように、先読みパターン部分(?=.jpg)はマッチ条件に使われるだけで、結果としては取得されません。
(image.jpgのimageだけ抜き取る)

Aに続くパターンBなのでイメージ的にはAの後という感覚があり、先読みという表現には当初困惑させられました(後述)。

否定的先読み:A(?!B)

肯定的先読みがAのあとにBが続くケースを正とするのに対し、否定的先読みはAのあとにBのパターンが続かない場合にマッチさせる表現です。

// 洗濯機にはマッチさせたいが洗濯機置き場にはマッチさせたくない場合
console.log('洗濯機'.match(/洗濯機(?!置き場)/))  // ['洗濯機']
console.log('洗濯機置き場'.match(/洗濯機(?!置き場)/))  // null

肯定的先読みの考え方が分かれば、先読み部分の有無を反転させるだけなので簡単ですね。

肯定的後読み:(?

後読みは先読みの逆で、Aの直前にBがある場合にマッチします。

const regex = /(?\$)\d+/
console.log('$777'.match(regex))  -> ['777']
console.log('115000円'.match(regex))  -> null --数字の直前に$がつかないのでマッチしない

例によって、パターン部分はマッチ条件に使われるだけで取得はされません。

否定的後読み:(?

Aの直前にBがない場合にマッチします。


console.log(('乾燥機').match(/(?浴室)乾燥機/)) -> ['乾燥機']
console.log(('ドラム式乾燥機').match(/(?浴室)乾燥機/)) -> ['乾燥機']
console.log(('浴室乾燥機').match(/(?浴室)乾燥機/)) -> null

なんとなく、後読みについては後読みというより前読みという方がイメージがつきやすいなと思ってしまいます。。

という疑問に、MDNで以下の解説がなされており、腑に落ちました。

正規表現は一般に左から右に照合します。これが先読みおよび後読みアサーションがこのように呼ばれる理由です。先読みアサーションは右にあるもの、後読みアサーションは左にあるものを表します。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Regular_expressions/Lookbehind_assertion#%E8%A7%A3%E8%AA%AC

さて、ざっと以上が先読みと後読みの記法ですが、これが使えると何が嬉しいのか、少し詳しく見ていきます。

ゼロ幅アサーション

基本的な正規表現が文字列そのものにマッチするのに対し、位置にマッチするゼロ幅アサーションというものがあります。

先読み後読みはこのゼロ幅アサーションに分類されます。

例えば、行の先頭および末尾を表す^$は、先頭と末尾という位置にマッチするため、ゼロ幅アサーションにあたります。

また、単語の境界(スペース等)とマッチする\bも同様です。

これらは総称してアンカーとも呼ばれます。

/\bcat\b/'the cat sat' -> マッチ
・'application' -> 非マッチ
・'category' -> 非マッチ

これら位置にマッチするゼロ幅アサーションは、「文字列(マッチ)を消費しない」と表現されることもあります。

これは、正規表現がマッチした際、現在位置を示すカーソルが前に進まないことを意味します。

ざっくりとした説明にはなってしまいますが、’HELLO’という文字列から’L’という文字をマッチさせたい場合を考えてみましょう。

(イメージ)

'HELLO'を[H][E][L][L][O]という5つのタイルに分割します。エンジンは初めのタイル[H]に足を踏み入れ、[L]のタイルの行き当たるまで一つ一つタイルを進んでいきます。

この、タイルに足を踏み入れる行為が「文字(マッチ)を消費する」ということになります。

これに対しゼロ幅とは、タイルの上には立ち入らず、タイルの隙間に立って前後のタイルを確認するというイメージの挙動を示します。

例えば「直後にLが続くL」を探索する先読み表現/L(?=L)/を使った例を考えてみます。

まず、正規表現エンジンは一つ目のLが書かれたタイルを見つけて、そのLタイルの右横にカーソル(現在位置)が移動します。

次に、エンジンは現在のLタイルに乗ったまま、足を動かさずに隣のタイルを覗き見ます((?=L)の部分)。Lの右隣のタイルは期待通りのLなので、マッチが成功します。この時、?=Lの部分はあくまで隣の文字を確認しただけで、そのタイルには移動しません。

これが、余計なマッチを消費しないという考え方です。

バックトラック

更にこれを踏まえて、バックトラックについて確認していきます。

バックトラックとは、あるパターンマッチが失敗した際に、正規表現エンジンが一つずつ後戻りして別のマッチ可能性を試していく動作を示します。

例えば、/a.*c/'abcabc'に適用した場合、正規表現は最初のaにマッチしますが、次の.*(任意の文字が0回以上)でa以降の全ての文字(’bcabc’)を一気に取得してしまいます。

(これを欲張りあるいは貪欲(greedy)な動作と表現することもあります。?(直前のパターンの0~1回繰り返し)や*(直前のパターンの0回以上繰り返し)、+(直前のパターンの1回以上繰り返し)がこれに当たります。使う時はバックトラックを念頭に置いて少々注意する必要があるかもしれません)。

続いて最後のcをマッチしようと試みますが、前段の.*で対象文字列は全て取得(消費)されてしまっているため、比較対象が存在せずにマッチ失敗します。

ここでバックトラックが発生し、.*で取得した文字を一つ手放します。つまり、取得したbcabcから最後のcを除去してbcabとし、再度cで探索をかけ残ったcにようやくマッチするということになります。

この例ではまだ手戻りが少ないですが、複雑な正規表現でバックトラックによる手戻りが長大なものになる場合、その分探索にかかるコスト、負荷も大きくなり、パフォーマンスの低下に繋がりかねないという面があります。

先読み後読みによるバックトラックの軽減

このバックトラックの特性を踏まえて、先読み後読みを使ったバックトラックの軽減というメリットについて見ていきます。

例えばid="12345"という文字列から数字部分だけを抽出したい場合を考えてみます。

先読み後読みを使わずに/id="(\d+)"/でマッチさせようとすると、例えばid="12345'のように末尾が"で終わらない文字列を比較対象としてしまった場合、\d+で貪欲にマッチし、次の”がしないことは確実なのに、マッチした数字を消して一つずつ手戻りチェックする無駄な手間が発生します(そして結果的にこのマッチは失敗に終わります)。

これに対し、先読みと後読み表現を用いて(?とした場合、\d+のマッチを試みる前段階で、マッチの消費をせずに「id="..."」の形式であるかどうかのチェックが担保されるため、最低限の探索で済みます。

おわりに

以上、正規表現における先読み後読みの概略について確認してきました。
記法と使い方自体は覚えてしまえばさほど複雑なものではありませんが、効率化等を絡めた観点も含めて考えると、かなり奥深い概念であることが分かります。

また、今回触れた内容も含め、ある種単純な領域である正規表現はAIに任せてしまえば瞬時に妥当な内容を構築してくれますが、

人間側もしっかりと正規表現の文法や内部挙動を理解した上で正確に記述・理解・指摘をできる状態にしておくことは重要であると認識しています。

拙文ながらここまで読んでいただき、ありがとうございます。

弊社ではエンジニアを募集しております。少しでもご興味がありましたら、ぜひカジュアル面談でお話ししましょう!

iimon採用サイト / Wantedly / Green

参考記事

正規表現 – JavaScript | MDN

【決定版】中級猫でもわかる正規表現再入門 – KAYAC Engineers’ Blog

正規表現のパフォーマンスの話をされても全くピンと来なかった僕は、backtrackに出会いました。 #Ruby – Qiita

JMP Help

正規表現サンプル集

正規表現の先読み・後読み




元の記事を確認する

関連記事