Perlの正規表現における「先読み/後読み」に関する私的まとめ

はじめに

Perlの基礎文法をある程度身につけて、本職のプログラマーほどではないにせよ、趣味や自分の本業を助ける程度のことができるようになってくると、そのもう一段先、ぐらいのことを知りたくなってくる。

現在のぼくにとってそれは正規表現の「先読み/後(アト)読み」を習得することで、実際にはそんなに使わない可能性も高いものの、いつまでもぼんやり把握したままなのが気持ち悪いので、ここ数ヶ月で理解したところを自分なりにまとめておきたい。

普通の正規表現

まずは土台となる、シンプルな正規表現の例を作っておく。

#!/usr/bin/env perl
use strict;
use warnings;
use feature 'say';

my $fruits = 'applebananaorange';

if ($fruits =~ /banana/) {  #<= ココが正規表現
    say 'match!';
}
else {
    say 'not match.';
}

変数 $fruits の値には「banana」が含まれているから、これは「match!」を出力する。

先読み

それではさっそく、「先読み」を試してみよう。
(以後のサンプルコードではプラグマは書かない)

my $fruits = 'applebananaorange';

if ($fruits =~ /banana(?=orange)/) {  #<= 書き換え
    say 'match!';
}
else {
    say 'not match.';
}

このように書くと、まず「banana」にマッチした上で、その右側に「orange」もあれば真になる。
よって、ここでも「match!」が出力される。

もしこのようにすると、

my $fruits = 'applebananaorange';

if ($fruits =~ /banana(?=lemon)/) { #<= orange を lemon に変える
    say 'match!';
}
else {
    say 'not match.';
}

「not match.」が出力される。

後読み(戻り読み)

次に、「後読み」を試してみる。
これは「戻り読み」と言う人もいるらしい。

まずコード例。このようにすると……

my $fruits = 'applebananaorange';

if ($fruits =~ /(?<=apple)banana/) {  #<= ココ
    say 'match!';
}
else {
    say 'not match.';
}

「match!」が出力される。

ここでやっているのは、もし「banana」がマッチしていたら、その左側をチェックして、そこに「apple」があれば真を返す、ということらしい。

だから、以下のようになっていると、

my $fruits = 'applebananaorange';

if ($fruits =~ /(?<=strawberry)banana/) { #<= apple を strawberry に変える
    say 'match!';
}
else {
    say 'not match.';
}

「not match.」が出力される。

「先」と「後」がもたらす混乱

ということで、「先読み」「後読み」がやっていることというのは、けっこうシンプルであるように思える。

では何が問題なのかと言うと、「先読み」「後読み」という名称における「先」とか「後」とかいう表現が、時間の前後を表しているのか、それとも場所(位置)の前後を表しているのか、あるいはどっちにしても右のことなのか、左のことなのか、解釈の余地が多すぎて直感的にわかりづらい、ということがよく問題になっている。

たとえば、「後読み」の「後(アト)」という言葉は「金なら後で払うからさ〜」と言うように、「今より遅い時間」のことを示すこともあれば、「後に並んでください」と言うように、場所としての「後ろ」を示すこともある。

もし場所としての「後ろ」を示すのであれば、それに対応する反対語は「先頭」になるが、ではコードにおける「先頭」とは、果たして右側のことだろうか? それとも左側のことだろうか?

ぼくだったら、任意の1行のコードを指して、「その先頭はどこですか?」と聞かれたら、一番左を「先頭」としてイメージするだろう。

以下の例で言ったら、

applebananaorange

左端の「a」が先頭であり、「e」が最後尾である。
(これは「行頭」「行末」という表現にも対応する)

しかし実際には、上記の正規表現における「先」とは右側「e」の方を指しており、「後」は左側「a」の方を指している。

なるほど、混乱している。

結論は「進行方向」における先と後

こうした事態に対し、「日本語で考えるからいけないのだ。原語(英語)で考えればよいのだ」と主張したのが以下の記事で、書かれた頃には少し話題になった。

qiita.com

じつはこの本文にはちょっと勘違いがあって(記事冒頭でその旨の追記もある)、だから本文だけを読むとかえって混乱が増すのだが、論旨としては、

  • 英語なら「先読み」は lookahead、「後読み」は lookbehind と表現されており、誤解の余地が少ない。
  • だからみんなも「先」とか「後」とか言わないで、英語で表現しようよ。

みたいなことを言っている。(と思う)

しかし、この記事でより注目すべきはコメント欄の方で、そこでは、

  • 「先(前)」であれ「後」であれ、「進行方向」を基準に考えなければ混乱は避けられない。

みたいなことが言われている。

ここで言う「進行方向」というのは、文字が伸びていく方向であり、再びこの例で言うと、

applebananaorange

最右の「e」が先頭で、最左の「a」が最後尾を指す。

よって、これらを踏まえて言えば、

  • 「先読み」とは対象とする語句(上のサンプルコードだと「banana」)から見て、文の進行方向における前(右側)を読むことであり、
  • 「後読み」とは対象語句から見て、文の進行方向における後ろ(左側)を読むことである。

と説明することができるだろう。

なお、そのリンク先の記事でもやや説明が曖昧になっているようなのだけど、これを単に「時間の観点ではなく位置関係の問題として捉えればいい」と言ってしまうと、新たな(というか未消化の)問題が生じてしまう。

なぜなら、「時間の前後ではなく、位置関係の前後なのである」と言ったところで、たとえば人が待ち行列に並ぶときの「後ろ」とは、列がどんどん伸びていく方向を指しているが、マラソンランナーがフルマラソンを走るときの「後ろ」は、ランナーがぐんぐん進んでいく方向とは逆側(スタート地点側)を指している。

だから、「時間の前後ではなく位置関係の前後なのだ」という説明ではまだ足りない。

よって、その点も考慮しつつ説明するなら、

  • 「後読み」の「後」というのは、『時間』に関する「後でやっておくよ」の「後(アト)」ではなく、
  • また『位置関係』を示す場合の「後ろ(ウシろ)」とも言い切れず、
  • 『進行方向』における「後(ウシろ)」である。

などと言う必要があるだろう。

また、その辺と繋がるイメージで、同記事に関するこちらのブックマークコメントもわかりやすかった。

[コラム] 正規表現の先読み/後読みは、どう考えても名前が悪いので、呼称禁止令を出してルックと気軽に呼んでみませんか。 - Qiita

「チラ見」と「後方確認」を推したい。

2017/06/04 23:40
b.hatena.ne.jp
(「チラ見」は先読み、「後方確認」は後読みを指すのだろう)

ということで、本題は以上だが、せっかくなのでこの機会に、上記に関連する正規表現をいくつかまとめておきたい。

否定先読み

まずは、「先読み」かつ「否定」するやつ。

これはつい最近までほとんど使っていなかったが、何かの機会に一度使ったら、けっこう便利だと感じた。

my $fruits = 'applebananaorange';

if ($fruits =~ /banana(?!lemon)/) {
    say 'match!';
}
else {
    say 'not match.';
}

この場合、「banana」の後に「lemon」が無ければ真なので、「match!」が出てくる。

否定後読み

上とほとんど同じやつ。

my $fruits = 'applebananaorange';

if ($fruits =~ /(?<!strawberry)banana/) {
    say 'match!';
}
else {
    say 'not match.';
}

これも「banana」の左に「strawberry」がなければ真なので、「match!」が出てくる。

幅を持つもの/持たないもの

さて、ここまでに挙がっていない話題として、上に挙げたパターン群には「幅を持たない」という特徴がある。

「幅」というのは、手元のPerl入門同人誌『雅なPerl入門 第3版』によると、

マッチした文字が消費する文字幅のことさ。. っていうのは、1文字だから1幅。\Aなどは、マッチしても幅は持たないから、0幅なんだ。

とのこと。
(同書は会話形式で進んでいくので、こういう話しぶりになっている)

この特徴は、以下のような例で示すことができる。

まず、通常の正規表現ならば、このような置き換えが可能だが、

my $fruits = 'applebananaorange';

$fruits =~ s/applebanana/xyz/;  #<= applebanana を xyz に置換

say $fruits; #=> xyzorange

たとえば「先読み」を使って以下のようにすると、

my $fruits = 'applebananaorange';

$fruits =~ s/apple(?=banana)/xyz/;

say $fruits; #=> xyzbananaorange

というふうに、括弧内の「banana」は置換されない。

これは上の『雅なPerl入門』からの引用にもあるように、「先読み」の記法が「\A」、つまり「アンカー」と同様に機能することを意味している。

この「アンカー」という観点から、正規表現の先読み・後読みを解説した記事としてはこちらがわかりやすかった。
abicky.net

冒頭部分だけ引用すると、こんな感じで説明されている。

この内容を理解するためには「先読み・後読みはアンカー」という考え方が必要になってきます.
アンカーとは文字列内の特定の位置を表す物であり,文字列の先頭を表す ^ や末尾を表す $ がそれにあたります.普通の正規表現では文字に対してマッチしますが,アンカーは位置に対してマッチします.

また、この特徴は「後読み」や「否定先読み」「否定後読み」にも共通する。

# 後読み
my $fruits = 'applebananaorange';

$fruits =~ s/(?<=apple)banana/xyz/;
say $fruits; #=> applexyzorange
# 否定先読み
my $fruits = 'applebananaorange';

$fruits =~ s/apple(?!lemon)/xyz/;
say $fruits; #=> xyzbananaorange
# 否定後読み
my $fruits = 'applebananaorange';

$fruits =~ s/(?<!lemon)banana/xyz/;
say $fruits; #=> applexyzorange

(?:PATTERN) との違い

以上、「幅」についても一貫した法則があるようで、煩雑ではあるものの難しくはない、という感じだが、その法則を外れているのが、ここで新たに登場する「(?:PATTERN)」という書き方である。
(「PATTERN」は説明のために便宜的に使用する仮の文字列)

この記法は上に挙げた「先読み」とか「後読み」のように使うわけではなく、通常の括弧でくくった場合とほとんど同じ働きをする。

しかしもちろん、通常の括弧とまったく同じ働きならば、そもそも存在する理由もないわけで、じゃあどこが微妙に違うのかと言ったら、通常の括弧はくくった文字列をキャプチャ(捕獲)するのだが、この記法ではキャプチャをしない。

「キャプチャ」とは、その括弧でくくった部分を後から再利用するために一時記憶することで、たとえば以下のように使う。

my $fruits = 'applebananaorange';

# 括弧でくくった apple が $1 に入る
if ($fruits =~ /(apple)/) {

    # $1を使った文が出力される
    say "I like an $1!"; #=> I like an apple!

}

一方、ここで「(?:PATTERN)」を使うと、

my $fruits = 'applebananaorange';

if ($fruits =~ /(?:apple)/) { #<= 変更
    say "I like an $1!";
}

以下のように、「$1」には何も入ってないよ! と怒られる。

Use of uninitialized value $1 in concatenation (.) or string at (略)
I like an !

ふむ、ふむ。
……えーと、でも、それがなんなのだ?! 一体これにどんなメリットがあるのだ? という感じだが、前掲の『雅なPerl入門』ではまさにその疑問と、回答が示されている。

どういう時に使うんですか? 普通の( )でいい気がします。
 
メモリ消費を減らす目的かな。マッチする文字が大きいとメモリ消費も大きくなるし。

なるほど……。(わかったような、わからんような)

さてしかし、じつはこの「キャプチャしない」という性質自体は、上記の「先読み」「後読み」にも共通している。
だから、問題はそのことではなく、先述のとおり、

「幅」についても一貫した法則があるようで、煩雑ではあるものの難しくはない、という感じだが、その法則を外れているのが「(?:PATTERN)」という記法である。

この「(?:PATTERN)」が幅を「持つ」ということが問題なのである。

見た目や、他の挙動はほとんど同じなのに、そこだけが違う。混乱する。

たとえば、「先読み」を使った置換コードはこのように動くが、

my $fruits = 'applebananaorange';

$fruits =~ s/apple(?=banana)/xyz/;

say $fruits; #=> xyzbananaorange

同じことを「(?:PATTERN)」でやると、こうなる。

my $fruits = 'applebananaorange';

$fruits =~ s/apple(?:banana)/xyz/;

say $fruits; #=> xyzorange

出力結果を並べると、前者は「xyzbananaorange」だが、後者は「xyzorange」であり、前者では括弧でくくった「banana」がそのまま残っているが、後者では「banana」も「apple」と一緒に「xyz」に置き換えられている。

言い換えると、前者(先読み)は、括弧内が幅を持たない(=アンカーである)から置き換えられないが、後者は幅を持つ(=アンカーではない)から置き換えられてしまう、ということのようである。

うーむ、まぎらわしい……。
まあ、それで困る場面がとくにない、ということなのかもしれないが。

幅・キャプチャに関するまとめ

ということで、ここまでに触れてきた「幅」「キャプチャ」について、サンプルコードを列記して簡単にまとめておこう。

my $text = "abc";

# 幅の研究
$text =~ s/a(?:b)/x/; #=> xc
$text =~ s/a(?=b)/x/; #=> xbc
$text =~ s/a(?!z)/x/; #=> xbc
$text =~ s/(?<=b)c/x/; #=> abx
$text =~ s/(?<!z)c/x/; #=> abx

# キャプチャの研究
# 以下、すべてエラー($1に値が入らないため)
$text =~ s/a(?:b)/$1/;
$text =~ s/a(?=b)/$1/;
$text =~ s/a(?!z)/$1/;
$text =~ s/(?<=b)c/$1/;
$text =~ s/(?<!z)c/$1/;

まとめ 〜「前後」がもたらす深い謎〜

以上、Perl正規表現の「先読み」「後読み」、およびそれに関連する書き方を、今把握しているかぎり書き出し&整理してみた。

正直、ここまで書いてみても尚、これらをどんな状況で便利に使えるのか、ということはよくわからない。

少なくとも自分が書く程度の規模や内容であれば、ごく基本的な正規表現だけで足りそうではある。

しかし、とくに後半でまとめたような、キャプチャや幅に関する知見については、後から思い出そうとしてもなかなか思い出せないだろうから、記憶が鮮明なうちにこうしてまとめておくことには、一定の意味もあるだろう。

今回の記事作成にあたり、その「先読み」「後読み」ならではの使いどころというか、特徴や使い方を細かくまとめているブログ記事をいくつか見たので、合わせて記録しておく。

前者の記事では、なぜ通常の正規表現ではなく「先読み」を使うのか、みたいなことが少し書いてあって、参考になった。

それから、前半で話題にしたQiita記事のコメント欄からリンクされていた以下の記事。

考えはじめると頭が割れそうなほどに哲学的というか、面倒な難問ではあるが、すこぶる面白かった。

果たして、「前」とは、「後ろ」とは、何を指しているのだろうか?

「前を向いて生きろ! 後ろを振り返るな!」と言うときの「前」は未来であり、「後ろ」は過去を指しているように思えるが、「前にこんなヒドイことがあってさあ〜」と言うときの「前」は明らかに過去である。

ゾゾ〜……。(←わからなさに震えているところ)

まあ、簡単に整理してしまえば、任意の出来事が遠い過去から現在に向かって、10年前、5年前、去年、昨日……と切れ目なく生まれては、どんどん1本の行列に並んできたのだと考えた場合、その一番「前」にいるのは最も古い過去だから、過去を「前」と呼ぶのは空間的な位置関係(たとえば商店や病院などの待ち行列)における「前後」のイメージと合致するが、そうした俯瞰的な見方ではなく、一人の人間が主体となって「俺はこれから前へ進んでいくぞ!」といった文脈における「前」は、未知の空間へ入っていくイメージを持っているから、時間の概念においても「過去」ではなく「未来」を指している。

つまりそのように、これは「前」である(あるいは「後ろ」である)、と判断する主体がいる場所によって、「前後」の意味が変わるのだ、と言うことはできるかもしれないが。

しかしいずれにせよ、こうした言葉(というか訳語というか)を考えるというのは、なんとも重い責任を持つことであり、なかなか大変そうである。

とくに、この「先/前/後」のような両義的な言葉を使うのは、非常に高難度な所業であるように思える。

実際のコーディングにおいては、勉強や経験を積むほどに、「そんなのどっちでもいい。というか、どっちでも問題ない」みたいな感じになるのかもしれないが、学習の途上にある人や、まだ経験の少ない人にとっては、ちょっとした障害になることも確かだろう。

上にも書いたが、ひとまずはこれが未来の(前の?後の?)自分の役に立てば幸いである。

関連資料

Perl入学式でいつもお世話になっています @xtetsuji さんから、同件に関してご自身がどう理解しているか、手書きのメモを書いてシェアしてもらいました!
これ、むっちゃ面白い&わかりやすい。

上に書いたような基礎をある程度理解してからの方がよりわかりやすいかもしれないけど、とりあえず今まで見たどんな解説より「あ、そうなんだ」という感じになりました。

@xtetsuji さん、ありがとうございます!