シンボルでも文字列でもアクセス可能なHashを使おう!ActiveSupport {Hash編}

皆さんこんにちは!遅れてきた最年長ルーキー MUGENUP の osada です。

さて皆さんは初めてオープンクラスという概念を知った時、驚かれませんでしたか?私はとても驚きました!だって、基本クラスさえもオーバライド可能なんですよ?

[コラム]オープンクラスとは?

同名でクラス定義を行うと、クラスの再定義(上書き)ではなく、クラスへの追加拡張になる仕組み。

class A
  def a
    "a"
  end
end

a =  A.new
raise unless a.a == "a"

raise if a.respond_to?(:b)
# b というメソッドはない

# 同名のクラス定義は拡張になる
class A
  def b
    "b"
  end
end

raise unless a.b == "b"
raise unless a.respond_to?(:b)

# b というメソッドがある

# a というメソッドも維持される
raise unless a.respond_to?(:a)
raise unless a.a == "a"

このオープンクラス、Stringなど基本クラスさえも拡張可能なのが凄いです。Javascript の prototype みたいですよね。

ActiveSupport コア拡張

オープンクラスという特徴的な仕組みのおかげで、Rails では基本クラスなのに便利なメソッドが沢山ある、という状態になっています。それがActiveSupportのコア拡張です。

ということで、今日のお題はActiveSupportHashです。ターゲットはRails初心者の方。一緒に勉強できれば嬉しいです。

Hash 編

hash拡張のソースは、ここにありました。9ファイル、19メソッドあります。 ruby/2.0.0/gems/activesupport-3.2.16/lib/active_support/core_ext/hash

conversion.rb

まずは Hash 変換するconversion.rbです。といっても対象はxml。そういえばそんなのもありましたね!

{"foo" => 1, "bar" => 2}.to_xml

できそうな気がしてましたよね?

Hash.from_xml(<<"EOS"
<?xml version="1.0" encoding="UTF-8"?>
<hash>
  <foo type="integer">1</foo>
  <bar type="integer">2</bar>
</hash>
EOS)

できそうな気がしてましたよね?

deep_dup.rb

rubydup, clone はシャローコピーだというのはよく知られています。

シャローコピー?という方はRubyist Magazine - 値渡しと参照渡しの違いを理解するがオススメ

deep_dup は深いコピーをしてくれます。当然再帰ですよね〜。

  def deep_dup
    duplicate = self.dup
    duplicate.each_pair do |k,v|
      tv = duplicate[k]
      duplicate[k] = tv.is_a?(Hash) && v.is_a?(Hash) ? tv.deep_dup : v
    end
    duplicate
  end

ところで、duplicate[k]v の値が異なることがあるのでしょうか?

deep_merge.rb

深いコピーがあるなら、深いマージもあるでしょう。そうでしょう。

こちらで興味深いのは、破壊メソッドを作っておいて、それを dup と組み合わせれば通常のメソッドになるということです。センスありますね。

  def deep_merge(other_hash)
    dup.deep_merge!(other_hash)
  end

  # Same as +deep_merge+, but modifies +self+.
  def deep_merge!(other_hash)
    other_hash.each_pair do |k,v|
      tv = self[k]
      self[k] = tv.is_a?(Hash) && v.is_a?(Hash) ? tv.deep_merge(v) : v
    end
    self
  end

diff.rb

Hash の差分って、どうされてますか? Array なら マイナス[1,2] - [2,3] # => [1]が使えますが、Hash にはありません。ここでdiffの出番です

しかしこのdiff、 差分ではなく、差異なんです。

[6] pry(main)> { 1 => 2 }.diff({1 => 2, 2 => 2, 3 => 2})
=> {2=>2, 3=>2}

2つのハッシュの相違点を取り出すメソッドなんですね。実装はとてもシンプルで、ハッシュ1とハッシュ2に同じkey を見つけてvalueも同じときは消す。ハッシュ2からハッシュ1にあるkeyを消す。そしてマージです。

  def diff(h2)
    dup.delete_if { |k, v| h2[k] == v }.merge!(h2.dup.delete_if { |k, v| has_key?(k) })
  end

なんと悲しいことに、Rails4 では Deprecated になっていました!

  def diff(other)
    ActiveSupport::Deprecation.warn "Hash#diff is no longer used inside of Rails, and is being deprecated with no replacement. If you're using it to compare hashes for the purpose of testing, please use MiniTest's assert_equal instead."
    dup.
      delete_if { |k, v| other[k] == v }.
      merge!(other.dup.delete_if { |k, v| has_key?(k) })
  end

Hash#deep_diff, the recursive difference between two hashes by nikitug · Pull Request #8142 · rails/rails deep_diffを使えばいいじゃん、ということですが、まだ時期ではない、ということで、deprecatedを付けるだけになっているようです。しばらくは、diffということでしょうか。

with_indifferent_access.rb

params がシンボルでも文字列でもアクセスできて、不思議だなぁ?と思われたことありませんか? 普通は nil が返ってきます。

[8] pry(main)> h = {a: 1, "b" => 2}
=> {:a=>1, "b"=>2}
[9] pry(main)> h[:a]
=> 1
[10] pry(main)> h["a"]
=> nil
[11] pry(main)> h[:b]
=> nil
[12] pry(main)> h["b"]
=> 2

そんな時に使うのか、with_indifferent_accessです!

[13] pry(main)> h = {a: 1, "b" => 2}.with_indifferent_access
=> {"a"=>1, "b"=>2}
[14] pry(main)> h[:a]
=> 1
[15] pry(main)> h["a"]
=> 1
[16] pry(main)> h[:b]
=> 2
[17] pry(main)> h["b"]
=> 2

このメソッドは、HashWithIndifferentAccessへのプロキシメソッドです。

  def with_indifferent_access
    ActiveSupport::HashWithIndifferentAccess.new_from_hash_copying_default(self)
  end

それが何かと言いますと、

module ActiveSupport
  class HashWithIndifferentAccess < Hash

Hash のサブクラスでした。そして、new_from_hash_copying_default(self)

    def self.new_from_hash_copying_default(hash)
      new(hash).tap do |new_hash|
        new_hash.default = hash.default
      end
    end

となっていますので、やっていることは、元 hash の default を自身に適用しているだけです。まさに継承している感じですね。

ではコンストラクタに秘密があるのでしょうか?

    def initialize(constructor = {})
      if constructor.is_a?(Hash)
        super()
        update(constructor)
      else
        super(constructor)
      end
    end

この場合、superHashクラスの呼び出しですから、ミソはupdateにあるようです。あれ?updateってマージするメソッドだったような?

    def update(other_hash)
      if other_hash.is_a? HashWithIndifferentAccess
        super(other_hash)
      else
        other_hash.each_pair { |key, value| regular_writer(convert_key(key), convert_value(value)) }
        self
      end
    end

ハッシュが既にHashWithIndifferentAccessだった場合は、そのままインスタンス化するだけです。するとelseが心臓部のようです。

        other_hash.each_pair { |key, value| regular_writer(convert_key(key), convert_value(value)) }

文字列でもシンボルでも

key 側の処理はこんな感じ。

      def convert_key(key)
        key.kind_of?(Symbol) ? key.to_s : key
      end

単に Symbol だったら文字列に変換しているんですね。ずるい!なんてシンプルな実装なんでしょうか。 このconvert_key を各所で使うため、key は全てシンボルではなく文字列になります。 でしたよね?

[13] pry(main)> h = {a: 1, "b" => 2}.with_indifferent_access
=> {"a"=>1, "b"=>2}

convert_value

value側の処理です。

      def convert_value(value)
        if value.is_a? Hash
          value.nested_under_indifferent_access
        elsif value.is_a?(Array)
          value.dup.replace(value.map { |e| convert_value(e) })
        else
          value
        end
      end

1 . value が Hash だったときは、indifferent_access に変換して返します。 というか単にエリアスが貼ってあるだけでした。

  alias nested_under_indifferent_access with_indifferent_access

普通に

value.with_indifferent_access

と書いても良い所をあえてvalue.nested_under_indifferent_accessと書く所、こだわっていますね。

2 . value がArray のとき、

        elsif value.is_a?(Array)
          value.dup.replace(value.map { |e| convert_value(e) })

再帰して返すというお決まりのコードです。ところでなぜ

value.map { |e| convert_value(e) }

ではいけないのでしょうか?

これはおそらく、is_a? は親クラスであっても true になるからだと思います。 value が Array のサブクラスだった場合、`value.map { |e| convert_value(e) } では、サブクラスではなく、Arrayが返ってしまいますからね。

ということで、心臓部はこの2つのメソッドで、後は全て、conver_*を経由するように書き換えているだけでした。例えば、key?convert_key を経由するように書き換えているだけです。

    def key?(key)
      super(convert_key(key))
    end

シンボルでも文字列でもアクセスできる、というのは keyvalue に対して、根本のところで文字列に変換してしまう、という方法で実装されていました。

これさえ理解しおけば、シンボルでも文字列でも扱えるHashが使いこなせますね!

except.rb

Hash の中から、特定の key だけを消したいこと、結構ありますよね?これは有名なので皆さんご存知かと思います。

[12] pry(main)> {a: 1, b: 2}.except(:a, :c)
=> {:b=>2}
  def except(*keys)
    dup.except!(*keys)
  end

  # Replaces the hash without the given keys.
  def except!(*keys)
    keys.each { |key| delete(key) }
    self
  end

keys.rb

Hash の key に対するメソッド群です。

  • stringfy_keys
  def stringify_keys
    dup.stringify_keys!
  end

  # Destructively convert all keys to strings. Same as
  # +stringify_keys+, but modifies +self+.
  def stringify_keys!
    keys.each do |key|
      self[key.to_s] = delete(key)
    end
    self
  end

delete で取り出しながら、key を変換して格納し直すという、 エレガントな入れ替えですね。

  • 逆にシンボル化もできるようです。
  def symbolize_keys
    dup.symbolize_keys!
  end

  # Destructively convert all keys to symbols, as long as they respond
  # to +to_sym+. Same as +symbolize_keys+, but modifies +self+.
  def symbolize_keys!
    keys.each do |key|
      self[(key.to_sym rescue key) || key] = delete(key)
    end
    self
  end

(key.to_sym rescue key)とか、シンプルでかっこいいですね~。

そういえば、to_options とかご存知でした?

  alias_method :to_options,  :symbolize_keys
  alias_method :to_options!, :symbolize_keys!
  • assert_valid_keys で key の確認を便利にできます。
  def assert_valid_keys(*valid_keys)
    valid_keys.flatten!
    each_key do |k|
      raise(ArgumentError, "Unknown key: #{k}") unless valid_keys.include?(k)
    end
  end

reverse_merge.rb

渡した方の hash にマージするメソッドです。

self.reverse_merge(other_hash)ではなく other_hash.merge(self) でいいんじゃないの?

ところが、面白い箇所が1点! merge!( other_hash ){|key,left,right| left } って何ですか?

  def reverse_merge(other_hash)
    other_hash.merge(self)
  end

  # Destructive +reverse_merge+.
  def reverse_merge!(other_hash)
    # right wins if there is no left
    merge!( other_hash ){|key,left,right| left }
  end

  alias_method :reverse_update, :reverse_merge!

同じkeyがあったとき、左側(self)を優先にできるようです。

[24] pry(main)> {a: 1, b: 2}.reverse_merge!({a: 1, c: 3})
=> {:a=>1, :b=>2, :c=>3}
[25] pry(main)> {a: 1, b: 2}.reverse_merge({a: 1, c: 3})
=> {:a=>1, :c=>3, :b=>2}
[26] pry(main)> {a: 1, b: 2}.merge({a: 1, c: 3})
=> {:a=>1, :b=>2, :c=>3}
[27] pry(main)> {a: 1, b: 2}.merge!({a: 1, c: 3})
=> {:a=>1, :b=>2, :c=>3}

破壊型のときは、自分に取り込むよ! ということで自分が優先なんでしょうか? merge に block が渡せるのは、初めて知りました。勉強になります

slice.rb

特定のkey だけを取り出したいこと、よくありますよね?

[15] pry(main)> a = {a: 1, b:2}
=> {:a=>1, :b=>2}
[16] pry(main)> a.slice(:a, :c)
=> {:a=>1}
[17] pry(main)> a
=> {:a=>1, :b=>2}
  def slice(*keys)
    keys = keys.map! { |key| convert_key(key) } if respond_to?(:convert_key, true)
    hash = self.class.new
    keys.each { |k| hash[k] = self[k] if has_key?(k) }
    hash
  end

特定のkeysだけ取り出して、それ以外は返り値で返すとか、delete っぽくでオシャレ!Hashの分割で使えますね。

[18] pry(main)> a.slice!(:a, :c)
=> {:b=>2}
[19] pry(main)> a
=> {:a=>1}
  def slice!(*keys)
    keys = keys.map! { |key| convert_key(key) } if respond_to?(:convert_key, true)
    omit = slice(*self.keys - keys)
    hash = slice(*keys)
    replace(hash)
    omit
  end

逆に特定のkeys だけを返してくれるメソッドもありました。

[20] pry(main)> a = {a: 1, b:2}
=> {:a=>1, :b=>2}
[21] pry(main)> a.extract!(:a, :c)
=> {:a=>1}
[22] pry(main)> a
=> {:b=>2}
  def extract!(*keys)
    result = {}
    keys.each {|key| result[key] = delete(key) }
    result
  end

まとめ

ということで、何か新しい発見はあったでしょうか? 9ファイル19メソッドのHash拡張でした。 with_indifferent_access, deep_dup, except, slice, slice! 辺りは、どんどん使っていきたいと思います。

便利なメソッドがあったら、是非教えて下さい!

宣伝

MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain