お題
あるシステムでは,ログインに使う ID をユーザーが自分で決めることになっています。
ただし,その ID は以下の条件をすべて満たさなければなりません。
- ASCII の範囲の英数字(アルファベットと数字)のみからなる
- 8 文字以上,12 文字以内
では,引数として与えられた文字列が ID として認められるものかどうかを true
/false
で返すメソッドを書いてください。
※文字列処理の練習問題なので,既存の ID と重複していないかは考えなくてよいことにします。
なお,「ASCII の範囲の」と書いたのは,要するに全角の「G」とか「7」とかは認めない,ということです。
以下では,記述を簡潔にするために「ASCII の範囲の英数字」を単に「英数字」と略して書くことにします。
コード
コード 1
def valid_id?(str)
str.match?(/[a-zA-Z\d]{8,12}/)
end
コード 2
def valid_id?(str)
str.match?(/^[a-zA-Z\d]{8,12}$/)
end
問題点
コード 1 も 2 も間違っています。どちらもよく見かける間違いです。
コード 1
コード 1 で使われている正規表現は,「英数字列」を表す文字クラス [a-zA-Z\d]
の書き方も,「8 回以上,12 回以下の繰り返し」を表す量指定子 {8,12}
の書き方も合っています。
この正規表現は確かに「8 文字以上,12 文字以下の英数字列」を意味するのです。
しかし,String#match? の動作を誤解しています。
このメソッドは「文字列の中に,正規表現にマッチする部分文字列が存在するか」を返すものです。
ただし,「部分文字列」といっても,全体の場合もあります1。日常語では「部分」といえば全体より小さいものですが,数学における部分集合などと同様に,全体と一致する場合も含めて考えます。
したがって,「8 文字以上,12 文字以下の英数字列」を含んでさえいれば true
を返してしまうのです:
p "あABCDEFGHう".match?(/[a-zA-Z\d]{8,12}/) # => true
これでは役に立ちません。
なお,String#match? の代わりに String#match を使うと返り値が「MatchData
オブジェクトまたは nil
」となりますが,その点を除いて同様の結果になります:
p "あABCDEFGHう".match(/[a-zA-Z\d]{8,12}/) # => #<MatchData "ABCDEFGH">
それから,String#=~ に変えると返り値が「見つかった部分文字列の開始位置または nil
」になりますが,やはりその点を除いて同様の結果になります:
p "あABCDEFGHう" =~ /[a-zA-Z\d]{8,12}/ # => 1
さらに,文字列と正規表現をひっくり返しても同様です:
p /[a-zA-Z\d]{8,12}/.match?("あABCDEFGHう") # => true
p /[a-zA-Z\d]{8,12}/.match("あABCDEFGHう") # => #<MatchData "ABCDEFGH">
p /[a-zA-Z\d]{8,12}/ =~ "あABCDEFGHう" # => 1
似たようなメソッドがいくつもあるわけですが,この記事のお題のように,「正規表現にマッチする部分文字列の有無だけ」が知りたい場合2,match?
を用いるのが定石です。高速でメモリー消費が少なく,$&
などの値を変更しないからです。
コード 2
コード 2 は上記のような失敗を避けようと,アンカーを使っています。
正規表現のアンカー(anchor,錨)とは,平たく言えば位置を指定するものの総称です。
コード 2 を再掲します:
def valid_id?(str)
str.match?(/^[a-zA-Z\d]{8,12}$/)
end
コード 1 との違いは,^
と $
という二種類のアンカーを置いたことです。
この正規表現を書いた人は,「文字列の先頭から『8 文字以上,12 文字以下の英数字列』が続き,そのあとが文字列末尾になっている」というつもりだったのでしょう。
この正規表現がそういう意味なのであれば合っています。match?
が部分文字列の存在を確認するものだといっても,両端をアンカーで押さえているので,全体にマッチするかどこにもマッチしないかのどちらかだからです。
しかし,残念ながら ^
と $
は文字列の先頭・末尾を意味しません。よく誤解されるところです。
^
は文字列の先頭ではなく「行頭」です。
$
は文字列の末尾ではなく「行末」です。
そのため,以下のようなことが起こります。
p "あ\nABCDEFGH\nう".match?(/^[a-zA-Z\d]{8,12}$/) # => true
要するに A
の前が行頭,H
のあとが行末になっているわけですね。
なお,正規表現における行頭・行末は正規表現エンジンによって微妙に仕様が異なります。Ruby の正規表現がある程度ちゃんと分かっている人の中でも,^
と $
を完璧に把握している人は少ないのではないかと思います。私は完璧に把握してないほうの人です(すぐ忘れる)。
この点に興味のある方は以下の拙記事をどうぞ。
Rubyの行末 - Qiita
改善
改善というよりバグフィクスとなります。
コード 2 は,使うアンカーが間違っていただけで,考え方は合っていました。
正しい「文字列先頭」「文字列末尾」のアンカーはそれぞれ \A
,\z
です(A
が大文字であるのに対し z
が小文字であることに注意!)。
よって,正しいコードは
def valid_id?(str)
str.match?(/\A[a-zA-Z\d]{8,12}\z/)
end
となります。
Ruby の正規表現の仕様は公式リファレンスで確認しましょう。
正規表現 (Ruby 3.0.0 リファレンスマニュアル)
アンカーについては アンカー の節を。
余談
JavaScript の正規表現では,^
と $
はそれぞれ文字列の先頭・末尾を意味します。
参考:言明 - JavaScript | MDN
そういう意味では Ruby の \A
と \z
に相当します。
ただし,/ /m
のように m
オプションを付けると,行頭・行末になります。
ここで Rubyist が気をつけなければならないのは,Ruby にも m
オプションがあるものの,JavaScript のそれとは全く意味が違う,ということです。
Ruby の m
オプションはメタ文字 .
の意味を変えるものです。m
を付けないと .
は改行にマッチしませんが,付けるとマッチします。
どちらの言語の m
オプションも multiline の頭文字を取ったようです。ややこしい。