132
74

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

JavaScriptで文字列が小文字・大文字・数字を全て含むかどうか判定する方法について

Last updated at Posted at 2020-05-26

タイトルにあるように、文字列が半角英小文字・大文字と半角数字を全て含むかどうかを判定するという機会は少なくありません。特に、文字種の多さがパスワードの強さであるという教義の持ち主である場合に顕著です。もちろん長さは16文字以内です。

さて、この判定は一見単純に見えて一筋縄ではいきません。文字列の条件判定といえば正規表現ですが、「全て含む」という条件をきれいに書くのは少し難しいでしょう。そこで、この記事ではこの条件を判定する諸方法について雑に考察します。

愚直に正規表現を使う方法

正規表現では、「ある文字種をひとつ含む」という条件を書くのは簡単です。例えば半角小文字を含むという文字列は/[a-z]/という正規表現で判定可能です。これを用いれば、正規表現を3回使うことで上述の条件を判定できます。

const ratz = /[a-z]/, rAtZ = /[A-Z]/, r0t9 = /[0-9]/;
function isValidPassword(str) {
  return ratz.test(str) && rAtZ.test(str) && r0t9.test(str);
}

console.log(isValidPassword("abcABC123"));    // true
console.log(isValidPassword("password1234")); // false
console.log(isValidPassword("*********"));    // false

ちなみに、正規表現オブジェクトはこのように変数に入れてキャッシュしたほうが高速のようです。オブジェクト作成のコストの差でしょうか。

ただし、gフラグ付きの場合は同じオブジェクトにtestメソッドを複数回走らせると結果が変わるので注意してください。

1つの正規表現にまとめる方法

一応、3つの正規表現を一つにまとめることは可能です。ES2018から導入された先読み (lookahead assertion) 機能を用いることで、同じ判定を次のように行うことができます。

const rall = /^(?=.*?[a-z])(?=.*?[A-Z])(?=.*?[0-9])/;
function isValidPassword(str) {
  return rall.test(str);
}

console.log(isValidPassword("abcABC123"));    // true
console.log(isValidPassword("password1234")); // false
console.log(isValidPassword("*********"));    // false

これを読み解くことができればそこそこの正規表現力があると言えるかもしれません。この正規表現に3回出てくる(?= )という構文が先読みを表しています。これは、^などと同じく位置にマッチする構文です。つまり、(?=中身)という構文は、その位置から中身にマッチすることができるという条件を表しています。今回これらは^の直後に現れていますから、この位置というのは文字列の先頭を表すことになります。

つまるところ、この正規表現は「文字列の先頭から.*?[a-z]にマッチできる」「文字列の先頭から.*?[A-Z]にマッチできる」「文字列の先頭から.*?[0-9]にマッチできる」という3つの条件を全て満たすということを表していることになります。

ちなみに、パフォーマンスを比べると、1つにまとめた方が速くなります(筆者のMac上のGoogle Chromeで計測)。具体的には、1つにまとめた方が約1.4倍高速です1

残念ながら、なぜ1.4倍という性能差があるのかはよく分かりません。というのも、正規表現が1つだろうと3つだろうと、書かれているロジックは変わらないからです。どちらの方式でも「小文字を含む」「大文字を含む」「数字を含む」という3つの事柄を別々に調べている点が同じですから、正規表現が1つでも、普通に解釈すれば3回の走査が行われるはずです。

可能性は主に2つあります。まずtestの呼び出し回数が少ない点で有利であるという可能性、そして後者の正規表現では何らかの最適化が行われているという可能性です。筆者はパフォーマンスには疎いので残念ながら何が正解なのかはよく分かりません。教えてくださる方を募集しています。

正規表現を使わずに1回で走査する

さて、この問題は別に正規表現を使わなくてもできますよね。ということで、愚直に走査してみましょう。

function isValidPassword(str) {
  let flag = 0;
  for (let i = 0; i < str.length && flag !== 7; i++) {
    const code = str.charCodeAt(i);
    if (0x61 <= code && code <= 0x7a) {
      flag |= 1;
    }
    if (0x41 <= code && code <= 0x5a) {
      flag |= 2;
    }
    if (0x30 <= code && code <= 0x39) {
      flag |= 4;
    }
  }
  return flag === 7;
}

これは明らかに文字列を1回走査するだけで判定しています。パフォーマンスを計測すると、正規表現1つの場合に対して約1.1倍高速です。

正規表現の場合に比べてもあまりパフォーマンスが上がっていない点が不思議ですね。やはり正規表現が最適化されているのかもしれません。一応愚直解の底力を見せたいということでビット演算を使った最適化を入れていますが、それが無ければ同じくらいのパフォーマンスです。

1回走査の正規表現

ところで、上記のプログラムでは、変数flagは明らかに0〜7の8通りを取ります。図にするとこんな感じです。ここで、上向きの矢印(青)は小文字を読んだときの遷移を、右向きの矢印(黄)は大文字を読んだときの遷移を、そして斜めの矢印(緑)は数字を読んだときの遷移を表しています。

automaton.png

よく見ると、これはオートマトンですね。0が始状態で7が受理状態です。先のfor文によるプログラムはこのオートマトンを実装したものであると言えます。

そして、オートマトンは正規表現で表現できることが知られています。それも、プログラミング言語等において拡張された正規表現ではなく、本来の意味での正規表現です。

ということで、このオートマトンを正規表現で表してみましょう。するとこうなります。

function isValidPassword(str) {
  return /^(?:[^a-zA-Z0-9]*(?:[a-z](?:[^A-Z0-9]*(?:[A-Z](?:[^0-9]*[0-9])|[0-9](?:[^A-Z]*[A-Z])))|[A-Z](?:[^a-z0-9]*(?:[a-z](?:[^0-9]*[0-9])|[0-9](?:[^a-z]*[a-z])))|[0-9](?:[^a-zA-Z]*(?:[a-z](?:[^A-Z]*[A-Z])|[A-Z](?:[^a-z]*[a-z])))))/.test(str)
}

この正規表現は文字列を1回しか走査しませんから、ちょっと長いものの、先ほどの正規表現よりも高速であることが期待できます。

……と思って計測してみたのですが、一番最初の正規表現×3と変わらない遅さでした。

まとめ

正規表現なんもわからん(完)

参考リンク

  1. 1.4倍高速というのは、同じ時間で実行できる回数が1.4倍であるということです。

132
74
3

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
132
74

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?