22Inc. サービス開発日誌

スタンプスのサービス開発チームが日々の業務で得たノウハウ、経験の共有ブログです。

RailsのActiveSupport/core_extのコードリーディングをしてみた(Hash編)

f:id:yamazaki22:20190412165204p:plain
はじめまして。 22Inc.に中途入社しました山崎と申します。 普段はサーバーサイドエンジニアとして、主にRuby on Railsを用いてStampsの機能開発を行っています。

本稿では、Ruby on Railsを構成するライブラリの一つである、ActiveSupport/core_ext にて実装されているメソッドを紹介しようと思います。(最近の私の趣味はRailsのコードリーディングをすることなので、どうせブログを書くなら普段やっていることをネタにしよう…ということでテーマが決まりました笑)

なぜRailsのコードを読むのか

Ruby on Railsは強力なフレームワークですが、強力であるが故に「よく分からないけど動いている」という状態を作りがちです。

しかし、フレームワークがよしなにやってくれるのに依存しすぎると、ひとたびエラーが発生した際の対応に苦慮します。

必要であればフレームワーク自体のコードを読みに行って仕様把握をするスキルがあれば、既存記事のコピペに頼らずとも自ら解決策を捻り出せる可能性は高まります。

そうした「問題解決能力」の高いエンジニアになるために、Railsのコードを一緒に読んでいきましょう!

ActiveSupport/core_extとは

ActiveSupport/core_extはざっくりいうと、Ruby言語をより使いやすくするための拡張機能を提供しているライブラリです。

Railsで書かれたコードを読んでいると、よくblank?present?といった真偽判定のメソッドが出てくると思います。これらはRuby本体で定義されておらず、ActiveSupport/core_extによってObjectクラス内に定義されています。

Objectクラスと同じように、ActiveSupport/core_extはStringやArrayといった基本的なクラスに対してもモンキーパッチを多くあてており、通常のRubyよりも簡潔なコーディング手法を提供しています。

なぜActiveSupport/core_extのコードを読むのか

上述の通り、ActiveSupport/core_extはRubyの基本クラスをオープンして、多くのモンキーパッチをあてています。

それはつまり、ActiveSupport/core_extライブラリを使用することによる影響範囲が大きいことを意味します。普段私達がRuby on Railsを用いてコードを書く際、ArrayやHashといった基本的なクラスを抜きにしてコーディングを行うことは不可能です(よね?)。

ActiveSupport/core_extで定義されるメソッド紹介(Hashクラス)

今回はHashクラスに焦点を当ててコードリーディングをしてみようと思います。

その中でも、Hashクラスに新たに定義している便利メソッドである、deep_mergeを本稿では紹介します。

※以下、Railsのバージョンは5.2.3系であることを想定しています。

deep_merge

deep_mergeの概要

まずはRuby本体で定義されている普通のmergeメソッドの挙動から見ていきましょう。

h1 = { a: true, b: { c: [1, 2, 3] } }
h2 = { a: false, b: { x: [3, 4, 5] } }
h1.merge(h2) #=> {:a=>false, :b=>{:x=>[3, 4, 5]}}

# c: [1, 2, 3] は消えてしまった。。。

上記のように入れ子のハッシュに対してmergeメソッドを用いると、内側のkeyが異なる場合であっても、外側のkey が同じであれば上書きされたハッシュが返ってきてしまいます。

次にActiveSupport/core_ext 内で定義されるdeep_mergeの挙動を見てみましょう。(※なお、以下のコード例はActiveSupport/core_ext/hash/deep_merge.rb 内に実際に記述されています。)

h1 = { a: true, b: { c: [1, 2, 3] } }
h2 = { a: false, b: { x: [3, 4, 5] } }
h1.deep_merge(h2) # => { a: false, b: { c: [1, 2, 3], x: [3, 4, 5] } }
# c: [1, 2, 3] が残っている!

# ブロックを渡した場合
h1 = { a: 100, b: 200, c: { c1: 100 } }
h2 = { b: 250, c: { c1: 200 } }
h1.deep_merge(h2) { |key, this_val, other_val| this_val + other_val }
# => { a: 100, b: 450, c: { c1: 300 } }
# 重複したkeyの処理をブロックで定義することも可能

このように、入れ子ハッシュの深い所まで厳密にkeyの重複があるか否かをチェックした上で、異なる場合は上書きせずに要素を追加します。

実際のコード

deep_mergeは以下のように定義されています。

def deep_merge(other_hash, &block)
    dup.deep_merge!(other_hash, &block)
end

はい、どうやらdeep_mergedeep_merge!をラップしたメソッドのようです。ちなみに、ActiveSupport/core_ext 内では、上記のように破壊的メソッドをラップして非破壊的メソッドを定義するというパターンが散見されます。

deep_merge! について

というわけでdeep_merge!の定義を見ていきましょう(deep_mergeのすぐ下で記述されてます)

def deep_merge!(other_hash, &block)
    merge!(other_hash) do |key, this_val, other_val|
      if this_val.is_a?(Hash) && other_val.is_a?(Hash)
        this_val.deep_merge(other_val, &block)
      elsif block_given?
        block.call(key, this_val, other_val)
      else
        other_val
      end
    end
  end

merge!メソッドをブロック付きで呼び出したあと、3つの条件分岐がされていますね。

各条件を実際のコードを踏まえながら見ていきましょう。以下のコードを実行したとします。

h1 = { a: 100, b: 200, c: { c1: 100 } }
h2 = { b: 250, c: { c1: 200 } }
h1.deep_merge(h2) { |key, this_val, other_val| this_val + other_val }
条件分岐その1
 if this_val.is_a?(Hash) && other_val.is_a?(Hash)
   this_val.deep_merge(other_val, &block)

【何をやってるのか】

deep_merge!のレシーバとなるハッシュh1と、deep_merge!の引数になるハッシュh2の要素を見て、重複するkeyがあるかをチェック。今回は、 以下の様に key の重複がありました。

b: 200
b: 250

c: { c1: 100 } }
c: { c1: 200 } }

重複keyがあったとして、そのkeyに対応するvalueがh1, h2共にハッシュであるかをチェック(=入れ子ハッシュの判定)しています。今回は重複keyである:cに対応するvalueは、h1,h2 共にハッシュです。

 {c1: 100} 
 {c1: 200}

【どんな処理をするのか】

 this_val.deep_merge(other_val, &block)
 # ↑のメソッドは以下の様に化ける
 # {c1: 100}.deep_merge({c1: 200}) { |key, this_val, other_val| this_val + other_val }

つまり、入れ子ハッシュの中身を取り出して、再びdeep_mergeメソッドを実行させるという再帰的な処理をしていたわけですね。

条件分岐その2
elsif block_given?
  block.call(key, this_val, other_val)

ブロックがある場合は単純に、ブロックを渡してmerge!メソッドを呼び出しているだけですね。 merge!メソッドの挙動についてはるりまを参照ください

docs.ruby-lang.org

条件分岐その3
else
  other_val
end

deep_merge!の引数にブロックを渡さなかった場合は、deep_merge!の引数に渡した方のハッシュのvalueが優先されていることが分かります。

ふたたびdeep_merge

もう一度deep_mergeの定義を見てみます。

def deep_merge(other_hash, &block)
    dup.deep_merge!(other_hash, &block)
end

deep_mergeのレシーバとなるオブジェクトをdupメソッドで複製し、その複製物をレシーバとしたdeep_merge!の戻り値を返しています。

前述の通り、deep_merge!は内部で破壊的メソッドであるmerge!を呼んでいたので、deep_merge!のレシーバは破壊的変更を受けます。

レシーバに影響を与えずにdeep_merge!の戻り値と同じ結果を得るために、dupでレシーバを複製していることが分かります。

まとめ

ActiveSupportが提供している機能の全体像と、一例としてHashクラスをどんなメソッドで拡張しているかを紹介しました。 難しい処理をしようとした際に「AvtiveSupportで定義してくれてないかな…」と疑うことで、自分の悩みを解決してくれる機能を見つけられるかもしれません。