正規表現の基本と、ECMAScript(JavaScript)における利用方法を紹介する連載。今回は、マッチング位置を明示する境界アサーションと、最短マッチのための非貪欲な数量詞について。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
正規表現を使っていくと、思い通りにマッチングしてくれない、ということがよくあります。その最たるものが、「マッチングはしたけど意図せず長くなってしまう」ことではないでしょうか? 今回は、マッチングする場所を明示する境界アサーションを紹介し、数量詞に「貪欲」「非貪欲」という区別があることも紹介しながら、思い通りにマッチングするための方法について模索していきます。
数量詞について深掘りする前に、数量詞を扱う上で重要になる「境界アサーション」について紹介しておきます。アサーション(assertion)とは、「主張」や「断言」といった意味ですが、この場合は「はっきりさせること」と捉えればよいでしょう。境界アサーションは、特定のパターンにマッチすることはなく、場所(境界)を明示するという意味です。
アサーションには、ここで紹介する境界アサーションの他に先読みアサーション、後読みアサーションなどがありますが、それらについては後続の回で取り上げる予定です。
ハット(^)は、文字列の先頭位置を明示します。ハット(^)が現れたら、それは必ず文字列の先頭になるので、後続のパターンは必ず先頭からマッチングが試みられます。つまり、先頭という境界を指定するのがハット(^)です。ハットの利用範囲は非常に広く、特に次に紹介するダラー($)と組み合わせると、文字列の全体に一致するパターンにすることができるので、フォームへの入力項目、ワークシート上のセルの値などのチェックに応用できます。
以下の「assertion_hat.js」は、文字列先頭にある空白文字を全て削除します。
const regexp = /^\s+/; const list = [ ' こんにちは。', // 行頭に全角の空白がある文字列 ' Good Morning!', // 行頭に半角の空白が複数ある文字列 '\t1,000,000円' // 行頭にタブ文字がある文字列 ]; list.forEach((str, index) => { console.log(`${index}:${str.replace(regexp, '')}`); });
こちらは、実行結果です。
0:こんにちは。 1:Good Morning! 2:1,000,000円
空白文字は、文字クラスである\sで指定しています。半角スペース、全角スペース、タブ文字にマッチしていることがお分かりいただけると思います。この\sを後述するプラス(+)で繰り返し指定し、ハット(^)により先頭のみに制限し、マッチさせています。マッチ結果は、replace()メソッドで空文字列に置換すなわち削除しています。
ダラー($)は、文字列の末尾を明示します。ダラー($)が現れたら、それは必ず文字列の末尾になるので、そこまでのパターンは必ず末尾からマッチングが試みられます。つまり、末尾という境界を指定するのがダラー($)です。既述の通り、多くの場合においてダラー($)はハット(^)と組み合わせて使います。
以下の「assertion_dollar.js」は、文字列末尾にある句点が全角または半角のピリオド、半角の句点だったら全て全角の区点「。」に置き換える例です。
const regexp = /[\..。]$/; // 全角または半角のピリオド、半角の句点 const list = [ 'よろしくお願いします。', // 末尾が全角ピリオド '理系の文章はカンマピリオドを使うことが多いです.', // 末尾が半角ピリオド '全角と半角を混ぜてしまうこともありますね。' // 末尾が半角区点 ]; list.forEach((str, index) => { console.log(`${index}:${str.replace(regexp, '。')}`); });
こちらは、実行結果です。
0:よろしくお願いします。 1:理系の文章はカンマピリオドを使うことが多いです。 2:全角と半角を混ぜてしまうこともありますね。
なお、ハット(^)とダラー($)だけのパターンは、空の文字列にマッチするパターンとして使われることがあります。
ここでは先頭、末尾という曖昧な表現をしていますが、これはmフラグ(マルチラインモード)の指定の有無で、改行文字(\n)の前後も末尾、先頭と見なすかどうか変化するからです。mフラグがなければ(シングルラインモード)、改行文字も単なる文字と見なされるので、先頭、末尾は常に1カ所です。mフラグがあれば(マルチラインモード)、改行文字の前後も末尾、先頭と見なされます。
なお、ECMAScriptではサポートされていませんが、\A、\Z、\zといったマルチラインモードを無視するメタ文字がPerlやPHP、Rubyなどのプログラミング言語においてサポートされています。このメタ文字を使うと、常に文字列の先頭(\A)と末尾(\Z、\z)にマッチするので、マルチラインモードや改行文字の有無に影響されません。
このへんの挙動はセキュリティ的にも重要な意味を持ってくるのですが、それについては後続の回にて紹介します。
数量詞については、第2回で文字クラスとともに使う代表的なメタ文字であると紹介しました。数量詞を使うと、文字クラスを含むパターンの繰り返しを表現できます。表1に、数量詞の一覧を示します。
メタ文字 | 機能 |
---|---|
* | 0回以上の繰り返しにマッチする※ |
+ | 1回以上の繰り返しにマッチする※ |
? | 0回か1回の出現にマッチする |
{n} | nで指定した出現数にマッチする※ |
{n,} | nで指定した以上の出現数にマッチする |
{n,m} | nとmで指定した範囲の出現数にマッチする※ |
上記+? | 最短マッチを指定する |
表1 数量詞 |
ここからは、表中で※を付けたものについて、改めて掘り下げて紹介します。
アスタリスク(*)は、直前の項目の0回以上の繰り返しにマッチします。ドット(.)と組み合わせて、あらゆる文字列を表すパターンとして使うことが多い数量詞ですが、実は使いどころが難しくもあります。それは、長さが0の文字列(空文字列)から、あらゆる長さの文字列にマッチするからで、例えばドット(.)と組み合わせて単独で使うと、どんな文字列だろうが必ずマッチしてしまうからです。ですので、ドット(.)の部分をもっと絞る、前後に他のパターンを加える、境界アサーションを使うなどして、マッチする範囲を限定させるのが普通の使い方です。
以下の「quantifier_asterisk.js」は、HTMLタグをタグ名と属性に分解する例です。
const regexp = /<(([a-z]+)(\s+([^>]*))*)>/; const tags = [ '<div id="top">', '<div id="top" class="normal">', '<div>' ]; tags.forEach((str, index) => { let result = str.match(regexp); console.log(`${index}:${str}`); console.log(`\tComplete tag: ${result[0]}`); console.log(`\tTag content: ${result[1]}`); console.log(`\tTag name: ${result[2]}`); console.log(`\tAttribute: ${result[3]}`); });
こちらは、実行結果です。
0:<div id="top"> Complete tag: <div id="top"> Tag content: div id="top" Tag name: div Attribute: id="top" 1:<div id="top" class="normal"> Complete tag: <div id="top" class="normal"> Tag content: div id="top" class="normal" Tag name: div Attribute: id="top" class="normal" 2:<div> Complete tag: <div> Tag content: div Tag name: div Attribute: undefined
ここでは、「(\s+([^>]*))*」というように「*」を使っています。最初の「*」で、属性に相当する文字列があってもなくてもよいことを指定しています。属性と見なすのは、1文字以上の空白区切りで「>」以外の任意の文字列を表す「\s+([^>]*)」です。2番目の「*」は、この属性の指定が幾つあってもよいということを指定しています。このように、パターンの長さや数を0以上とするのがアスタリスク(*)の使いどころです。
プラス(+)は、直前の項目の1回以上の繰り返しにマッチします。ドット(.)と組み合わせて、あらゆる文字列を表すパターンとして使うことが多い数量詞です。以下の「quantifier_plus.js」は、文字列がメールアドレスとしてマッチするかどうか調べています(メールアドレスチェッカー)。
const regex = /^([\w\-\.]+)@([A-Za-z\d\-]+\.)+([A-Za-z]+)$/; const addresses = [ 'info_user@naosan.jp', 'nao.yamauchi@mx.japan-east2.example.com', 'nao%yamauchi@naosan.jp', 'user@good_domain.jp' ]; addresses.forEach((str, index) => { console.log(`${index}:${str}`); const result = str.match(regex); if (result == null) console.log('\tNot matched'); else result.forEach(elem => console.log(`\tMatched: ${elem}`)); });
こちらは、実行結果です。
0:info_user@naosan.jp Matched: info_user@naosan.jp Matched: info_user Matched: naosan. Matched: jp 1:nao.yamauchi@mx.japan-east.example.com Matched: nao.yamauchi@mx.japan-east2.example.com Matched: nao.yamauchi Matched: example. Matched: com 2:nao%yamauchi@naosan.jp Not matched 3:user@good_domain.jp Not matched
メールアドレスを表す正規表現パターンを確認しておきましょう。メールアドレスは、ザクッと「@」を挟んだ英数字記号の連なりと言えますが、厳密にはRFC0922という規格で定められています。ここでは、RFC0922をベースにあまり複雑にならない範囲でメールアドレスを正規表現として表現してみることにしました。
まず、「@」の前部に着目してみます。「([\w\-\.]+)」は、キャプチャグループとしていますが、その中身は[ ]による文字集合とプラス(+)です。[ ]の中身は\wとハイフン(-)とドット(.)です。ハイフンとドットはエスケープされているので、単なるリテラルとして扱われていることに注意してください。これはつまり、「@」の前は英数字、アンダースコア、ハイフン、ドットの組み合わせであることを指定しています。例えば、「user_1-2.3」というような構成です。
次に、「@」の後部に着目します。「([A-Za-z\d\-]+\.)+([A-Za-z]+)」と少々長いのですが、真ん中にあるプラス(+)で区切って見ると分かりやすいでしょう。前半は、サブドメインの指定です。ここでは、文字種を回りくどく指定していますが、これはドメイン名にはアンダースコア(_)を含められず、「\w」が使えないからです。後半は、トップレベルドメインの指定ですが、こちらはさらに数字も含められないため、サブドメインとは違った指定方法になっています。
なお、実行例の3番目は、「nao%yamauchi@naosan.jp」に対してマッチしないと正しく判定されていますが、これは先頭の境界を明示するハット(^)があるおかげです。これがないと、「yamauchi@naosan.jp」がマッチしたとされてしまいます。これは意図しない動作なので、境界の明示が重要であることがお分かりいただけるでしょう。
中括弧({ })で整数値を囲んだ'{n}'は、直前の項目のn回の出現にマッチします。郵便番号やクレジットカード番号、会員番号のような固定の桁数で繰り返す文字があるなど、それが正しい繰り返し数になっているかどうかチェックしたいときに使用できます。以下の「quantifier_just.js」は、文字列が郵便番号としてマッチするかどうか調べています(郵便番号チェッカー)。
const regexp = /^\d{3}-\d{4}$/; const list = [ '100-002', '225-0002', '969-22635', '273-0128', '23-0008' ]; list.forEach((elem, index) => { console.log(`${index}:${elem}:${regexp.test(elem)}`); });
こちらは、実行結果です。
0:100-002:false 1:225-0002:true 2:969-22635:false 3:273-0128:true 4:23-0008:false
この例でも、ハット(^)とダラー($)で境界を明確にしたことで、曖昧な判定が排除されています。
中括弧({ })で整数値を囲んだ'{n,m}'は、直前の項目のn回以上m回以下の出現にマッチします。つまり、何文字から何文字の範囲でといった場合に用いられます。最低文字数と最長文字数のあるパスワードをチェックしたいときに使用できます。以下の「quantifier_range.js」は、パスワードを8文字以上、16文字以下の英数字、アンダースコア、ハイフンとしてチェックする例です(パスワードチェッカー)。
const regexp = /^[\w\-]{8,16}$/; const list = [ '1234', 'password', 'long-password', 'long-long-password' ]; list.forEach((elem, index) => { console.log(`Password ${index}:${elem}:${regexp.test(elem)}`); });
こちらは、実行結果です。
Password 0:1234:false Password 1:password:true Password 2:long-password:true Password 3:long-long-password:false
ところで、上記で改めて紹介した数量詞は、「貪欲(greedy)」な数量詞と呼ばれています。どのように貪欲かというと、可能な限り長くマッチしようとするからです(最長マッチ)。以下の「greedy_match.js」は、これを確かめる例です。
const regexp = /【(.+)】/g; const str = '山内直【著】山田祥寛【監修】'; console.log(str.replaceAll(regexp, '《$1》')); // 山内直《著】山田祥寛【監修》
replaceAll()メソッドは、ECMAScript 2020からサポートされています。最新のWebブラウザでは動作に問題ないですが、バージョンの古いWebブラウザやNode.jsなどではType errorになるので注意してください。
この例でやりたいのは、【著】といった【】で囲まれたパターンを、《著》のように別のかっこに置き換えることです。しかし、結果を見ると最も長い【〜】がマッチし、それが置換されていますから、かっこがちぐはぐになってしまっています。数量詞が基本的に貪欲だというのは、このようなことによります。
これを解決する方法を検討してみます。以下の「greedy_match_no.js」は、これの解決を試みる例です。
const regexp = /【([^】]+)】/g; const str = '山内直【著】山田祥寛【監修】'; console.log(str.replaceAll(regexp, '《$1》')); // 山内直《著》山田祥寛《監修》
今度は、目的を達成する置換結果となりました。「greedy_match.js」との違いは、正規表現パターンの「.+」(1文字以上の任意の文字)を「[^】]」(1文字以上の「】以外」の文字)に変えただけです。マッチングの対象が「】以外」に変わり、「】」が現れた時点でマッチング結果が確定したことで、目的が達成できたわけです。
このように対処できるなら、「貪欲」さは問題でないのかもしれません。しかし、以下の「greedy_match_complex.js」の例はどうでしょうか?
const regexp = /[[【]([^】]]+)[】]]/g; const str = '山内直【著】山田祥寛[監修]'; console.log(str.replaceAll(regexp, '《$1》')); // 山内直《著》山田祥寛《監修》
かっこの組み合わせに応じて、除外する文字の追加が必要になりました。これはメンテナンス性を下げるばかりか、指定ミスによるバグの原因にもなります。同じものを何度も繰り返し記述することは、正規表現に限らず避けるべきだからです。
正規表現では、対象の文字列の先頭からパターンに一致する文字を順番に探していきます。例えば「【(.+)】」において、「【」が見つかったら、次は「.+」に従って探していきます。これはあらゆる文字を含むので、たとえ「】」が見つかってもそれは「.+」に一致しますから、その先も探し続けて行末までたどり着いたところで探索は終了します。終了はしますが、「】」が処理されていないので、今度は行末から行頭に向かってさかのぼります。そして、「】」が見つかれば、一致したと見なされてマッチングは成功します。結果として、行末に最も近い「】」までが一致します。「.+」の後に「】」が指定されているからといって、それをあらかじめ見つけておいてはくれません。これには、次の「非貪欲な数量詞」を指定します。
最長マッチを回避するために、「貪欲」を「非貪欲」「怠惰(lazy)」にする数量詞が用意されています。これについて紹介しましょう。
プラス(+)は、直前の項目の1回以上の繰り返しにマッチする数量詞です。これに疑問符(?)を続けることで、直前の項目の、1回以上の「最短の」繰り返しにマッチする指定になります(最短マッチ。他の数量詞も同じ)。以下の「lazy_match.js」は、先ほどのgreedy_match_no.jsを「+?」を用いて書き換えた例です。
const regexp = /【(.+?)】/g; const str = '山内直【著】山田祥寛【監修】'; console.log(str.replaceAll(regexp, '《$1》')); // 山内直《著》山田祥寛《監修》
同じく、期待通りの結果となっています。「?」を加えるだけなので、一見して解釈に悩むようなこともなく、スッキリとした正規表現パターンです。同様に、greedy_match_complex.jsも「+?」を用いて書き換えてみたのが、以下の「lazy_match_complex.js」です。
const regexp = /[[【](.+?)[】]]/g; const str = '山内直【著】山田祥寛[監修]'; console.log(str.replaceAll(regexp, '《$1》')); // 山内直《著》山田祥寛《監修》
こちらも、スッキリとしました。パターンが必要以上に複雑にならないので、メンテナンス性も向上します。
この回では、ECMAScriptにおける数量詞の詳細について、境界アサーションを絡めて貪欲、非貪欲(怠惰)な数量詞の存在とともに紹介しました。
次回は、すでに使ってきたgフラグをはじめとする、便利なマッチングのためのフラグについて紹介します。
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.