便利だけど分かりにくい「否定先読み」「肯定先読み」「否定後読み」「肯定後読み」について文字の頭や後ろに指定した文字があるかないか、というざっくりした覚え方をしていないでしょうか。
先読みと後読みの基本
書式
書き方 | 名前 | 意味 |
---|---|---|
(?=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 に格納されます。
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 にはマッチする |
事例でみる先読みと後読み
先読みと後読みをより理解するために事例を交えて説明します。
特定タグで囲まれた文字のみ抽出
<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でつなぎ、前後にタグがない文字列を抽出しています。
英数混じりのパスワードになっているかチェック
$(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桁ずつカンマ(,)で区切る
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