Rubyで文字列が数字列かチェックする

技術,プログラミング言語

久々にプログラミングネタ。Rubyでちょっと文字列が数字列かどうかをチェックしたいと思ったのですが、そのものずばりの方法はないとのこと。色々調べてみると正規表現を使うのが主流のよう。それはそれで良いのですが、モヤっとしたので他の方法を探ってみました。

TL;DR

普通に正規表現を使えば良いと思いますが、条件によっては他の方法も使えるかも。

背景

技術的なことは次の項以降なので、興味がなければ読み飛ばしてください。

以前、Paizaさんのスキルチェックを毎日1題やっては、GitHubにpushして草を生やすという遊びをしていました。最近は小遣い稼ぎの初心者向けに「〇〇と〇〇の違いとは?」みたいな記事を書いてる時間が増えてサボりがち。ちなみに、このブログは一切の収益化をしていません。

それはさておき、ちょっと大量のデータを加工する必要性が出てきて、Rubyを久々に復習してみました。昔、仕事のかたわらでちょっとしたデータ加工とかに使って以来です。そこで色々公式のチュートリアルを復習がてら見てみました。

その中でちょっと気になったのが競プロとかPaizaさんのスキルチェックでありがちなアレ。標準入力から文字列を受け取って数値として扱う方法です。それ自体はto_iメソッドを使うだけです。ただ、個人的にはいきなりto_iメソッドを使うのに少し抵抗があります。また、他のケースでは文字列が数字列かどうかチェックしたい時もあるでしょう。それをRubyでどうするのか調べたが、そのものずばりのメソッドはないとのこと。何ヶ所かWeb記事をチェックしてみましたが、判で押したようにどこも正規表現で解決しています。

それ自体は問題ないし用は足せるのですが、数字列かどうかのチェックをしたいのに正規表現を持ち出すことにちょっとモヤっとしてしまいました。まあ、最終的にどうするかは別として、他の方法はないのかと模索してみたので、それを記します。

memo

to_iメソッドは数字列でないものを与えても普通にエラーなく動きます。そのため、to_iを通す前に数字列かどうかチェックする必要性はありません。
それは確かに便利です。ただ、「2a」みたいな文字列も「2」を返してしまいますし、「abc」や「a2」も0になります。それはそれでケースによっては困ってしまいます。

数字列かどうかチェックしてみる

とりあえずいくつかの方法を試してみます。

数字列かどうかを確認、正規表現方式

数字列かどうかをみたいだけなので正規表現でチェックすればよいというのは自然です。実際、Web検索すると正規表現でやりましょうみたいなWeb記事ばかり出てきます。まずは正攻法で考えましょう。とは言え、すでに解答例が山ほどあるのでその中の一つをそのまま借用させていただきました。

本来ならリンク元を示すべきかとは思いますが、どこのサイトでも同様なコードでしたので特に示しません。オリジナルは"[+-]?"の部分はありませんでした。数字列かどうかだけみたい場合には余計ですが、話の都合で追加しました。

# 正規表現式数値チェック
def isNumericByRegexp?(str)
    nil != (str =~ /\A[+-]?[0-9]+\z/)
end

押してもダメなら引いてみな、変換&逆変換方式

to_iで変換した結果をto_sで戻して、それが最初の文字列に一致すればそれは数字列です。という思考で考えたのがこの方法。to_iを使ってよいかどうかみるためにto_iを使うのは本末転倒ですが、気にしないことにします。

# 変換式数値チェック
def isNumericByTranslate?(str)
        str.to_i.to_s == str
end

やっていることは単純なので、特に説明は必要ないかと思います。
ただ、当たり前ながら問題もあります。例えば「012」みたいな文字列を与えると”012″→12→"12″と変換されるので、一致しない=falseになります。また、仕組み上to_iメソッドがないオブジェクトを渡すと実行時エラーになります。当然ですね。
それを防ぐためにrespond_to?でチェックしてみましたが、格段に遅くなったので割り切りました。

memo

格段に遅くなったと言っても、100万回のループを回しての話。単発で使う分には大きな影響はないとは思います。安全に倒すならばチェックするのはありかと。

to_iが使えなければIntegerを使えばいいのに、Integer方式

to_iは数字列でないものを渡しても例外を投げてくれません。それなら、変換できない時に例外を投てくれるIntegerを使えばよいじゃない。と言うわけで、Integerを使ってみました。

# Integer式数値チェック
def isNumericByInteger?(str)
    begin
        Integer(str)
    rescue
        nil
    end
end

これもto_iを使う前の事前チェックとしては本末転倒ですが、単に数字列かどうかチェックしたい時もあるので気にしないことにします。本来はtrue/falseを返すべきですが、せっかく呼ぶのだからと数字列の時はその数値を、それ以外はnilを戻すようにしています。普通にtrue/falseを返すのでもパフォーマンスには大差ないようです。

問題ないかテストしよう

とりあえず3パターンで数値チェックを実装してみました。単に「123」とか与えた時に数値の123が返ってくるのは確認してあります。とは言え、さまざまなケースを試してみないと使えません。そんなわけで、テストケースを書いてみました。

まずはテストする方のクラス。適当に「check_numeric_string.rb」とかファイル名をつけました。Integer方式もテストの都合で他と戻り値を揃えるべくtrue/falseにしてあります。

class CheckNumericString

    # 変換式数値チェック
    def isNumericByTranslate?(str)
        if str.respond_to?("to_i")
            return str.to_i.to_s == str
        else
            return false
        end
    end

    # 正規表現式数値チェック
    def isNumericByRegexp?(str)
        nil != (str =~ /\A[+-]?[0-9]+\z/)
    end

    # Integer式数値チェック
    def isNumericByInteger?(str)
        begin
            Integer(str)
            true
        rescue
            false
        end
    end
end

これをテストするテストケースはこんな感じ。適当に「check_numeric_strin_test.rb」とかしておきます。

require 'test/unit'
require './check_numeric_string'

def check_pattern(f)
    assert_true(f.call('1'), '1')
    assert_true(f.call('12'), '12')
    assert_true(f.call('012'), '012')
    assert_true(f.call('00012'), '00012')
    assert_false(f.call(''), '')
    assert_true(f.call('+1'), '+1')
    assert_true(f.call('-1'), '-1')
    assert_false(f.call('abc'), 'abc')
    assert_false(f.call('123abc'), '123abc')
    assert_false(f.call('abc123'), 'abc123')
    assert_false(f.call('123\n'), '123\n')
    assert_false(f.call('123\n123'), '123\n123')
    assert_false(f.call('123\nabc'), '123\nabc')
    assert_false(f.call('\n123'), '\n123')
    assert_false(f.call('123'), '123')
    assert_false(f.call('一二三'), '一二三')
    assert_false(f.call('ⅠⅡⅢ'), 'ⅠⅡⅢ')
    assert_false(f.call(nil), 'nil')
    assert_false(f.call(Object.new), 'Object.new')
    assert_false(f.call(Array.new), 'Array.new')
end

class CheckNumericStringTest < Test::Unit::TestCase
    def test_number_regexp?
        m = CheckNumericString.new
        f = m.method(:isNumericByRegexp?)
        check_pattern(f)
    end
    def test_number_translate?
        m = CheckNumericString.new
        f = m.method(:isNumericByTranslate?)
        check_pattern(f)
    end
    def test_number_integer?
        m = CheckNumericString.new
        f = m.method(:isNumericByInteger?)
        check_pattern(f)
    end
end

このテストパターン自体は、検索して見つけたサイトから拝借しました。3つの方式でテストケースを書いて、この20パターンが通るかチェックします。

しかし、結論から言うと変換&逆変換方式はいくつかのテストケースが通りません。上述のように「012」みたいな0詰めに対応できませんし、to_iメソッドがないオブジェクトを渡すとエラーになります。

なお、拝借したサイトでは「+1」と「-1」はfalseになるべきというテストケースになっていましたが、自分はtrueになるべきと判断したので変更しています。何を数字列として通すかは議論の余地があります。

残りの正規表現方式とInteger方式はすべてのチェックを通過します。試せばすぐに結果が出ることなので特に実行結果は引用しません。

パフォーマンスを確認する

変換方式はやや難がありますが、一通り通ることは確認したので、パフォーマンスを確認してみます。Rubyでちょっとしたデータ加工するときにそこまで気にする必要もなさげに思いますが、まあよいに越したことはないですよね。

実際問題、正規表現を使うという事例を見たときに、パフォーマンスがちょっと気になりました。それがこの確認の動機になっています。もう一点は、あまりに正規表現を使う話ばかりなので、他に方法はないのか確認したくなったというのもあります。

それはさておき、こんな感じで100万回ループを回して、それぞれの方式のパフォーマンスを雑に比べてみました。当初はtrueのケースしか見ていませんでしたが、試しにfalseもやったところ大きな差が出たので、やはり手抜きはいかんと再認識したところです。
サクッと書いたものなのでDRY原則に反している部分は大目に見てください。

# 変換式数値チェック
def isNumericByTranslate?(str)
        str.to_i.to_s == str
end

# 正規表現式数値チェック
def isNumericByRegexp?(str)
    nil != (str =~ /\A[+-]?[0-9]+\z/)
end

# Integer式数値チェック
def isNumericByInteger?(str)
    begin
        Integer(str)
    rescue
        nil
    end
end

def measure_true(func, count)
    start_time = Time.now
    foo = '1000'
    count.times do
        bar = func.call(foo)
    end
    puts Time.now - start_time
end
def measure_false(func, count)
    start_time = Time.now
    foo = 'abc'
    count.times do
        bar = func.call(foo)
    end
    puts Time.now - start_time
end

# それぞれ1M回ループして時間を計測
count = 1_000_000
measure_true(BasicObject.method(:isNumericByTranslate?), count)
measure_true(BasicObject.method(:isNumericByRegexp?), count)
measure_true(BasicObject.method(:isNumericByInteger?), count)
puts "---"
measure_false(BasicObject.method(:isNumericByTranslate?), count)
measure_false(BasicObject.method(:isNumericByRegexp?), count)
measure_false(BasicObject.method(:isNumericByInteger?), count)

これを実際に自分の環境で走らせた結果が以下になります。

% ruby check_numeric.rb       
0.168998
0.430253
0.119303
---
0.143399
0.193703
0.968447

trueとfalseで結果が結構違います。最初trueだけ見た時は正規表現方式が際立って遅いと思ったのですが、falseを見てみると今度はInteger方式が極端に遅いです。例外を起こして拾っているので、それなりの犠牲が出るようです。そう言う意味では、割り切れれば変換方式も捨てたものではないかと感じます。

結論

総合的に見ると、よく紹介されている正規表現方式が可もなく不可もなく、それなりに妥当という結果になりました。trueだけ見ればInteger方式が速いのですが。

どちらの場合も変換方式が速いのですが、やはりダメなケースがあるのが難点。それを避けるためにチェックを入れていくと遅くなるので、それなら正規表現を使うというのは正論だと思います。Integer方式と同様に例外を握りつぶす手はあると思いますが。

技術Ruby,プログラミング言語

Posted by woinary