LoginSignup
16
12

More than 1 year has passed since last update.

実例で学ぶ正規表現の「否定先読み」「肯定先読み」「否定後読み」「肯定後読み」

Last updated at Posted at 2022-11-13

便利だけど分かりにくい「否定先読み」「肯定先読み」「否定後読み」「肯定後読み」について文字の頭や後ろに指定した文字があるかないか、というざっくりした覚え方をしていないでしょうか。

先読みと後読みの基本

書式

書き方 名前 意味
(?=pattern) 肯定先読み 直後にpatternがある
(?!pattern) 否定先読み 直後にpatternが無い
(?<=pattern) 肯定後読み 直前にpatternがある
(?<!pattern) 否定後読み 直前にpatternが無い

意味 結果
for(?=each) 直後に each がある for(forの直後にマッチ) foreach の頭の for にはマッチし、for にはマッチしない
for(?!each) 直後に each がない for(forの直後にマッチ) foreach はマッチされず、for にはマッチする
(?<=for)each 直前に for がある each(eachの直前にマッチ) foreach のお尻の each にはマッチし、each にはマッチしない
(?<!for)each 直前に for がない each(eachの直前にマッチ) foreach はマッチされず、each にはマッチする

ここまで説明しているサイトはよくありますが、これだけだと説明不足です。

先読みと後読みは位置にマッチする

行頭(^)や行末($)は正規表現でよく使うメタ文字ですが、このような位置にマッチするものをアンカーといいます。
先読みと後読みも実はアンカーの一種で、位置にマッチしています。

位置にマッチしているので以下の例で (?=each) は for と each の間にマッチし、each は含まれません。
よって for も each もキャプチャされ、$matches に格納されます。

php
preg_match('/(for)(?=each)(each)/', 'foreach', $matches);
var_dump($matches);
/*
array(3) {
  [0]=>
  string(7) "foreach"
  [1]=>
  string(3) "for"
  [2]=>
  string(4) "each"
}
*/

内部的には一旦forの「(文字を読む)先」にeachパターンがあるかを偵察し、eachパターンがあれば戻ってきてその位置情報を返します。

「直後」「直前」と解釈するから混乱する

これをforの「直後にeachパターンがあるか」と解釈してしまうと「直後なのに先読み??」となり、混乱の元になってしまいます。
軸となるキーワード(ここではfor)の右を「読む先」と解釈し、左を「読む後」と解釈することで混乱を防げます。

改めて表を書き直すと以下のようになります。

書き方 名前 意味
(?=pattern) 肯定先読み 読む先にpatternがある
(?!pattern) 否定先読み 読む先にpatternが無い
(?<=pattern) 肯定後読み 読む後にpatternがある
(?<!pattern) 否定後読み 読む後にpatternが無い
意味 結果
for(?=each) for の読む先に each がある(forの右にマッチ) foreach の頭の for にはマッチし、for にはマッチしない
for(?!each) for の読む先に each がない(forの右にマッチ) foreach はマッチされず、for にはマッチする
(?<=for)each each を読む後に for がある (eachの左にマッチ) foreach のお尻の each にはマッチし、each にはマッチしない
(?<!for)each each を読む後に for がない(eachの左にマッチ) foreach はマッチされず、each にはマッチする

事例でみる先読みと後読み

先読みと後読みをより理解するために事例を交えて説明します。

特定タグで囲まれた文字のみ抽出

html
<div>
  <p>あいうえお</p>
  <a href="#">かきくけこ</a>
  <p class="p">さしすせそ</p>
  たちつてと
</div>
<script>
$(function()
{
  const body_html = $("div").html();
  console.log( body_html.replace(/(?<=<p[^>]*>)([^<>]+)(?=<\/p>)/gm, '<span>$1</span>') );  // 1. pタグの内側にspanタグ
  console.log( body_html.replace(/<p[^>]*>([^<>]+)<\/p>/gm, '<span>$1</span>') );           // 2. pタグをspanタグに差し替え
  console.log( body_html.replace(/(?![^<>]+<\/[^>]+>)(?!<)(?![^<>]+>)([^<>\s]+)/gm, '<p>$1</p>') );  // 3. タグで囲まれていない文字をpタグで囲う
});
</script>

1と2について
肯定後読みで開くタグ、肯定先読みで閉じタグのパターンを指定すると
そのタグパターンは含まれず位置だけマッチするので、
タグの内側を別のタグで囲うことができます。

3について
先読みでパターンチェックすると位置は先読み前の位置に戻ります。
これを利用すると条件のAND判定が可能になります。
この場合は否定条件をANDでつなぎ、前後にタグがない文字列を抽出しています。

英数混じりのパスワードになっているかチェック

javascript
$(function()
{
  $('#pass').on('change', function(){
    if( !$(this).val().match(/^(?=.*[0-9])(?=.*[a-zA-Z])[a-zA-Z0-9]{8,}$/) ){
      alert("パスワードは半角の英字と数字を混ぜ、8文字以上を入力ください")
    }
  })
});

上記の3と同様に先読みでAND判定しています。
こちらの場合は肯定条件のAND判定でパスワードの条件を評価しています。
[a-zA-Z][a-z][A-Z] に分ければ大文字小文字混じりの判定もできます。

数字を3桁ずつカンマ(,)で区切る

javascript
let nums = [1234567, -123456.78901, 123];
for(let num of nums)
{
  console.log( num.toString().replace(/(?<!\.\d*)(\d)(?=(\d{3})+[^\d])/g, '$1,') );
}

否定後読みで小数点が手前にない数字を抽出し、
肯定先読みで数字が3つ続く位置を抽出し、カンマを挿入します。

参考

https://www.javadrive.jp/regex-basic/writing/index2.html
https://www-creators.com/archives/5332
https://zenn.dev/usamik26/articles/regex-lookahead
https://gihyo.jp/dev/serial/01/perl-hackers-hub/005803

16
12
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
12