esm アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

Rubyで任意の文字列を削除するメソッドの速度を比較してみた

はじめに

こんにちは、wai-doiです。

Railsアプリケーションを開発していて、文字列の削除をするコードを書くことがあると思います。 例えば以下のようなコードです。

zip_code = '123-4567'
zip_code.gsub(/-/, '')
#=> 1234567

この例では String#gsub を使いましたが、他のメソッドでも同様のことを実現することができます。 私はそのたびに、どの書き方が良いのか迷っていました。そこで今回は速度の観点でどのメソッドを使うのがよいのかを測定してみました。

実行環境

速度の測定には、簡単に処理速度を計測することができる benchmark-ips を用いました。

github.com

今回の実行環境です。

  • Ruby (3.0.0)
  • Active Support (6.1.3.1)
  • benchmark-ips (2.8.4)

測定その1

郵便番号からハイフン - 1 文字を削除するときの速度を測定します。以下の観点を比較してみることにしました。

  • 比較するメソッドはString#tr, String#delete, String#sub, String#gsub, String#remove の 5 種類。
  • 非破壊的と破壊的な変更をするメソッドはどちらが速いのか。
  • 置換対象の引数に文字列でも正規表現でもとることができるメソッドはどちらが速いのか。

郵便番号を表す文字列から - 1文字を削除する Ruby のコードを実行します。String#remove を使うため Active Support を require しています。

require 'active_support/core_ext/string'
require 'benchmark/ips'

Benchmark.ips do |x|
  x.report('String#tr') { '123-4567'.tr('-', '') }
  x.report('String#tr!') { '123-4567'.tr!('-', '') }

  x.report('String#delete') { '123-4567'.delete('-') }
  x.report('String#delete!') { '123-4567'.delete!('-') }

  x.report('String#sub (string)') { '123-4567'.sub('-', '') }
  x.report('String#sub (regexp)') { '123-4567'.sub(/-/, '') }

  x.report('String#sub! (string)') { '123-4567'.sub!('-', '') }
  x.report('String#sub! (regexp)') { '123-4567'.sub!(/-/, '') }

  x.report('String#gsub (string)') { '123-4567'.gsub('-', '') }
  x.report('String#gsub (regexp)') { '123-4567'.gsub(/-/, '') }

  x.report('String#gsub! (string)') { '123-4567'.gsub!('-', '') }
  x.report('String#gsub! (regexp)') { '123-4567'.gsub!(/-/, '') }

  x.report('String#remove (string)') { '123-4567'.remove('-') }
  x.report('String#remove (regexp)') { '123-4567'.remove(/-/) }

  x.report('String#remove! (string)') { '123-4567'.remove!('-') }
  x.report('String#remove! (regexp)') { '123-4567'.remove!(/-/) }

  x.compare!
end

benchmark-ips の出力は以下になりました。

Warming up --------------------------------------
           String#tr   402.521k i/100ms
          String#tr!   434.153k i/100ms
       String#delete   453.843k i/100ms
      String#delete!   519.036k i/100ms
 String#sub (string)   245.331k i/100ms
 String#sub (regexp)   240.213k i/100ms
String#sub! (string)   262.920k i/100ms
String#sub! (regexp)   255.543k i/100ms
String#gsub (string)   120.926k i/100ms
String#gsub (regexp)    63.566k i/100ms
String#gsub! (string)
                       117.623k i/100ms
String#gsub! (regexp)
                        63.364k i/100ms
String#remove (string)
                        56.069k i/100ms
String#remove (regexp)
                        37.356k i/100ms
String#remove! (string)
                        67.638k i/100ms
String#remove! (regexp)
                        42.655k i/100ms
Calculating -------------------------------------
           String#tr      3.933M (± 2.4%) i/s -     19.724M in   5.018301s
          String#tr!      4.370M (± 1.6%) i/s -     22.142M in   5.067534s
       String#delete      4.514M (± 1.9%) i/s -     22.692M in   5.028565s
      String#delete!      5.081M (± 2.0%) i/s -     25.433M in   5.007540s
 String#sub (string)      2.419M (± 1.6%) i/s -     12.267M in   5.071963s
 String#sub (regexp)      2.385M (± 3.1%) i/s -     12.011M in   5.041188s
String#sub! (string)      2.600M (± 3.5%) i/s -     13.146M in   5.062776s
String#sub! (regexp)      2.539M (± 1.9%) i/s -     12.777M in   5.035136s
String#gsub (string)      1.186M (± 2.6%) i/s -      5.925M in   5.001154s
String#gsub (regexp)    630.950k (± 3.6%) i/s -      3.178M in   5.044408s
String#gsub! (string)
                          1.074M (± 4.8%) i/s -      5.411M in   5.050392s
String#gsub! (regexp)
                        528.834k (± 1.7%) i/s -      2.661M in   5.033851s
String#remove (string)
                        487.230k (± 2.8%) i/s -      2.467M in   5.067225s
String#remove (regexp)
                        349.516k (± 4.1%) i/s -      1.756M in   5.032162s
String#remove! (string)
                        677.874k (± 1.7%) i/s -      3.450M in   5.090182s
String#remove! (regexp)
                        427.461k (± 1.9%) i/s -      2.175M in   5.091096s

Comparison:
      String#delete!:  5080887.5 i/s
       String#delete:  4514317.0 i/s - 1.13x  (± 0.00) slower
          String#tr!:  4370478.5 i/s - 1.16x  (± 0.00) slower
           String#tr:  3932740.8 i/s - 1.29x  (± 0.00) slower
String#sub! (string):  2600367.7 i/s - 1.95x  (± 0.00) slower
String#sub! (regexp):  2538594.2 i/s - 2.00x  (± 0.00) slower
 String#sub (string):  2419134.9 i/s - 2.10x  (± 0.00) slower
 String#sub (regexp):  2385105.6 i/s - 2.13x  (± 0.00) slower
String#gsub (string):  1185649.3 i/s - 4.29x  (± 0.00) slower
String#gsub! (string):  1073853.4 i/s - 4.73x  (± 0.00) slower
String#remove! (string):   677873.7 i/s - 7.50x  (± 0.00) slower
String#gsub (regexp):   630949.8 i/s - 8.05x  (± 0.00) slower
String#gsub! (regexp):   528833.6 i/s - 9.61x  (± 0.00) slower
String#remove (string):   487230.2 i/s - 10.43x  (± 0.00) slower
String#remove! (regexp):   427461.0 i/s - 11.89x  (± 0.00) slower
String#remove (regexp):   349516.3 i/s - 14.54x  (± 0.00) slower

測定その 1 の考察

メソッドを速い順に並べると String#delete, String#tr, String#sub, String#gsub, String#remove の順番でした。用途がより限定的なメソッドは速く、より汎用的なメソッドは遅いという結果となりました。便利な String#gsub をいつも使うのではなく、用途に合わせて適切なメソッドを使うことが良いということですね。

非破壊的と破壊的メソッドについては、常にどちらの方が速いかというのは今回の測定ではわかりませんでした。破壊的メソッドの方がオブジェクトを新たに作らないため速いのではないかと予想していましたが、速度のために破壊的メソッドを使うメリットはそれほど無いのかもしれません。

引数に文字列を与えるか正規表現を与えるかですが、常に文字列を与えたときの方が少しだけ速いことがわかりました。それは正規表現のマッチに少し時間がかかるためと考えられます。置換対象の引数が文字列で十分なときは文字列を使うのが良いですね。

また、とても学びになったのは String#remove がとても遅かったということです。Rails で開発しているなら String#remove がせっかく使えるから使おうと私はいままで考えていましたが、考え直す必要があるなと思いました。実装を見たところ、中で String#gsub! を呼び出しているだけなので gsub より遅いのは納得ですね。

測定その2

次に、レシーバーが長い文字列の場合の速度を測定してみました。

先ほどの郵便番号に対して1 万字の長さの文字列をくっつけた文字列をレシーバーにして、削除のメソッドを実行してみました 。比較する観点は、測定その 1 と同じにしています。

Benchmark.ips do |x|
  x.report('String#tr') { ('a' * 10000 + '123-4567').tr('-', '') }
  x.report('String#tr!') { ('a' * 10000 + '123-4567').tr!('-', '') }

  x.report('String#delete') { ('a' * 10000 + '123-4567').delete('-') }
  x.report('String#delete!') { ('a' * 10000 + '123-4567').delete!('-') }

  x.report('String#sub (string)') { ('a' * 10000 + '123-4567').sub('-', '') }
  x.report('String#sub (regexp)') { ('a' * 10000 + '123-4567').sub(/-/, '') }

  x.report('String#sub! (string)') { ('a' * 10000 + '123-4567').sub!('-', '') }
  x.report('String#sub! (regexp)') { ('a' * 10000 + '123-4567').sub!(/-/, '') }

  x.report('String#gsub (string)') { ('a' * 10000 + '123-4567').gsub('-', '') }
  x.report('String#gsub (regexp)') { ('a' * 10000 + '123-4567').gsub(/-/, '') }

  x.report('String#gsub! (string)') { ('a' * 10000 + '123-4567').gsub!('-', '') }
  x.report('String#gsub! (regexp)') { ('a' * 10000 + '123-4567').gsub!(/-/, '') }

  x.report('String#remove (string)') { ('a' * 10000 + '123-4567').remove('-') }
  x.report('String#remove (regexp)') { ('a' * 10000 + '123-4567').remove(/-/) }

  x.report('String#remove! (string)') { ('a' * 10000 + '123-4567').remove!('-') }
  x.report('String#remove! (regexp)') { ('a' * 10000 + '123-4567').remove!(/-/) }

  x.compare!
end
Warming up --------------------------------------
           String#tr     4.700k i/100ms
          String#tr!     5.329k i/100ms
       String#delete     4.399k i/100ms
      String#delete!     5.182k i/100ms
 String#sub (string)    15.998k i/100ms
 String#sub (regexp)     8.603k i/100ms
String#sub! (string)    14.971k i/100ms
String#sub! (regexp)     8.500k i/100ms
String#gsub (string)    11.075k i/100ms
String#gsub (regexp)     5.322k i/100ms
String#gsub! (string)
                        13.001k i/100ms
String#gsub! (regexp)
                         5.132k i/100ms
String#remove (string)
                        10.418k i/100ms
String#remove (regexp)
                         4.718k i/100ms
String#remove! (string)
                        12.948k i/100ms
String#remove! (regexp)
                         5.079k i/100ms
Calculating -------------------------------------
           String#tr     47.354k (± 3.1%) i/s -    239.700k in   5.066950s
          String#tr!     50.061k (±10.0%) i/s -    250.463k in   5.078335s
       String#delete     45.918k (± 6.5%) i/s -    228.748k in   5.005509s
      String#delete!     51.088k (± 5.2%) i/s -    259.100k in   5.088284s
 String#sub (string)    160.722k (±14.8%) i/s -    799.900k in   5.071508s
 String#sub (regexp)     83.337k (± 7.9%) i/s -    421.547k in   5.090275s
String#sub! (string)    149.517k (±11.8%) i/s -    748.550k in   5.070937s
String#sub! (regexp)     83.269k (± 5.1%) i/s -    416.500k in   5.015623s
String#gsub (string)    140.134k (±10.0%) i/s -    697.725k in   5.024230s
String#gsub (regexp)     55.118k (± 3.4%) i/s -    276.744k in   5.026910s
String#gsub! (string)
                        137.273k (± 8.2%) i/s -    689.053k in   5.051651s
String#gsub! (regexp)
                         54.317k (± 6.9%) i/s -    271.996k in   5.036091s
String#remove (string)
                         99.842k (± 6.8%) i/s -    500.064k in   5.031706s
String#remove (regexp)
                         45.780k (± 9.3%) i/s -    231.182k in   5.114578s
String#remove! (string)
                        128.251k (± 8.8%) i/s -    647.400k in   5.085621s
String#remove! (regexp)
                         53.039k (± 3.1%) i/s -    269.187k in   5.080040s

Comparison:
 String#sub (string):   160722.0 i/s
String#sub! (string):   149517.0 i/s - same-ish: difference falls within error
String#gsub (string):   140134.1 i/s - same-ish: difference falls within error
String#gsub! (string):   137273.0 i/s - same-ish: difference falls within error
String#remove! (string):   128250.9 i/s - same-ish: difference falls within error
String#remove (string):    99842.5 i/s - 1.61x  (± 0.00) slower
 String#sub (regexp):    83337.3 i/s - 1.93x  (± 0.00) slower
String#sub! (regexp):    83269.4 i/s - 1.93x  (± 0.00) slower
String#gsub (regexp):    55118.3 i/s - 2.92x  (± 0.00) slower
String#gsub! (regexp):    54317.2 i/s - 2.96x  (± 0.00) slower
String#remove! (regexp):    53039.1 i/s - 3.03x  (± 0.00) slower
      String#delete!:    51087.8 i/s - 3.15x  (± 0.00) slower
          String#tr!:    50060.5 i/s - 3.21x  (± 0.00) slower
           String#tr:    47353.9 i/s - 3.39x  (± 0.00) slower
       String#delete:    45917.8 i/s - 3.50x  (± 0.00) slower
String#remove (regexp):    45779.8 i/s - 3.51x  (± 0.00) slower

測定その 2 の考察

結果は測定その 1 から大きく変わり、長い文字列がレシーバーのときは String#deleteString#tr はとても遅くなってしまうことが分かりました。逆に String#sub, String#gsub, String#remove の方が速くなっていました。

また、String#sub, String#gsub, String#remove において引数が正規表現の場合がとても遅くなってしまうことがわかりました。それは、文字列が長いと正規表現のマッチ処理のステップ数が多くなるため速度に影響してしまったと思われます。

まとめ

今回は任意の文字列を削除するメソッドの速度を測定してみました。今回の知見から速度の面と用途の面で適切なメソッドを選んでいきましょう。