ぶちのブログ

競プロとCTFが趣味なWebエンジニアのアウトプットの場

Ruby2.7.0から追加された、Enumerable#filter_mapとEnumerable#tallyの可読性の比較と速度検証

TL;DR

Ruby2.7.0のEnumerable#filter_mapEnumerable#tallyはどちらも従来より速い上に、可読性も高く、積極的に取り入れていきたいメソッドだった。

はじめに

Ruby2.7.0のリリースノートを読んで、Enumerable#filter_mapEnumerable#tallyの2つのメソッドが良さそうと感じたので、従来の書き方と比較しつつ、速度も検証してみました。

Rubyのバージョン

$ ruby -v
ruby 2.7.0p0 (2019-12-25 revision 647ee6f091) [x86_64-linux-musl]

filter_mapの検証

使用したコード

require 'benchmark'

N = 10_000
range = 1.upto(10_000)

Benchmark.bmbm do |x|
  x.report('select + map') do
    N.times { range.select(&:even?).map { |i| i + 1 } }
  end
  x.report('select + map!') do
    N.times { range.select(&:even?).map! { |i| i + 1 } }
  end
  x.report('map + compact') do
    N.times { range.map { |i| i + 1 if i.even? }.compact }
  end
  x.report('map + compact!') do
    N.times { range.map { |i| i + 1 if i.even? }.compact! }
  end
  x.report('filter_map') do
    N.times { range.filter_map { |i| i + 1 if i.even? } }
  end
end

結果

                     user     system      total        real
select + map    16.550000   0.420000  16.970000 ( 16.982197)
select + map!   16.880000   0.190000  17.070000 ( 17.085424)
map + compact   14.560000   0.570000  15.130000 ( 15.145185)
map + compact!  14.900000   0.320000  15.220000 ( 15.245962)
filter_map      13.870000   0.130000  14.000000 ( 14.027301)

破壊的メソッドを使っている2つは、途中に生成されるArrayを減らせるから速くなる気がしたのですが、むしろ遅くなり謎でした。
とりあえずこの2つは無視します。

途中に生成されるArrayが小さいmap + compactのほうがselect + mapよりも性能は良かったです。
それ以上にArrayを途中で作らないfilter_mapが1~2割ほど高速でした。

可読性に関しても、filter_mapが一番スッキリしていますね。

tallyの検証

使用したコード

# frozen_string_literal: true

require 'benchmark'

N = 10_000
rands = 10_000.times.map { rand(10) }

Benchmark.bmbm do |x|
  x.report('group_by + to_h + count') do
    N.times { rands.group_by { _1 }.to_h { |k, v| [k, v.count] } }
  end
  x.report('each_with_object') do
    N.times { rands.each_with_object({}) { |rand, result| result[rand] = result[rand] ? result[rand] + 1 : 1 } }
  end
  x.report('each_with_object 2') do
    N.times { rands.each_with_object((0..9).to_h { |i| [i, 0] }) { |rand, result| result[rand] += 1 } }
  end
  x.report('tally') do
    N.times { rands.tally }
  end
end

結果

                              user     system      total        real
group_by + to_h + count  12.040000   0.010000  12.050000 ( 12.053547)
each_with_object         25.830000   0.000000  25.830000 ( 25.855872)
each_with_object         21.800000   0.010000  21.810000 ( 21.825286)
tally                     7.590000   0.070000   7.660000 (  7.675483)

今まではgroup_by + to_h + countで書いていました。
一応愚直なeach_with_objectでも試しましたが、読みにくい上に遅かったです。 each_with_object 2は少しズルをして場合分けを減らしていますが、それでも遅いです。Hashの順番も他と異なります。

tallyめっちゃ速い上に、めっちゃ読みやすい!!!
Ruby2.7.0すごい!!!!!!

おまけ

一応、中の実装がCであることも確かめました。(自分のRubyの書き方が悪いということもあり得るので)

irb(main):001:0> [].method(:filter_map).source_location
=> nil
irb(main):002:0> [].method(:tally).source_location
=> nil

とりあえず、どちらもCで実装されているっぽいです。

おわりに

簡単な検証なので、要素数や要素のcardinalityを固定にしているので、常に有効かどうかは確かめられていません。
しかし、どちらのメソッドも速そうな上に、可読性も高く、積極的に取り入れていきたいメソッドでした。

すべてのコードが記事中に載っていますが、githubも公開しておきます。 https://github.com/tabuchi0919/ruby2_7_sandbox