ラクスルでサーバサイドエンジニアをやっている小林です。
最近の業務では、主に 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#to_h を利用する
Ruby 2.1 から Array#to_h というメソッドが追加になっています。
レシーバを[key, value] のペアの配列として、Hash を返します。
これを利用すると、下記のように書くことができます。
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#transpose & Array#to_h を利用する
レシーバを二次元配列として、転置配列を作成する Array#transpose を利用しても、
同じことができます。
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
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)
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 から 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
実行環境は以下のとおりです。
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
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
ちなみに、空Hash に追加するという Enumerable#index_by の実装も、
リファクタリングされて現在の形になっています。
まとめ
コーディングの際よくあるパターンとして、Array から Hash を作成する実装方法を7つあげ、
性能比較をしてみました。
個人的推しメソッドである Array#zip のかわいさが少しは伝わったでしょうか?