正規表現を書き終えてテストすると、あるデータでは一致し、別のデータでは一致しない。これはよくある光景です。本当に時間を食うのは「なぜ合わないのか」の部分です。以下に挙げる 3 つの罠でほとんどのケースをカバーでき、しかも確認する順序が決まっています。
まず 2 種類の失敗を切り分ける
一致しないのと一致したが中身を取り違えるのは別種の問題です。前者はたいてい文字クラスかフラグの書き間違いで、後者は量指定子の貪欲さとグループ括弧の位置の問題です。どちらなのかを先に決めれば時間が半分になります。デバッグパネルではまず「一致があるか」を見て、次に「グループ 1 に何が入ったか」を見てください。
罠 1:貪欲な量指定子が後ろを食べてしまう
.* は既定で貪欲で、行末まで一気に進んでから後ろのパターンを探してバックトラックします。そのため title="(.*)" を title="a" id="b" に対して使うと、キャプチャグループに入るのは a" id="b で、a ではありません。
直し方は 2 通りあります。量指定子を非貪欲の .*? にするか、より正確な文字クラス [^"]* を使うか。後者のほうが通常は速く、安定もします。非貪欲の量指定子もバックトラックしますが、文字クラスはしないからです。
罠 2:エスケープの階層が 1 つ多い/少ない
正規表現を文字列の中に書くと、バックスラッシュは 2 段階で解釈されます。JavaScript では、正規表現リテラルの \d(数字 1 文字)を文字列から組み立てるときは \\d と 2 つ重ねます。1 つ足りないと d という文字の意味になり、1 つ多いと「バックスラッシュ+d」の意味になります。\s、\.、\/ も同じく 1 段ずれやすいところです。
素早い判定法は、デバッグツールで「最終的な pattern 文字列」を出力し、思っていたものと一致するかを見ることです。これだけで「ちゃんと書いたのに一致しない」という混乱の多くが解消します。
罠 3:m と g の 2 つのフラグ
m を付けない場合、^ と $ は文字列全体の先頭と末尾にしか一致せず、複数行のテキストを行単位で扱おうとすると失敗します。m を付けてはじめて、各行の先頭と末尾を認識します。
g を付けた場合、正規表現オブジェクトは前回一致した位置(lastIndex)を記憶します。ループの中で g 付きの同じ正規表現を使い回すと、2 回目の呼び出しは前回の終了位置から探し始めるため、「一致が漏れている」ように見えます。毎回新しいインスタンスを作るか、lastIndex を 0 に戻してください。
4 ステップの切り分け手順
- まず範囲を狭める:テスト文字列を再現できる最短の長さまで切り詰める。ノイズが少ないほど見通しが良くなります
- 部分ごとに試す:正規表現を
|で分割するか、任意部分を削って、どこから失敗するかを見つける - グループを見る:各キャプチャグループが実際に何を取ったかを確認する。思い込みで判断しない
- 別の入力で検証する:一致すべきデータと一致すべきでないデータを用意し、「過剰一致」がないか確かめる
4 番目が最も飛ばされがちです。対象に一致することは、不要なものまで飲み込まないことを意味しません。「過剰一致」は「不一致」より深刻な結果を招くことがあり、たとえば利用者が入力した HTML の段落全体をプレーンテキストとして置換してしまいます。
ついでに性能の話
(a+)+$ のような入れ子の量指定子は、長い不一致入力に当たると壊滅的なバックトラックを起こし、ブラウザを数秒フリーズさせます。判定は簡単で、入力長を 2 倍にしたとき処理時間が 4 倍以上になるかを見ます。そうなるなら、利用者の入力に対してこうした正規表現を直接使ってはいけません。
ひと言でまとめると
まず「一致しない」のか「取り違えている」のかを切り分け、次に貪欲・エスケープ・フラグの順で確認する。 この順序で進めれば、ほとんどの正規表現の問題は当て推量なしで解決できます。
デバッグパネルで見るべき 3 つの数字
正規表現テスターを開いたら、まずハイライトの見栄えではなく、この 3 つの情報を見てください。
- 一致の総数。0 なら「まったく一致していない」ので、文字クラスかフラグの問題です。0 より大きいが想定より少ないなら、
gフラグの付け忘れか、ある部分が後ろを食べてしまっているかのどちらかです - 各一致の開始位置と終了位置。内容より位置のほうが雄弁です。最初の一致がテキストの末尾まで伸びているなら、貪欲な量指定子が原因です
- グループ 1 に入った内容。グループの中身だけを見れば、「一致していない」と「一致したが取り違えている」を即座に区別できます
順序が重要です。まず総数、次に位置、最後にグループ。逆の順で見ると、ハイライトの色に引きずられて余計に混乱します。
中国語と Unicode の 3 つの盲点
\w は [A-Za-z0-9_] と等価で、漢字は一切含みません。ですから \w+ で「張三」を探しても 0 件になります。中国語に一致させたいなら [一-龥]、より広く取るなら [一-鿿] を使ってください。
. は s モードでない限り改行に一致しません。改行を含む複数行のテキストで .* を使うと、行末までしか届きません。[\s\S]* に変えるか、s フラグを有効にしてください。
\b の単語境界は中国語にはほぼ無効です。中国語の語と語の間にスペースがないため、\b は境界を見つけられません。中国語の分かち書きには正規表現ではなく専用の形態素解析ライブラリを使ってください。
もう 1 点。中国語の句読点は全角文字で、, と , は別のコードポイントです。フォームの検証で半角カンマを想定して書くと、利用者が全角カンマを入力した時点で失敗します。
テストケース表で正規表現を固定する
「テストでは通った」は「正規表現が正しい」と同じではありません。確実な方法は、正規表現ごとに 3〜5 件のケースを用意し、小さな表にすることです。
- 一致すべきもの:通常の入力、境界の入力(最短、最長、ちょうど境界に触れるもの)
- 一致すべきでないもの:空文字列、空白のみ、前後に余計なものが付いた汚いデータ
- 誤一致しやすいもの:似ているが別種の入力。たとえば電話番号の検証で「12 桁の数字」や「1 で始まる固定電話番号」をぶつけてみる
この表の価値は変更後に現れます。半年後に誰かがこの正規表現を直しに来たとき、ケース表を流せば既存の挙動を壊していないかがわかります。表がなければ、どんな変更も当てずっぽうになります。
正規表現を使うべきでない場面
正規表現は万能ではなく、使うと必ず破綻する場面があります。
- 入れ子のある HTML や XML の解析。タグはいくらでも入れ子になり、正規表現には再帰能力がありません。
<div>.*?</div>は常に最初の閉じタグまでしか取れず、外側の構造は完全に崩れます。DOM パーサーを使ってください - JSON の解析。文字列の中に任意のエスケープや波括弧が現れ得るため、正規表現は誤判定します。
JSON.parseを使ってください - メールアドレスの完全な妥当性検証。RFC 5322 が許す表記は極めて複雑で、正規表現で 100% の正確さを目指すのは書きにくく保守も困難です。実用的には長さを制限し、
@が 1 つとドットが最低 1 つあることを求めたうえで、確認メールを送るのが正解です - 極端に長い入力の処理。入れ子の量指定子を含む正規表現は、数万文字の不一致入力に当たると指数的にバックトラックし、ブラウザを固めます。大きなテキストを扱う前に長さの上限を設けてください
判断基準は簡単です。対象の構造が任意の入れ子や再帰を許すか? 許すなら、正規表現は使わないでください。