RAKSUL TechBlog

ラクスルグループのエンジニアが技術トピックを発信するブログです

【Ruby】Array から Hash を作る方法7選(a.k.a. やっぱり Array#zip はかわいいよ)

ラクスルでサーバサイドエンジニアをやっている小林です。

最近の業務では、主に Ruby を書いています。

さて、Ruby の組み込みライブラリにはいろいろな便利メソッドがありますが、
みなさん推しメソッドはありますか?

個人的推しメソッドは Array#zipHash#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_objectEnumerable#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 のかわいさが少しは伝わったでしょうか?