正規表現の先読みを解説

正規表現の先読みを最近になって理解したので解説してみる。

正規表現の先読み

正規表現の先読みとは、簡単に言うと「それがあることを保証するがマッチはさせない文字列」である。

この、「あることを保証してマッチさせない文字列regexp」はRubyでは「(?=regexp)」と書く。

まずは先読みがない例を挙げる。

str = "hoge1huga2"
str.match(/hoge.+2/)            # => #<MatchData "hoge1huga2">
str.match(/hoge.+3/)            # => nil

これは問題ないと思う。次に先読みがある例だ。

str = "hoge1huga2"
str.match(/hoge(?=.+2)1/)         # => #<MatchData "hoge1">
str.match(/hoge(?=.+3)1/)         # => nil

この1つ目のmatchでは、「.+2」が「あることを保証してマッチさせない文字列」となる。

確かに、文字列strの後ろには「1huga2」という「.+2」にマッチする文字列が存在するが、MatchDataにはそれが反映されていない。

以下のように2段階に分けて考えると、もっとわかりやすいかもしれない。

  • (?=regexp) の後ろを除去した「hoge(?=.+2)」にマッチするかをまず調べる(これはつまり「hoge.+2」とマッチすることを確認する)
  • 上がマッチされることを確認したら (?=regexp) を除去した hoge1 と実際にマッチさせる(上がマッチしなければnilを返す)

いっそのことrubyのプログラムで書いてしまうとこんな感じ。

(str =~ /hoge.+2/) ? str.match(/hoge1/) : nil    # => #<MatchData "hoge1">

これだと、先読み例2つめのmatch(/hoge(?=.+3)1/)では、「hoge.+3」とマッチする文字列がstrに存在しないため結果がnilというのもわかるだろう。先ほどのようにプログラムで書いてもnilが返ってくる。

(str =~ /hoge.+3/) ? str.match(/hoge1/) : nil     # => nil

否定先読み

さて、実際にはこちらの方が使用頻度が多いかもしれない。

上と同じ言い方をすると、否定先読みは「それがないことを保証する文字列」である。

否定先読みはRubyで「(?!regexp)」と書く。

先ほどと同じ例を先読みではなく否定先読みで実行してみる。

str.match(/hoge(?!.+2)1/)         # => nil
str.match(/hoge(?!.+3)1/)         # => #<MatchData "hoge1">

先読みが理解できていれば、結果が当然逆になることもわかるだろう。2段階に分けた考え方はこのようになる。

  • (?=regexp) の後ろを除去した「hoge(?=.+3)」にマッチしないかをまず調べる(これはつまり「hoge.+3」とマッチしないことを確認する)
  • 上がマッチしないことを確認したら (?=regexp) を除去した hoge1 と実際にマッチさせる(上がマッチしたらnilを返す)

先読みの逆である。三項演算子を使用したプログラムの例についても、str.match(/hoge1/) と nil を入れ替えることになる。

使用例

例えば、以下のように記号で囲まれた文字列を取得したい場合。

:hoge:
:hoge:huga:piyo:

文字列が1つなら「:」で挟み、2つ以上ならその後ろに「文字列:」を書き足していくというものである。

これは2つ以上の場合「/:[^:]+:/」では取得できない。

s1 = ":hoge:"
s2 = ":hoge:huga:piyo:"
s1.scan(/:[^:]+:/)             # => [":hoge:"]
s2.scan(/:[^:]+:/)             # => [":hoge:", ":piyo:"]

なぜなら、s2の「:hoge:」とマッチが終わった時点での文字列が「huga:piyo:」のように、先頭の「:」が欠けてしまっているからだ。

s2.scan(/:([^:]+)/).flatten     # => ["hoge", "huga", "piyo"]
s2.scan(/([^:]+):/).flatten     # => ["hoge", "huga", "piyo"]

のようにしてもいいが、これは先頭や末尾に別の文字列があるときに上手くいかない。

そこで先読みを使ってみる。先読みを使って書くとこのように書ける。

s2.scan(/:(?=.+:)([^:]+)/).flatten      # => ["hoge", "huga", "piyo"]

後ろ「:」があることを保証して「:[^:]+」とマッチさせている。こうすれば、末尾に別の文字列があっても「:」が入っていなければマッチしない。

まとめ

先読みについて説明してきたが、絶対に必要かと言われたらそんなことはない。プログラム中では三項演算子の例のように分岐でどうにかなるからだ。

ただ、否定先読みは「[^ ]」の文字列版として便利なことが多い。正規表現に慣れてきたら、否定先読みを使えるところでは積極的に使ってみることをオススメする。