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

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

## 先読みと後読みの基本

### 書式

書き方       | 名前       | 意味
-------------|------------|---------------------
(?=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: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: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: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]` に分ければ大文字小文字混じりの判定もできます。

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

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

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

## 参考

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