はじめに
パスワード制限に利用する正規表現を調査した際、言語別:パスワード向けの正規表現 の記事に出会いました。
そしてこの表現
/^(?=.*?[a-z])(?=.*?\d)[a-z\d]{8,100}$/i
これは
半角英字と半角数字それぞれ1文字以上含む8文字以上100文字以下の文字列
を意味しています。
正規表現レベル初級の自分にはこの表現がどういう意味なのかよくわからなかったので調査しました。
前提
処理系は JavaScript の正規表現エンジンを想定します。
読解
^と$は何か
それぞれ
^は文字列の先頭の位置
$は文字列の終端の位置
を意味します。それぞれ具体的な文字列としてマッチせずあくまで位置にマッチします。
[a-z\d]{8,100} は何か
これは、「英字大小関わらずaからz、または、0から9の文字中で8字以上100字以下の連続」を意味しています。
[a-z\d]は英数字のうち任意の一文字を意味します。
{8,100}は直前の文字が8回から100回の間繰り返されていることを意味する量指定子(Quantifier)です。 ※1
なので[a-z\d]{8,100}は英数字の任意の文字が8回以上100以下存在しているという条件になります。
※1 補足 {8,100} は下記で言及する貪欲的(greedy)な量指定子の表現です。{8,100}?が非貪欲(lazy)になります。
入力文字列がabc4567890の場合
||正規表現|文字列|マッチ文字列|
|---|---|---|---|---|
|貪欲(greedy)|[a-z\d]{8,100}|abc4567890|abc4567890|
|非貪欲(lazy)|[a-z\d]{8,100}?|abc4567890| abc45678 |
(?=.*?[a-z])(?=.*?\d) は何か
この部分が何を意味しているか。
(?= ) は何か
1番のメイン。
(?=.*?[a-z])の(?= )の部分です。
ずばり名前を肯定的先読みと言います。
この表現では、たとえば、
/(?=xyz)/
という正規表現の場合、**xyzの「直前の位置」**がマッチ対象になります。
肯定的先読み 例
| 正規表現 | 文字列 | マッチ文字列 | |
|---|---|---|---|
| 何も無し | (?=xyz) |
abcxyz |
cとxの間(空文字) |
.付き |
(.?=xyz) |
abcxyz |
c |
.*? は何か
(@scivolaさんのコメントを受け修正しました。)
.は「改行以外の任意の1文字」を意味します。
そして、*?ですが、これはこの2文字で1つの量指定子を意味する、非貪欲的(lazy)と呼ばれる表現です。
非貪欲的な正規表現は、先頭からマッチするパターンを探していき、1パターン分マッチしたらその時点で以降の文字にてマッチ対象を探すのを止めます。これには、以降の無駄なサーチ処理をさせないという意図があります。
一方で貪欲的(greedy)な正規表現(*)は、条件にマッチする箇所を入力文字列の最後までサーチし1つのマッチ文字列を返します。
入力文字列が123abcの場合
| 正規表現 | 文字列 | マッチ文字列 | 補足 | |
|---|---|---|---|---|
| 貪欲(greedy) | .*[a-z] |
123abc |
123abc |
123abまでが.*にあたる |
| 非貪欲(lazy) | .*?[a-z] |
123abc |
123a |
マッチ文字列は複数ありbとcが順に続く |
つまり
(?=.*?[a-z])(?=.*?\d) は、「任意の0回以上の文字列.*?とaからzの1文字[a-z]を条件とした任意の位置の先頭位置(?= )、かつ、任意の0回以上の文字列.*?と数字1文字\dを条件とした任意の先頭位置(?= )」を意味しています。
|正規表現|文字列|マッチ位置(!の位置)|
|---|---|---|---|---|
|(?=.*?[a-z])|abc123| !a!b!c123 |
|(?=.*?\d)|abc123| !a!b!c!1!2!3 |
|(?=.*?[a-z])(?=.*?\d)|abc123| !a!b!c123 |
|^(?=.*?[a-z])(?=.*?\d)|abc123| !abc123 |
ちなみに、(?=.*?[a-z])(?=.*?\d)のように肯定的先読みを使わなくても.*?[a-z].*?\dでも良さそうだと初め思いましたが、これでは英字→数字の順序の依存が出て入力文字列123abcのケースでマッチしないので先読みが必要です。
以上を踏まえて例えば passw0rd12 が入力文字列のとき
pが.*?[a-z]にマッチしているので入力文字列の行頭(^)の時点で(?=.*?[a-z])にマッチしていることになります。
また、passw0が.*?\dにマッチしているので、ここでも入力文字列の行頭(^)の時点で(?=.*?[\d])とマッチしています。
[a-z\d]{8,100}は貪欲マッチなので入力文字10文字分すべてであるpassw0rd12がマッチします。
したがって、正規表現^(?=.*?[a-z])(?=.*?\d)[a-z\d]{8,100}$は入力文字列passw0rd12に対して^passw0rd12$でマッチします。 ※2
※2 補足
入力文字列数が8文字未満のときは[a-z\d]{8,100}の時点でそもそもマッチしませんが、100文字よりも多いとき(?=.*?[a-z])(?=.*?\d)[a-z\d]{8,100}は任意の100文字ちょうど分がマッチします。しかし^と$が両端に存在しておりどちらかはマッチできないため、100文字よりも多い場合はその入力文字列はマッチ対象ではならなくなります。
まとめ
「英字大小関わらずaからz、または、0から9の文字中で8字以上100字以下の連続」 において、「英字と数字が両方ある」という条件付き と 解読できました。