「かなり使えるPHPの正規表現まとめ」にツッコんでみる。

かなり使えるPHPの正規表現まとめ404 Blog Not Found:「PHP使いはもう正規表現をblogに書くな」と言わせないでくれがDisかまして、まあほどよく荒れているわけですが。

Idea*Ideaの中の人も既に突っ込まれているのに「個人的にまだ検証していないのであれですが」なんていいながら次のエントリを書いてますし、たぶん修正する気がないんだな、ということで、あの正規表現にツッコミをいれてみようと思います。

ちなみにはてなブックマークのほうでは、perl正規表現PHP正規表現が違う(ゆえにperlでの例を見てもPHPの参考にならないのでは?)、と思っている人がいるみたいですが、今どきのPHPperlであれば、正規表現に差はほとんどないです。差があるとしても、間違ったマッチを書くよりは少ない差です。

あと、これはブクマコメントにも要旨として書いたのですが「正規表現がいくら強力だからといって、1行ですべてを解決するのはやめたほうがいいこともある」というのは前提として考えておいたほうがいいです。
わけのわからない正規表現は、解決手段と言うよりはパズル的なお遊びだと思った方がいいです。(後述)

メールアドレスにマッチさせる。

前にIWDDで正規表現について書いた時に言ったのが

「到達可能なメールアドレスにマッチさせる正規表現を書くことは不可能」

と言うことでした。
これはまあdankogaiが書いていたような細かい話もありますが、

  • メールサーバによってどこまでをメールアドレスとして認めるかが違っている、ということ
  • @の右側(ドメイン)の表記が一見正しいことと、実際にそのドメインでメールが受けられるかは違う、ということ
  • 指定されたローカル部で到達するかどうかは送信してみなければわからないこと

といった話から考えても言える話です。

それと、PHPでこれをやるとしたら、場面は「入力フォームに入っている文字列に、明らかにメールアドレスでないものが入っていないか調べる」ということに限られるわけで、そこで求められるのは

/^[a-z0-9_.+-]+[@][a-z0-9.-]+$/i

くらいじゃないか、もしくはこの正規表現でも、
ドメイン部分は一応「許されるドメイン表記なら全てマッチする*1」のでともかくとしても、ローカル部が

  • 「許させるローカル部ならこれは全て通過する」でもなく*2
  • 「ここを通過するなら正しいローカル部である」でもない*3

ことから考えると、最悪

/[@]/

でいいじゃないか、という話もありえます。

ま、PHPだったらPEARからMAIL_RFC822持ってきて使うのが現実的なのかな。

ちなみに[@]と単一文字を囲むのは、@が言語によって、さらにある場面で特殊文字的に展開されちゃうから。そうでないところでは@単体でいいし、そう言うところで\@使ってもいいんですけど、\が増えると大変だし。同様に.も\.でなく[.]とする手法があったはずで。

ユーザー名の正規表現

これも何に使うのかが微妙な正規表現ですよね。英語と日本語しかない環境で使うなら、

/([\p{alnum}_]{5,20})/

で、()内にユーザー名が入るようにすればいいんじゃないか、って気がしますが、この場面、マッチさせて何する気なんでしょうね?大文字小文字を区別しないし。

電話番号の正規表現

実際にかかる電話番号かどうか、もしくは、ある文字列が電話番号かどうか、ということを調べるのには正規表現は使えません。前者はともかく、市外局番のない場合を考えれば、例えば221-0011が電話番号なのか郵便番号なのかを判断するのは不可能だからです。

で、電話番号において、区切り文字はある意味見やすくする以上の意味を持っていません。そうすると、

$num = $phone;
$num =~ s/[^0-9]//g;
if($num =~ /^0[1-9]0/ && length($num) == 11){
	# $phone is maybe cellphone , ip-phone or pager;
}elsif{$num =~ /^0/ && length($num) == 10){
	# $phone is maybe telephone;
}else{
	# $phone is maybe invalid;
}

とか、これでどうか、という気になります。
ただし、これは国際電話の表記(+81とかつけるやつ)には対応していませんし、0070,0077の電話番号なんかには10桁いかなかったり、13桁になったりするものがありますし、0800で始まる11桁の電話番号(総務省のサイトに割り当て一覧があります)なんてものもありますし、さらに言えば090で始まりつつ全9桁の電話番号もあったりする(090-310-143)ので、これでもきちんとした判断にはならなかったりします。

IPアドレス正規表現

元ネタに書いてあるやつをコメント付きで展開すると

/^(
	([1-9]?[0-9]|	#0から99か
	1[0-9]{2}|	#100から199か
	2[0-4][0-9]|	#200から249か
	25[0-5])[.]){3}	#250から255までの数字に.がついたのが3つあって
	([1-9]?[0-9]|
	1[0-9]{2}|
	2[0-4][0-9]|
	25[0-5]		#同様0〜255の数字が付く
)$/x

これは、IPv4で、かつ.区切りの数字4組、表記は10進に限る、という制限がつきますね。
詳しくは、WikipediaのIPアドレス表記をご覧下さい。この制限がない場合、ここまで簡単な正規表現でマッチさせるのはさすがに不可能だと思います。コメント欄にもありましたが、それ用の関数を使った方がいいですね。

郵便番号の正規表現

これも、入っている文字列が郵便番号とわかっているとき前提ですよ。もちろん。

/([\d]{3}	#3桁
 (?:
 -[\d]{2}	#5桁
 [\d]{2}?	#7桁
 )?		#5桁,7桁はない場合もある
)/x

まあこんな感じかな。

SSNの正規表現

コメント欄にツッコミがありますが、まあこれでもいいかと。ただ、そのナンバーが正規のものかどうかは(略)

クレジットカード番号の正規表現

/^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6011[0-9]{12}|3(?:0[0-5]|[68][0-9])[0-9]{11}|3[47][0-9]{13})$/

このまま使うとJCBのカードが通りません。
ちなみに、これは何をやっているかというと、

/^(?:
4[0-9]{12}(?:[0-9]{3})?|	#4で始まる13桁か16桁(VISA)
5[1-5][0-9]{14}|		#51から55で始まる16桁(Master,もしくは一部のDiners)
6011[0-9]{12}|			#6011から始まる16桁(Discover Card)
3(?:0[0-5]|[68][0-9])[0-9]{11}|	#300-305,360-369,380-389で始まる14桁(Diners)
3[47][0-9]{13}			#34,37で始まる15桁(Amex)
)$/x

ということなんですが。
とりあえず、Wikipediaのクレジットカード番号一覧を見てもJCB以外にも抜けがあることがわかると思います。
また、どうせクレジットカード番号を求める、ってことは決済に必要なんですよね。
オンライン決済ができるならクレジットカード番号の確認はそちらに任せた方がいいでしょうし、何らかの事情でオンラインで決済出来ないなら*4、これだけでなく、チェックデジットの計算くらいはしたほうがよいと思います。
ちなみに、Luhn algorithmについて調べたところ、Ruby Quiz #122まとめに、

def type
    length = @number.size
    if length == 15 && @number =~ /^(34|37)/
      "AMEX"
    elsif length == 16 && @number =~ /^6011/
      "Discover"
    elsif length == 16 && @number =~ /^5[1-5]/
      "MasterCard"
    elsif (length == 13 || length == 16) && @number =~ /^4/
      "Visa"
    else
      "Unknown"
    end
  end

なんてのがありました。(こちらもJCBが足りないですが)桁数とかはこうやって素直に調べた方がいいと自分も思います。

ドメイン正規表現

元のやつが「ドメイン正規表現」と言いつつURLの正規表現になってるのですが、たぶんURLの正規表現ですよね、これ。
で、ホスト名に_とか使っちゃだめですし、そんなホストはないものとして扱って良いと思います。

m{(https?|ftp)://			#http://,https://,ftp://のみ判定
(
	(?:[A-Z0-9][A-Z0-9-]*[.])*	#英数字で始まって、英数字とハイフンがあって.がついて、
	(?:[A-Z0-9][A-Z0-9-]+)		#同様な感じ(2文字以上)で終わって
	(?: :\d+)?			#ポート番号があるかもしれない
)					#という一連の固まり
}ix					#を、大小文字無関係で、コメント込みで表記

\1,\2(もしくは$1,$2)にプロトコルドメインが入りますので、後は"://”で両者を連結して下さい。もしくは、これをさらに大きく囲む、という手もあるんですが、括弧が多すぎると読みにくいのでそこは個々の対応でよろしくです。
ところどころ出てくる(?: )は、( )と同じものですが、機能が制限されていて*5、()よりも速く動きます。わからなかったり、読みにくかったりすると思ったら(?:の代わりに(を使ってください。必要になりそうになったら思い出してもらえれば。

あと、perl、かつモジュールが導入できるなら、Regexp::Commonを使うことを考えた方がいいです。

 use Regexp::Common;
 ($uri) = 
    $line =~ m/($RE{URI}{HTTP})/;

とかで済むので。*6

PHPだってユーザー多いんだから、似たようなものを作っている人がいるはずです。

URLからドメインを抽出する正規表現

というわけで、ドメインを抽出する正規表現は前の項でやっちゃいました。これなんていうか、冗長な記述ですね。10個にするための数あわせだったりするのかしら。

特定のキーワードを強調表示

これ、\b使っている時点で英語限定ですね。日本語でやるためには形態素解析が必要になります。

おわりに

結局、言えるのは冒頭にも書いたとおり「いくら強力な正規表現でも、それでやったら複雑になりすぎることがある」ということです。1行で書けるからといって一瞬で終わるとは限らないですし、後のことを考えたら正規表現でやらずに、何らかの比較用関数を作った方がいいことも多いですよ、ということで。

だって、いくら比較できると言っても、

(1x$number) =~ m/^(?:1?|(11+?)(?>\1+))$/;

これじゃ、何を比較しているのかわからないですものね。

*1:IPアドレス表記はそれでもはじく

*2:記号などで、許されているにも関わらず通らないものが多いです

*3:.の扱いに問題があります

*4:そんな場合があるのかは知らないですが

*5:ふつうの括弧はマッチした結果をみられますが、こちらはその結果を保存しません。

*6:まあ、それ言っちゃったら他の比較もこれで済む場合があったりするわけですが