1
0

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 1 year has passed since last update.

【初心者向け】Ruby のまずいコード 25 本Advent Calendar 2021

Day 13

【Ruby のまずいコード】ID として可能な文字列か判定

Last updated at Posted at 2021-12-12

お題

あるシステムでは,ログインに使う 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

似たようなメソッドがいくつもあるわけですが,この記事のお題のように,「正規表現にマッチする部分文字列の有無だけ」が知りたい場合2match? を用いるのが定石です。高速でメモリー消費が少なく,$& などの値を変更しないからです。

コード 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 の頭文字を取ったようです。ややこしい。

  1. また,「部分文字列」は長さ 0 の文字列(つまり空文字列)の場合もあります。/^/ という正規表現は「行頭にある空文字列」という部分文字列にマッチします。

  2. つまり,見出した部分文字列の位置とかキャプチャー文字列などが必要ない場合。

1
0
0

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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?