Perl Hackers Hub

第58回正規表現の勘所―わかりづらい記法の覚え方、先読みや後読みの実践(3)

(1)こちら⁠2)こちらから。

先読みと後読みを理解する

正規表現を学んでいくと出会うのが、その位置の両側にどのような文字列があるかを指定する先読さきよ後読あとよです。まとめて先後読せんごよとも呼ばれます。

先後読みを使いこなすと、処理が簡潔になる恩恵を得られます。しかし、Perlの入門書で詳しく取り扱われていない場合が多く、いまいち理解できていない初学者は少なくないでしょう。筆者もその一人でした。

ここでは、筆者がどのように先後読みを理解して正規表現を読み書きしているのかを紹介します。

先後読みはゼロ幅マッチ

正規表現のメタ文字には文字自体にマッチするもののほか、文字にはマッチしないものの文字と文字の間などの位置にマッチするものがあります。後者の位置にマッチすること、またはその正規表現を言明げんめいassertion注1⁠、またはゼロ幅マッチzero-length matchと呼び、前者の幅のある文字や文字列とのマッチと区別します。

ゼロ幅マッチは、基本的な正規表現ですでに登場しています。たとえば、冒頭^末尾$単語境界\bはゼロ幅マッチです。そして重要なのが、先後読みもゼロ幅マッチであることです。

ゼロ幅マッチはマッチ文字列に入ることがないので、置換演算子s///で文字列置換の対象に入ることがありません。このことを指して、文字を消費しないとも言われます。

先後読みの記法

先読みと後読みにはそれぞれ肯定と否定があり、表3にある4種類のメタ文字が用意されています。

表3 先後読みのメタ文字
メタ文字名前説明
(?=●)肯定の先読みこの位置の右側が正規表現●にマッチすることを要請
(?!●)否定の先読みこの位置の右側が正規表現●にマッチしないことを要請
(?<=●)肯定の後読みこの位置の左側が正規表現●にマッチすることを要請
(?<!●)否定の後読みこの位置の左側が正規表現●にマッチしないことを要請

まず、簡単な肯定の先読みの例を見てみましょう。

my $intro = "私は牛乳と牛丼が好きです";
$intro =~ s/牛(?=丼)/豚/g;
print "$intro\n"; # => 私は牛乳と豚丼が好きです

正規表現牛(?=丼)は、の右側にがある場合、にマッチします。は先読み(?=丼)によってマッチに必要な条件ですが、ゼロ幅マッチなのでマッチ文字列には入りませんという文字を消費しません⁠⁠。そのため、マッチ文字列すなわち置換対象文字列はとなり、結果的に単語牛丼のみ豚丼に置換されます。

この例だとs/牛丼/豚丼/gでも代用できます。しかし、複数の正規表現演算子や文字列関数を駆使しないと実現できなさそうな要望も、先後読みを使うと単一の正規表現で簡潔に書ける場合があります。

先や後はマッチカーソルの動き

初学者が先後読みを学んだ際、⁠先や後の意味がわからない」と訴える場面をしばしば見かけます。

前項のs/牛(?=丼)/豚/gのサンプルは、の後にがある場合」といった説明もできるでしょう。先読みを使用したのに、その文字の後と取れる説明もできるわけです。ここでの「後」は、文字の登場順、つまり時間軸における「後」ととらえています。

しかし、先後読みの先や後は、時間軸のことではなくマッチ文字列を取り込んでいく先や後と位置関係でとらえるのがお勧めです。言い換えると、文章を読む目線の移動方向、すなわち書字方向の向かう先が先読み、その逆側が後読みです。日本語や英語を含む多くの言語の書字方向は左から右へ向かうので、右側を対象とするのが先読み、左側を対象とするのが後読みと考えて差し支えありません。

時間軸で考える場合も、をマッチ文字列候補に取り込んだけれど、次の文字をマッチ文字列候補として取り込む前に、先に次の文字を偵察するだと先読みの意味を正しく反映した説明となります。ただ、時間軸よりも位置関係でとらえるほうが誤解が起こりづらいと筆者は考えています。

「後」が時間軸を想像させるからか、⁠後読み」の代わりに「戻り読み」⁠後ろ読み」といった位置関係を強調した用語を使う人もいます[2]⁠。また、先読みと後読みの英語での表現は、それぞれlook aheadとlook behindです[3]⁠。戻り読みや後ろ読み、または英単語aheadやbehindがしっくり来る人は、そちらで覚えるのもよいでしょう。

ゼロ幅マッチは一段下げて読む

先読みや後読みはゼロ幅マッチであると解説しましたが、ゼロ幅マッチを含んだ正規表現を読むときに筆者が頭の中で想像するのが、1行にした正規表現からゼロ幅マッチ部分だけを下の行に移す方法です。

図1では(1)の正規表現牛(?=丼)を例として、(2)で肯定の先読み(?=丼)を下の行に移しています。ゼロ幅マッチが位置にマッチすることと、先読みの方向がわかりやすいよう、(3)のように正規表現内の位置を線で示して、ゼロ幅マッチの指示を方向がわかりやすい図形にすると、視覚的にわかりやすくなります。

図1 ゼロ幅マッチの読み方(その1)
図1 ゼロ幅マッチの読み方(その1)

このように、通常の幅のある文字列マッチと、ゼロ幅マッチを上下に分けて書き、下の行にあるゼロ幅マッチの情報を上の行にある幅のある文字列マッチへの指示として書くことで、見通しが良くなります。

隣接する複数のゼロ幅マッチはAND条件

ゼロ幅マッチは位置にマッチしますが、複数のゼロ幅マッチが隣接している場合はAND条件になります。

my @data = ("1 Alice", "", "3 Carol", "Dave");
for my $line (@data) {
    $line =~ s/^(?!\d)/WARNING:/;  ――(1)
    print "$line\n";
}

(1)は、冒頭^かつ否定の先読み(?!\d)によって数値が右側に存在しない位置に文字列WARNING:を挿入するサンプルです。正規表現がゼロ幅マッチのみで構成されており、文字を消費しないため、置換がゼロ幅マッチで確定した位置への文字列挿入として機能します。出力結果は下記になります。

1 Alice
WARNING:
3 Carol
WARNING:Dave

上記で「かつ」を使って説明したことからわかるように、^(?!\d)は位置に関する2個の条件のAND条件となっており、順序を逆にして(?!\d)^と書いても同じ意味です。しかしこの場合は、左端に書かれることが自然に感じる冒頭^を、わざわざ否定の先読み(?!\d)の右側に書く意義はないでしょう。ただ、複数のゼロ幅マッチの正規表現が隣接している場合、それらはその位置に関するAND条件になっていて、かつその順序の入れ替えで意味が変わらないことを理解しておくと、ゼロ幅マッチへの理解が深まります。

(1)で否定の先読みを使わず、1つのs///に収めようとすると、幅のある1文字をキャプチャした結果によって条件分岐させることになるでしょう。

$line =~ s{^(.)}{
    my $x = $1; $x =~ /^\d$/ ? "$x" : "WARNING:$x";
}e;

しかし、数値に関する検査のため、幅のある1文字をキャプチャする必要があり、上記の例では$lineが空文字の場合に対応できていません。また、s{●}{■}eの置換文字列を得るためのPerlコードの中にm//があることが読みやすいかどうかは、議論が分かれるでしょう。

幅のある1文字をキャプチャする例と比較すると、否定の先後読みは、左右にそもそも文字列が存在しない冒頭や末尾で効果を発揮することがわかります。

先後読みを使うか、それとも複数のステップで文字列を分解および検査していくかは、開発現場ごとの方針もあるでしょう。しかし上記の例でも、先後読みの恩恵の一端が垣間見えます。

否定の先後読みの実践

前項で否定の先読みの例が登場しましたが、引き続き、否定の先後読みの実践的な活用例を見ていきましょう。

紙幅の都合上、肯定の先後読みの実践的な活用例の解説は割愛します。s/牛(?=丼)/豚/gs/牛丼/豚丼/gの例で見たとおり、肯定の先後読みは幅のあるマッチで代用しやすい傾向にあるためです。ただし、肯定の先後読みは、文字を消費しないメリットが置換で活きる場面もあります。

否定の先後読みを日本語の単語境界の代用として使う

筆者は東京在住なのですが、プライベートで定期的に京都を訪れています。過去の京都訪問時のことを調べようと、移動の記録から「京都」を検索すると、⁠東京都」を含む大量のエントリがヒットしてしまう課題がありました。

検索に正規表現を使えるのであれば、否定の先後読みがこの課題を解決してくれます。

ちなみに、単語境界\bは日本語文字列に対応していない[4]ので、\b京都\bではうまくいきません[5]⁠。

否定の後読みで単語マッチを限定する

「東京都」ではない「京都」にマッチする、否定の後読みを使った正規表現の例を見てみましょう。

my @entries = (
    "東京都の自宅を出て品川駅へ移動",  ――(1)
    "品川駅から東海道新幹線に乗車",  ――(2)
    "東海道新幹線の京都駅で下車",
    "京都市右京区へ移動",
);
for my $entry (@entries) {
    if ( $entry =~ /(?<!東)京都/ ) {  ――(3)
        print "$entry\n";
    }
}

(3)の正規表現(?<!東)京都によって、文字列「京都」があり、かつ「京」の左側に「東」がない場合に限り、⁠京都」にマッチします。これによって、(2)だけでなく(1)の文字列にもマッチしません。

否定の先読みで単語マッチを限定する

本項では、⁠東京都」だけでなく「京都府」「京都市」にもマッチしない例を題材に、マッチ文字列の右側に条件を課す否定の先読みを見ていきます。

my @entries = (
    "東京都の自宅を出発",
    "京都府の旅館に到着",
    "京都嵐山を散策",  ――(1)
    "バスで京都市街を移動",
    "京都文化博物館を観覧",  ――(2)
);
for my $entry (@entries) {
    if ( $entry =~ /(?<!東)京都(?!府)(?!市)/ ) {  ――(3)
        print "$entry\n";
    }
}

(3)の正規表現(?<!東)京都(?!府)(?!市)は、否定の後読み(?<!東)のほか、2つの否定の先読み(?!府)(?!市)によって、⁠東京都」⁠京都府」⁠京都市」の一部ではない「京都」にマッチします。この例では、(1)(2)が表示されます。

ゼロ幅マッチを一段下げて読む例で表すと、図2となります。正規表現(1)から否定の先後読みを下の行に移して(2)とし、視覚的にわかりやすいよう(3)で否定の先後読みを表した図形が正規表現内の位置に指示を与えています。AND条件となる(?!府)(?!市)の関係も把握しやすくなります。

図1 ゼロ幅マッチの読み方(その2)
図1 ゼロ幅マッチの読み方(その2)

まとめ

本稿では、Perlの正規表現の話題の中から、初学者向けの丁寧な解説が少ないと筆者が感じる事項について解説しました。

Perlの正規表現は、それ自体が小さなプログラミング言語と思えるほどの機能を有しています。今回解説した内容が、みなさんの一歩進んだ正規表現の理解と活用の一助となれば幸いです。

さて、次回の執筆者は白方健太郎さんで、テーマは「Perlで作る非中央集権型ソーシャルネットワークサーバ」です。お楽しみに。

おすすめ記事

記事・ニュース一覧