ラクスルでサーバサイドエンジニアをやっている小林です。
最近の業務では、主に Ruby を書いています。
さて、Ruby の組み込みライブラリにはいろいろな便利メソッドがありますが、
みなさん推しメソッドはありますか?
個人的推しメソッドは Array#zip と Hash#transform_values です。
Hash#transform_values について少し紹介すると、
もともと Rails4.2 の ActiveSupport で実装され、
Ruby2.4 で組み込みライブラリに移植されました。
移植の際は、名前をどうするかという議論でとても盛り上がったようです。
また、Ruby2.5 からは姉妹メソッドとも言える Hash#transform_keys が実装されました。
Hash#transform_values の話はこの辺にして、今回は Array#zip の推しポイントを紹介するため、
Array から Hash を作る方法について考えてみようと思います。
コーディングをしていると、下記のようなコードを書きたいことはないでしょうか?
- ActiveRecord で取ってきて、id をkey、インスタンスを value にしたHash を作りたい
- 例) [AR1, AR2, …] ⇒ { id1: AR1, id2: AR2, … }
- 文字列のArrayに対し、元の文字列をkey、正規化後の文字列を value にしたHash を作りたい
- 例) [Str1, Str2, …] ⇒ { Str1: NormalizeStr1, Str2: NormalizeStr2, … }
このようなときの実装方法をいくつかあげ、後半で性能比較をしてみようと思います。
以下、ActiveRecord で User 一覧を取得し、id を key、インスタンスを value とするHash を作成する場合を考えます。
空Hashに追加していく
他の言語でも実装できる、一番オーソドックスな方法かと思います。
array = User.all
hash = {}
array.each do |user|
hash[user.id] = user
end
hash
- array = User.all
- hash = {}
- array.each do |user|
- hash[user.id] = user
- end
- hash
array = User.all
hash = {}
array.each do |user|
hash[user.id] = user
end
hash
Array#to_h を利用する
Ruby 2.1 から Array#to_h というメソッドが追加になっています。
レシーバを[key, value] のペアの配列として、Hash を返します。
これを利用すると、下記のように書くことができます。
array = User.all
array.map { |user| [user.id, user] }.to_h
- array = User.all
- array.map { |user| [user.id, user] }.to_h
array = User.all
array.map { |user| [user.id, user] }.to_h
Array#zip & Array#to_h を利用する
[key, value] のペアを作るのであれば、 Array#zip が便利です。
メソッドチェインですっきりと書けるところが、個人的気に入っています。
これだけでも、Array#zip がかわいいと思えます。
array = User.all
array.map(&:id).zip(array).to_h
- array = User.all
- array.map(&:id).zip(array).to_h
array = User.all
array.map(&:id).zip(array).to_h
Array#transpose & Array#to_h を利用する
レシーバを二次元配列として、転置配列を作成する Array#transpose を利用しても、
同じことができます。
array = User.all
keys = array.map(&:id)
[keys, array].transpose.to_h
- array = User.all
- keys = array.map(&:id)
- [keys, array].transpose.to_h
array = User.all
keys = array.map(&:id)
[keys, array].transpose.to_h
Enumerable#each_with_object / Enumerable#inject を利用する
Array#to_h がない時代は Enumerable#each_with_object や Enumerable#inject を使うことが
多かった気がします。
array = User.all
array.each_with_object({}) do |user, hash|
hash[user.id] = user
end
- array = User.all
- array.each_with_object({}) do |user, hash|
- hash[user.id] = user
- end
array = User.all
array.each_with_object({}) do |user, hash|
hash[user.id] = user
end
Enumerable#index_by を利用する(ActiveSupport)
よくあるパターンなので、ActiveSupport に Enumerable#index_by という、
まさになメソッドがあります。
ただ、こちらは Proc の返り値を key とする Hash を返すので、Array の要素を key、
Proc の返り値を value とする Hash を作りたい場合は、Hash#invert で一手間加える必要があります。
array = User.all
array.index_by(&:id)
- array = User.all
- array.index_by(&:id)
array = User.all
array.index_by(&:id)
Enumerable#reduce & Hash#merge を利用する
Lisp 的な発想で、 Enumerable#reduce と Hash#merge を使って畳み込みを行うことで 、
Hash を作ることもできます。
ちなみに、Ruby の Enumerable#inject とEnumerable#reduce は違う名前ですが、
同じ挙動をします。
少し話がそれますが、なぜ同じ挙動で名前が違うメソッドがあるのかについては、
るびまに書かれているので、読んでみると面白いかもしれません。
array = User.all
array.map {|user| {user.id => user} }.reduce(&:merge)
- array = User.all
- array.map {|user| {user.id => user} }.reduce(&:merge)
array = User.all
array.map {|user| {user.id => user} }.reduce(&:merge)
比較
みなさん、どの方法で実装することが多いでしょうか?
好みやコードの読みやすさなどで意見が分かれそうですが、一指標として、
各実装方法の性能評価をしてみたいと思います。
今回は、Array から Hash に変換する性能のみを評価するため、変換やメソッド呼び出しはせず、
Array から key と value が同じ Hash に変換する場合の性能を比較してみようと思います。
検証コード
ベンチマークの取得には、 Ruby on Rails Guides にも紹介されている
benchmark-ips gem を利用したいと思います。
検証コードは以下のとおりです。
#!/usr/bin/env ruby
require 'active_support/all'
require 'benchmark/ips'
array = (1..10_000).to_a
Benchmark.ips do |r|
r.config(time: 20)
r.report "Empty Hash" do
hash = {}
array.each do |num|
hash[num] = num
end
hash
end
r.report "to_h" do
array.map { |num| [num, num] }.to_h
end
r.report "zip & to_h" do
array.zip(array).to_h
end
r.report "transpose & to_h" do
[array, array].transpose.to_h
end
r.report "each_with_object" do
array.each_with_object({}) do |num, hash|
hash[num] = num
end
end
r.report "index_by" do
array.index_by { |num| num }
end
r.report "reduce & merge" do
array.map { |num| {num => num} }.reduce(&:merge)
end
r.compare!
end
- #!/usr/bin/env ruby
- require 'active_support/all'
- require 'benchmark/ips'
- array = (1..10_000).to_a
- Benchmark.ips do |r|
- r.config(time: 20)
- r.report "Empty Hash" do
- hash = {}
- array.each do |num|
- hash[num] = num
- end
- hash
- end
- r.report "to_h" do
- array.map { |num| [num, num] }.to_h
- end
- r.report "zip & to_h" do
- array.zip(array).to_h
- end
- r.report "transpose & to_h" do
- [array, array].transpose.to_h
- end
- r.report "each_with_object" do
- array.each_with_object({}) do |num, hash|
- hash[num] = num
- end
- end
- r.report "index_by" do
- array.index_by { |num| num }
- end
- r.report "reduce & merge" do
- array.map { |num| {num => num} }.reduce(&:merge)
- end
- r.compare!
- end
#!/usr/bin/env ruby
require 'active_support/all'
require 'benchmark/ips'
array = (1..10_000).to_a
Benchmark.ips do |r|
r.config(time: 20)
r.report "Empty Hash" do
hash = {}
array.each do |num|
hash[num] = num
end
hash
end
r.report "to_h" do
array.map { |num| [num, num] }.to_h
end
r.report "zip & to_h" do
array.zip(array).to_h
end
r.report "transpose & to_h" do
[array, array].transpose.to_h
end
r.report "each_with_object" do
array.each_with_object({}) do |num, hash|
hash[num] = num
end
end
r.report "index_by" do
array.index_by { |num| num }
end
r.report "reduce & merge" do
array.map { |num| {num => num} }.reduce(&:merge)
end
r.compare!
end
実行環境は以下のとおりです。
ruby 2.5.0p0 (2017-12-25 revision 61468) [x86_64-darwin16]
activesupport (5.1.4)
benchmark-ips (2.7.2)
- ruby 2.5.0p0 (2017-12-25 revision 61468) [x86_64-darwin16]
- activesupport (5.1.4)
- benchmark-ips (2.7.2)
ruby 2.5.0p0 (2017-12-25 revision 61468) [x86_64-darwin16]
activesupport (5.1.4)
benchmark-ips (2.7.2)
結果
Warming up --------------------------------------
Empty Hash 80.000 i/100ms
to_h 73.000 i/100ms
zip & to_h 108.000 i/100ms
transpose & to_h 98.000 i/100ms
each_with_object 70.000 i/100ms
index_by 63.000 i/100ms
reduce & merge 1.000 i/100ms
Calculating -------------------------------------
Empty Hash 773.943 (±10.3%) i/s - 15.280k in 20.013161s
to_h 733.483 (± 8.9%) i/s - 14.600k in 20.090466s
zip & to_h 1.065k (±10.3%) i/s - 21.060k in 20.027320s
transpose & to_h 1.005k (± 8.5%) i/s - 19.992k in 20.067946s
each_with_object 719.383 (± 7.1%) i/s - 14.350k in 20.063602s
index_by 654.471 (± 8.6%) i/s - 12.978k in 19.999714s
reduce & merge 0.962 (± 0.0%) i/s - 20.000 in 20.834702s
Comparison:
zip & to_h: 1065.3 i/s
transpose & to_h: 1004.8 i/s - same-ish: difference falls within error
Empty Hash: 773.9 i/s - 1.38x slower
to_h: 733.5 i/s - 1.45x slower
each_with_object: 719.4 i/s - 1.48x slower
index_by: 654.5 i/s - 1.63x slower
reduce & merge: 1.0 i/s - 1107.19x slower
- Warming up --------------------------------------
- Empty Hash 80.000 i/100ms
- to_h 73.000 i/100ms
- zip & to_h 108.000 i/100ms
- transpose & to_h 98.000 i/100ms
- each_with_object 70.000 i/100ms
- index_by 63.000 i/100ms
- reduce & merge 1.000 i/100ms
- Calculating -------------------------------------
- Empty Hash 773.943 (±10.3%) i/s - 15.280k in 20.013161s
- to_h 733.483 (± 8.9%) i/s - 14.600k in 20.090466s
- zip & to_h 1.065k (±10.3%) i/s - 21.060k in 20.027320s
- transpose & to_h 1.005k (± 8.5%) i/s - 19.992k in 20.067946s
- each_with_object 719.383 (± 7.1%) i/s - 14.350k in 20.063602s
- index_by 654.471 (± 8.6%) i/s - 12.978k in 19.999714s
- reduce & merge 0.962 (± 0.0%) i/s - 20.000 in 20.834702s
- Comparison:
- zip & to_h: 1065.3 i/s
- transpose & to_h: 1004.8 i/s - same-ish: difference falls within error
- Empty Hash: 773.9 i/s - 1.38x slower
- to_h: 733.5 i/s - 1.45x slower
- each_with_object: 719.4 i/s - 1.48x slower
- index_by: 654.5 i/s - 1.63x slower
- reduce & merge: 1.0 i/s - 1107.19x slower
Warming up --------------------------------------
Empty Hash 80.000 i/100ms
to_h 73.000 i/100ms
zip & to_h 108.000 i/100ms
transpose & to_h 98.000 i/100ms
each_with_object 70.000 i/100ms
index_by 63.000 i/100ms
reduce & merge 1.000 i/100ms
Calculating -------------------------------------
Empty Hash 773.943 (±10.3%) i/s - 15.280k in 20.013161s
to_h 733.483 (± 8.9%) i/s - 14.600k in 20.090466s
zip & to_h 1.065k (±10.3%) i/s - 21.060k in 20.027320s
transpose & to_h 1.005k (± 8.5%) i/s - 19.992k in 20.067946s
each_with_object 719.383 (± 7.1%) i/s - 14.350k in 20.063602s
index_by 654.471 (± 8.6%) i/s - 12.978k in 19.999714s
reduce & merge 0.962 (± 0.0%) i/s - 20.000 in 20.834702s
Comparison:
zip & to_h: 1065.3 i/s
transpose & to_h: 1004.8 i/s - same-ish: difference falls within error
Empty Hash: 773.9 i/s - 1.38x slower
to_h: 733.5 i/s - 1.45x slower
each_with_object: 719.4 i/s - 1.48x slower
index_by: 654.5 i/s - 1.63x slower
reduce & merge: 1.0 i/s - 1107.19x slower
Array#zip 早いですね!!メソッドチェーンですっきりかける上、処理も早いという、
これは推さざるをえない感じがしませんか?
Array#transpose も Array#zip とほぼ同じくらいの性能ですが、
やはり個人的にはメソッドチェーンで書ける Array#zip のほうが好きですね。
他の手法についても見てみると、Enumerable#index_by が思ったより遅いです。
実装を見たところ、空Hashに追加していく実装と同じなので、
yield の呼び出し分オーバーヘッドがかかっている感じでしょうか。
Enumerable#reduce と Hash#merge を利用する方法は、
配列長分 Hash#merge が実行されるため、かなり遅くなっています。
ただ、実際のコードでは、key と value が同じということはなく、
key や value に対して何かしらの処理を行うため、Hash の作成コストより、
他の処理のオーバーヘッドが大きくなります。
別途、key を Integer#to_s して Hash を作成する場合のベンチマークも取ってみましたが、
空Hash に追加する方法が一番早く、Enumerable#reduce & Hash#merge を除く
実装方法については、それほど変わらないという結果になりました。
#!/usr/bin/env ruby
require 'active_support/all'
require 'benchmark/ips'
array = (1..10_000).to_a
Benchmark.ips do |r|
r.config(time: 20)
r.report "Empty Hash" do
hash = {}
array.each do |num|
hash[num.to_s] = num
end
hash
end
r.report "to_h" do
array.map { |num| [num.to_s, num] }.to_h
end
r.report "zip & to_h" do
array.map(&:to_s).zip(array).to_h
end
r.report "transpose & to_h" do
[array.map(&:to_s), array].transpose.to_h
end
r.report "each_with_object" do
array.each_with_object({}) do |num, hash|
hash[num.to_s] = num
end
end
r.report "index_by" do
array.index_by(&:to_s)
end
r.report "reduce & merge" do
array.map { |num| {num.to_s => num} }.reduce(&:merge)
end
r.compare!
end
=begin
Warming up --------------------------------------
Empty Hash 17.000 i/100ms
to_h 15.000 i/100ms
zip & to_h 19.000 i/100ms
transpose & to_h 18.000 i/100ms
each_with_object 19.000 i/100ms
index_by 18.000 i/100ms
reduce & merge 1.000 i/100ms
Calculating -------------------------------------
Empty Hash 192.959 (± 8.8%) i/s - 3.825k in 20.060582s
to_h 178.314 (± 9.0%) i/s - 3.525k in 20.023982s
zip & to_h 181.005 (±10.5%) i/s - 3.572k in 20.065550s
transpose & to_h 176.782 (± 9.1%) i/s - 3.510k in 20.039329s
each_with_object 192.932 (± 5.2%) i/s - 3.857k in 20.054975s
index_by 182.739 (± 4.4%) i/s - 3.654k in 20.036235s
reduce & merge 0.905 (± 0.0%) i/s - 19.000 in 21.030102s
Comparison:
Empty Hash: 193.0 i/s
each_with_object: 192.9 i/s - same-ish: difference falls within error
index_by: 182.7 i/s - same-ish: difference falls within error
zip & to_h: 181.0 i/s - same-ish: difference falls within error
to_h: 178.3 i/s - same-ish: difference falls within error
transpose & to_h: 176.8 i/s - same-ish: difference falls within error
reduce & merge: 0.9 i/s - 213.18x slower
=end
- #!/usr/bin/env ruby
- require 'active_support/all'
- require 'benchmark/ips'
- array = (1..10_000).to_a
- Benchmark.ips do |r|
- r.config(time: 20)
- r.report "Empty Hash" do
- hash = {}
- array.each do |num|
- hash[num.to_s] = num
- end
- hash
- end
- r.report "to_h" do
- array.map { |num| [num.to_s, num] }.to_h
- end
- r.report "zip & to_h" do
- array.map(&:to_s).zip(array).to_h
- end
- r.report "transpose & to_h" do
- [array.map(&:to_s), array].transpose.to_h
- end
- r.report "each_with_object" do
- array.each_with_object({}) do |num, hash|
- hash[num.to_s] = num
- end
- end
- r.report "index_by" do
- array.index_by(&:to_s)
- end
- r.report "reduce & merge" do
- array.map { |num| {num.to_s => num} }.reduce(&:merge)
- end
- r.compare!
- end
- =begin
- Warming up --------------------------------------
- Empty Hash 17.000 i/100ms
- to_h 15.000 i/100ms
- zip & to_h 19.000 i/100ms
- transpose & to_h 18.000 i/100ms
- each_with_object 19.000 i/100ms
- index_by 18.000 i/100ms
- reduce & merge 1.000 i/100ms
- Calculating -------------------------------------
- Empty Hash 192.959 (± 8.8%) i/s - 3.825k in 20.060582s
- to_h 178.314 (± 9.0%) i/s - 3.525k in 20.023982s
- zip & to_h 181.005 (±10.5%) i/s - 3.572k in 20.065550s
- transpose & to_h 176.782 (± 9.1%) i/s - 3.510k in 20.039329s
- each_with_object 192.932 (± 5.2%) i/s - 3.857k in 20.054975s
- index_by 182.739 (± 4.4%) i/s - 3.654k in 20.036235s
- reduce & merge 0.905 (± 0.0%) i/s - 19.000 in 21.030102s
- Comparison:
- Empty Hash: 193.0 i/s
- each_with_object: 192.9 i/s - same-ish: difference falls within error
- index_by: 182.7 i/s - same-ish: difference falls within error
- zip & to_h: 181.0 i/s - same-ish: difference falls within error
- to_h: 178.3 i/s - same-ish: difference falls within error
- transpose & to_h: 176.8 i/s - same-ish: difference falls within error
- reduce & merge: 0.9 i/s - 213.18x slower
- =end
#!/usr/bin/env ruby
require 'active_support/all'
require 'benchmark/ips'
array = (1..10_000).to_a
Benchmark.ips do |r|
r.config(time: 20)
r.report "Empty Hash" do
hash = {}
array.each do |num|
hash[num.to_s] = num
end
hash
end
r.report "to_h" do
array.map { |num| [num.to_s, num] }.to_h
end
r.report "zip & to_h" do
array.map(&:to_s).zip(array).to_h
end
r.report "transpose & to_h" do
[array.map(&:to_s), array].transpose.to_h
end
r.report "each_with_object" do
array.each_with_object({}) do |num, hash|
hash[num.to_s] = num
end
end
r.report "index_by" do
array.index_by(&:to_s)
end
r.report "reduce & merge" do
array.map { |num| {num.to_s => num} }.reduce(&:merge)
end
r.compare!
end
=begin
Warming up --------------------------------------
Empty Hash 17.000 i/100ms
to_h 15.000 i/100ms
zip & to_h 19.000 i/100ms
transpose & to_h 18.000 i/100ms
each_with_object 19.000 i/100ms
index_by 18.000 i/100ms
reduce & merge 1.000 i/100ms
Calculating -------------------------------------
Empty Hash 192.959 (± 8.8%) i/s - 3.825k in 20.060582s
to_h 178.314 (± 9.0%) i/s - 3.525k in 20.023982s
zip & to_h 181.005 (±10.5%) i/s - 3.572k in 20.065550s
transpose & to_h 176.782 (± 9.1%) i/s - 3.510k in 20.039329s
each_with_object 192.932 (± 5.2%) i/s - 3.857k in 20.054975s
index_by 182.739 (± 4.4%) i/s - 3.654k in 20.036235s
reduce & merge 0.905 (± 0.0%) i/s - 19.000 in 21.030102s
Comparison:
Empty Hash: 193.0 i/s
each_with_object: 192.9 i/s - same-ish: difference falls within error
index_by: 182.7 i/s - same-ish: difference falls within error
zip & to_h: 181.0 i/s - same-ish: difference falls within error
to_h: 178.3 i/s - same-ish: difference falls within error
transpose & to_h: 176.8 i/s - same-ish: difference falls within error
reduce & merge: 0.9 i/s - 213.18x slower
=end
ちなみに、空Hash に追加するという Enumerable#index_by の実装も、
リファクタリングされて現在の形になっています。
まとめ
コーディングの際よくあるパターンとして、Array から Hash を作成する実装方法を7つあげ、
性能比較をしてみました。
個人的推しメソッドである Array#zip のかわいさが少しは伝わったでしょうか?