正規表現のマッチングをどこからでも―「境界アサーション」と「ルックアラウンドアサーション」ECMAScriptで学ぶ正規表現(7)

正規表現の基本と、ECMAScript(JavaScript)における利用方法を紹介する連載。今回は、「^」「$」など登場済みのものからECMAScript 2015以降で利用可能になった全てのアサーションについて。

» 2022年07月15日 05時00分 公開

この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。

「ECMAScriptで学ぶ正規表現」のインデックス

連載:ECMAScriptで学ぶ正規表現

 本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。具体的な利用方法は連載第1回を参考にしてください。

アサーションとは?

 アサーション(assertion)とは、「主張」「表明」といった意味です。「言明」と呼んでいるドキュメントもありますが、正規表現では「(位置などを)はっきりさせる」という意味で用いられています。これまでの回ですでに紹介したハット(^)、ダラー($)は、文字列先頭と末尾という位置をはっきり指定する、という目的で使用していました。ECMAScriptでは表1に挙げるアサーションがサポートされています。

アサーション 概要
^ 文字列あるいは行の先頭にマッチする
$ 文字列あるいは行の末尾にマッチする
\b 単語の区切りにマッチする
\B 単語の区切り以外にマッチする
X(?=Y) パターンXにYが続く場合のみxにマッチする(先読みアサーション)
X(?!Y) パターンXにYが続かない場合のみxにマッチする(否定的先読みアサーション)
(?<=Y)X パターンYにXが続く場合のみxにマッチする(後読みアサーション)※
(?<!Y)X パターンYにXが続かない場合のみxにマッチする(否定的後読みアサーション)※
表1 アサーションの一覧

 ※の付いたアサーションはECMAScript 2018以降でサポートされたものです。このうち、「^」と「$」については第5回などで取り上げましたが、今回は別の切り口でこれらを改めて紹介します。

文字列あるいは行の先頭および末尾にマッチする(^、$)

 すでに紹介したように、「^」と「$」は境界アサーションです。第5回などでは、ドット(.)などと組み合わせて、先頭(末尾)にあるパターンとマッチさせる、といった使い方を紹介しました。もちろん、そのような使い方が主流と思われますが、これらは単独で指定することもできます。「^」と「$」を単独で指定すると、それは先頭と末尾に必ずマッチするので、replace()メソッドなどで置換する(実際には挿入に相当する)ことができます。

 以下の「replace_begin.js」は、数字の列の先頭に無条件に通貨記号である「£」(ポンド)を挿入する例です。

const regexp = /^/;	// ハット(^)のみのパターン
const lines = ['1,000', '2,000', '3,000'];
lines.forEach(line => {
    console.log(line.replace(regexp, '£'));
});
replace_begin.js

 こちらは、実行結果です。

£1,000
£2,000
£3,000

 見ての通り、パターンは「^」のみですが、replace()メソッドによって「£」に置換されています。これはアサーションの特性で、マッチしたとしてもそれは幅(文字数)を全く持ちません(すなわち、マッチした結果の文字列に含まれない)。この特性を利用して、境界アサーションにマッチした位置を何かに置換する、すなわちマッチした位置に何かを挿入する、といった処理も可能になります。

 これは「$」も同様です。上記の「replace_begin.js」の「^」を「$」に置き換えればすぐに試すことができますが(配布サンプルの「replace_end.js」を参照)、ここでは「^」と「$」のいずれかといった指定を試してみます。以下の「replace_both.js」は、文字列の先頭および末尾に「*」を挿入します。

const regexp = /^|$/g;		// 先頭または末尾を表すパターン
const lines = ['1,000', '2,000', '3,000'];
lines.forEach(line => {
    console.log(line.replace(regexp, '*'));
});
replace_both.js

 こちらは、実行結果です。

*1,000*
*2,000*
*3,000*

 文字列には必ず先頭または末尾があるので、このパターンは必ずマッチし、「*」が文字列の両端に追加される、といった処理になります。

単語の区切りにマッチする(\b)

 \bは、単語の区切りにマッチします。これも、「^」「$」と同様に境界アサーションで「単語境界」と呼ばれます。ここでいう単語とは英数字とアンダースコア(_)の並びを指し、すなわち\wおよび「[A-Za-z0-9_]」と等価です。これらの文字が出現しないという位置、すなわち単語の境界にマッチします。どのようなケースで使うのか、見てみましょう。以下の「word_boundary.js」は、数字の列を検索する例です。

const regexp = /\b\d+\b/g;	// 境界、数字の列、境界のパターン
let str = "11,12,13";	(1)
console.log(str.match(regexp));
// (3) ['11', '12', '13']
str = "11th, 12th, 13th anniversary.";	(2)
console.log(str.match(regexp));
// null
word_boundary.js

 (1)のように、数字の列の前後が単語の一部でないときは、それはマッチしたと見なされます。カンマ(,)もそうですが、文字列の先頭も単語の一部ではないと見なされます。これに対して(2)のように数字の列があっても、その前後が境界でない場合にはマッチしません。「11」の後には「th」があって、「11th」全体が単語と見なされるからです。

単語の区切り以外にマッチする(\B)

 \Bは、単語の区切り「以外」にマッチします。これも境界アサーションで「非単語境界」と呼ばれます。\bの反対の機能を持ったメタ文字ということができます。単語内の2文字の間、単語ではない2文字の間、空文字列にそれぞれマッチします。

 1つ目はイメージしやすいでしょう。例えば、「abc」の「ab」の間と「bc」の間です。2つ目は、例えば「##」の間です。このように単語および単語以外の文字が続く場合、その間は全て「単語の区切り以外」になります。最後の空文字列も、単語を含まないので該当するというわけです。実際には、\Bを単独で使うことはあまりないでしょうから、1番目までを意識しておけば問題ないでしょう。

 以下の「no_word_boundary.js」は、単語の末尾(接尾辞)が「ation」である部分を「e」に置き換える例です。単語の途中が「ation」であってもそれはマッチしません。

const regexp = /\Bation\b/;
let str = "internationalization";
console.log(str.replace(regexp, "e"));	// マッチする
// internationalize
str = "National Kid";
console.log(str.replace(regexp, "e"));	// マッチしない
// National Kid
no_word_boundary.js

ルックアラウンドアサーション

 ここからは、ルックアラウンドアサーションを紹介します。ルックアラウンドアサーションは、表1に示したアサーションの、残りの4つを指します。ルックアラウンド(look around)とは、その名の通り、周りを見渡すことです。マッチング対象を指定する本来のパターンに対して、別に指定するパターンが前後でマッチするかしないかを調べて、マッチする場合のみ本来のマッチングを行う、というようなことができます。プログラミングにおけるif文に似ていますが、あくまでも主役となるパターンがあり、そのマッチング結果に条件付けする、といった動きになります。条件付けによって、マッチング結果をフィルタリングすることもでき、非常に有用です。

 ルックアラウンドアサーションには、大きく先読み(ルックアヘッド)と後読み(ルックビハインド)があります。再度になりますが、後読みはECMAScript 2018からサポートされた比較的新しい機能です。ルックアヘッドは文字列の後方(ahead、文字列の終端に向かって)を見るのに対し、ルックビハインドは文字列の前方(behind、文字列の先頭に向かって)を見ます。また、それぞれにパターンが「ある」場合を指定する肯定的(ポジティブ)と、パターンが「ない」場合を指定する否定的(ネガティブ)がありますので、計4個のルックアラウンドアサーションがあるということになります。

図 ルックアラウンドアサーション

 以降、これらについて、それぞれ見ていきますが、カタカナ語は長いので、アサーション以外は日本語で表記することにします。また、「肯定的」は「否定的」に対して使われるものであるため、特に明記の必要がない限りは省略します。

XにYが続く場合のみXにマッチする(X(?=Y)、先読みアサーション)

 「X(?=Y)」を使うと、先読みアサーションを利用できます。パターンXに対して、パターンYが続く場合のみXがマッチしたと見なされます。つまり、パターンXがマッチしたとしても、その先を見に行ってパターンYがマッチしていなければ、パターンXもマッチしたとは見なされません。

 以下の「p_look_ahead.js」は、先読みアサーションを使った字句の置き換えの例です。

let regexp = /(次元|時言|自現)(?=爆弾)/g;		(1)
let str = "仕掛けられていたのは精巧な次元爆弾だった。";
console.log(str.replaceAll(regexp, "<em>$1</em>"));
// 仕掛けられていたのは精巧な<em>次元</em>爆弾だった。
regexp = /ー(?=\P{scx=Katakana})/ug;		(2)
str = "プログラミング言語には、インタープリター型とコンパイラー型がある。";
console.log(str.replaceAll(regexp, ""));
// プログラミング言語には、インタープリタ型とコンパイラ型がある。
p_look_ahead.js

 (1)は、先読みアサーションの基本的なパターンです。「次元」「時言」「自現」などに「爆弾」が続くという誤りを見つけたら、ReplaceAll()メソッドによって、その部分をHTMLの<em>タグ(強調の意味)で囲みます。「次元爆弾」などそのものをパターンにすればよいのではないかと思われるかもしれませんが、例えば誤りである「次元」などの箇所だけをハイライト表示したい、という場合には「爆弾」は余計です。

 (2)は、\Pによるスクリプト拡張の指定によって、「片仮名以外の文字が続くときのみ」という指定になっています。つまり、片仮名以外の文字や文字列の終端が現れれば、その前の「ー」はマッチしたことになってReplaceAll()メソッドによって削除されます。なお、\Pは第4回で紹介したUnicodeプロパティエスケープです。このため、uフラグが指定されています。

 このように、先読みアサーションは字句や用法の揺らぎを検出してそれを指摘したり、修正したりする用途に役立ちます。

 先読みアサーションに限りませんが、ルックアラウンドアサーションもアサーションなので、境界アサーションと同様に幅を持ちません。よって、「X(?=Y)」のXがマッチしたら、それは結果としての部分文字列に含まれますが、Yは含まれません。あくまでも、本来のマッチング対象Xのみが結果に含まれます。

 さらに、ルックアラウンドアサーションは入力の文字列を消費しません。先読みによってパターンのマッチングが試みられ、その結果マッチしたとしても、lastIndexは直接マッチしたXの直後で更新されます。

XにYが続かない場合のみXにマッチする(X(?!Y)、否定的先読みアサーション)

 「X(?!Y)」は否定的先読みアサーションです。要は「X(?=Y)」の否定形なので、「?=」が否定を表す「!」で置き換えられて「?!」となっています。よって、上記の「p_look_ahead.js」は以下の「n_look_ahead.js」のように書き直すことができます。

const regexp = /ー(?!\p{scx=Katakana})/ug;
let str = "コンピューターの使い方を教えるインストラクターが増えてきた。";
console.log(str.replaceAll(regexp, ""));
// コンピュータの使い方を教えるインストラクタが増えてきた。
n_look_ahead.js

 「?=」に替わり「?!」を使い、Unicodeプロパティエスケープを\Pから\pに変更しました。完全な否定の意味なら、この否定的先読みアサーションを使った方が意味がはっきりするでしょう。

YにXが続く場合のみXにマッチする((?<=Y)X、後読みアサーション)

 ここからは、ECMAScript 2018でサポートされた後読みアサーションを紹介します。「(?<=Y)X」を使うと、後読みアサーションを利用できます。パターンXに対して、パターンYが前方向にある場合のみXがマッチしたと見なされます。つまり、パターンXがマッチしたとしても、文字列先頭方向を振り返って見に行ってパターンYがマッチしていなければ、パターンXもマッチしたとは見なされません。

 以下の「p_look_behind.js」は、後読みアサーションを使って通貨記号に続く数値のみをピックアップする例です。

const regexp = /(?<=[\$£€])\d+/g;
let str = "主な通貨には、円以外に$1、£2、€3など少なくとも3種類以上あります。";
console.log(str.match(regexp));
// ['1', '2', '3']
p_look_behind.js

 見ての通り、「$1」「£2」「€3」の数値はマッチしていますが、「3種類」はマッチしません。

YにXが続かない場合のみXにマッチする((?<!Y)X、否定的後読みアサーション)

 「(?<!Y)X」は否定的後読みアサーションです。要は「X(?<=Y)」の否定形なので、「?<=」が否定を表す「!」で置き換えられて「?<!」となっています。

 以下の「n_look_behind.js」は、マイナス記号(-)の付くこともある数値の並びから、マイナス記号の付かないものだけを抽出する例です。

const regexp = /(?<![\-\d])\d+/g;
const str = "123 -456 78 -90 0";
console.log(str.match(regexp));
// ['123', '78', '0']
n_look_behind.js

 否定的後読みアサーションのパターンを見ると、「(?<![\-\d])」すなわち「-」でも数字(\d)でもないものという意味です。これに「\d+」が続いているので、「-」でも数字(\d)でもないものに続く数字の繰り返し、という意味になります。つまり、数字の列の前に「-」も数字もないときで、この例の場合は文字列先頭か空白のみ、ということになります。

 「(?<![\-\d])」というパターンが少々回りくどいので、「「(?<![\-])」」のみでよいのではないかと思われるかもしれません。しかし、実際にこのパターンで実行してみると、「-90」の「0」にマッチしてしまいます。「-」でない文字に数字も含まれてしまうからです。

ルックアラウンドアサーションの組み合わせ

 ルックアラウンドアサーションは単体で使っても有用なものですが、つなげたり入れ子にしたりと、組み合わせるとさらに有用度が増します。以下の「look_around.js」は、数字の列に3桁ごとにカンマ(,)を挿入する例です。

const regexp = /(?<=\d)(?=(\d{3})+(?!\d))/g;
let str = "今日の売上は12345678,790円、販売個数は9876個でした。";
console.log(str.replaceAll(regexp, ","));
// 今日の売上は12,345,678,790円、販売個数は9,876個でした。
look_around.js

 パターンが少し複雑なので、2つに分解してみましょう。

(?<=\d)(?=(\d{3})+(?!\d))
↓
?(?<=\d)	←数字が先頭方向にあるとき
?(?=(\d{3})+(?!\d))	←3桁の数字の繰り返しと数字でない文字があるとき

 ?の方から先に見ると、肯定的先読み「(?=…)」の中に否定的先読み「(\d{3})+(?!\d)」が含まれていることが分かります。後者から解釈すると、3桁の数字の繰り返しで、その直後が数字でない位置、です。つまり「12345678」があったら、「345678」の直前です。これに、?の肯定的後読み「(?<=\d)」が加わると、数字がその前にあるので条件が成立します。

 ここで、冒頭の「^」と「$」の例を思い出してください。このパターンは境界にしかマッチしていないので、その境界をカンマ(,)で置き換える、すなわち挿入するという動作になって現れるということです。しかも、「345678」は先読みアサーションで参照されただけなので、次回のマッチングは「345678」から行われます。すると次は「678」がマッチし、再度カンマの挿入が実行されます。ルックアラウンドアサーションは文字列を消費しない、という意味を理解していただけたのではないかと思います。ルックアラウンドアサーションを使わない場合、繰り返しのコードを使わなければならないなど、処理が煩雑になるでしょう。

ルックアラウンドアサーションとキャプチャグループ

 これまで紹介した通り、ルックアラウンドアサーションはパターンではありますが、結果としての部分文字列には含まれないものでした。しかし、既出の「p_look_behind.js」のようにマッチした結果が数値だけになる場合、それに関連した通貨記号も欲しいということもあるでしょう。

 このようなときは、ルックアラウンドアサーションのパターンにキャプチャグループを指定します。キャプチャグループを指定することで、match()メソッドなどの結果にルックアラウンドアサーションのパターンの一部を反映させることができます。

 以下の「p_look_behind_capture.js」は、既出の「p_look_behind.js」を改変し、後読みアサーションのパターンにキャプチャグループを含めてみた例です。

const regexp = /\d+(?=(\$|£|€))/g;
let str = "主な通貨には、円以外に100$、200£、300€など少なくとも3種類以上あります。";
let result = str.matchAll(regexp);
for(element of result) {
    console.log(`${element[0]} は ${element[1]}`);
}
p_look_behind_capture.js

 こちらは、実行結果です。

100 は $
200 は £
300 は €

 ここでは、match()メソッドの替わりにmatchAll()メソッドを使って、マッチング結果をオブジェクトの配列として取得しています。これから分かるように、キャプチャグループで指定した部分がオブジェクトに格納されますので、それを必要に応じて参照できます。なお、キャプチャグループを指定しましたが、あくまでも「先読み」であることには変わりません。このため入力の文字列は消費されず、2回目のマッチングは「100」の次の文字から実行されます。

 キャプチャグループの指定は、先読み、後読み、肯定的、否定的にかかわらず全てのルックアラウンドアサーションで使用できます。

まとめ

 今回は、正規表現においてマッチング位置を指定できるアサーションについて、これまで紹介した境界アサーションから、ES2018以降でサポートされたルックアラウンドアサーションも含めて紹介してみました。

 今回をもって、本連載は終了です。本連載を読んでくださった読者が正規表現に興味を持ち、仕事やプライベートに役立てられそうだと感じていただけたら幸いです。

筆者紹介

WINGSプロジェクト

有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。

サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/
RSS(https://wings.msn.to/contents/rss.php
Twitter: @yyamada(https://twitter.com/yyamada
Facebook(https://www.facebook.com/WINGSProject


Copyright © ITmedia, Inc. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。