そういうこともある

エンジニア的な何かの、プログラミングとかイベントレポートとか読書感想文

Railsのソースコード読んでみる | Active Support with_options編

f:id:sktktk1230:20171212153754p:plain

普段仕事で使っているRuby on Railsですが、ソースコードを読む機会もなかなかないので、試しにやってみることにしました

読めるようにするまで

以前書いた記事で読めるようにするまでの設定を画像キャプチャ付きで解説しましたので、よろしければこちらをご参照下さい
shitake4.hatenablog.com

読んだ箇所

with_options を今日は読んでみようと思います

どんな使い方だっけ?

読んでみる前にまずは使い方を調べてみます
RAILS GUIDESの日本語ドキュメントを見てみると

with_optionsメソッドは、連続した複数のメソッド呼び出しに対して共通して与えられるオプションを解釈するための手段を提供します。
デフォルトのオプションがハッシュで与えられると、with_optionsはブロックに対するプロキシオブジェクトを生成します。
そのブロック内では、プロキシに対して呼び出されたメソッドにオプションを追加したうえで、そのメソッドをレシーバに転送します。
たとえば、以下のように同じオプションを繰り返さないで済むようになります。

class Account < ActiveRecord::Base  
  has_many :customers, dependent: :destroy  
  has_many :products,  dependent: :destroy
  has_many :invoices,  dependent: :destroy
  has_many :expenses,  dependent: :destroy
 end  

上は以下のようにできます。

class Account < ActiveRecord::Base
  with_options dependent: :destroy do |assoc|
    assoc.has_many :customers
    assoc.has_many :products
    assoc.has_many :invoices
    assoc.has_many :expenses
  end
end

引用:RAILS GUIDES:Active Support コア拡張機能#with_options

読んでみましたが、イマイチ使い方がわからなかったので、さらに調べてみます

with_optionsの引数に渡したHashのオプションの値がブロック内に適用されます。上の例ではhash_manyのdependentオプションを共通化しましたが、他のパターンでも適用できます。例えば、モデルの例では他にもvalidatesメソッドのオプションを共通化など使えます。
引用:Railsのwith_options

上記例であれば、ハッシュである dependent: :destroy を共通化して各 has_many :xxx の引数に渡すという感じです

ソースコードを読んでみる

1. railsプロジェクトのactivesupportにある機能ですので、activesupportディレクトリのlib配下で def with_options を探してみます

f:id:sktktk1230:20180126135718p:plain

2. 該当箇所をみてみます

1. activesupport > lib > active_support > core_ext > object > with_options.rb
# frozen_string_literal: true

require "active_support/option_merger"

class Object

省略

def with_options(options, &block)
  option_merger = ActiveSupport::OptionMerger.new(self, options)
  block.arity.zero? ? option_merger.instance_eval(&block) : block.call(option_merger)
end

まず引数を見てみます
さきほどの例だと optionsdependent: :destroy となります
&block

do |assoc|
  assoc.has_many :customers
  assoc.has_many :products
  assoc.has_many :invoices
  assoc.has_many :expenses
end

までとなります

それではwith_optionsのメソッドのロジックをみていきます
一行目 option_merger = ActiveSupport::OptionMerger.new(self, options)ActiveSupport::OptionMerger が何をしているのかわからないので、 こちらのコードを読んでみます

activesupport > lib > active_support > option_merger.rb
# frozen_string_literal: true

require "active_support/core_ext/hash/deep_merge"

module ActiveSupport
  class OptionMerger #:nodoc:
    instance_methods.each do |method|
      undef_method(method) if method !~ /^(__|instance_eval|class|object_id)/
    end

    def initialize(context, options)
      @context, @options = context, options
    end

    private
      def method_missing(method, *arguments, &block)
        if arguments.first.is_a?(Proc)
          proc = arguments.pop
          arguments << lambda { |*args| @options.deep_merge(proc.call(*args)) }
        else
          arguments << (arguments.last.respond_to?(:to_hash) ? @options.deep_merge(arguments.pop) : @options.dup)
        end

        @context.__send__(method, *arguments, &block)
      end
  end
end

newした際の引数self, optionsインスタンス変数 context, optionsにそれぞれ格納されます

次に block.arity.zero? ? option_merger.instance_eval(&block) : block.call(option_merger) です

条件式 ? 真(true)の場合に実行される : 偽(false)の場合に実行される という書き方は三項演算子というものです

arity を調べると

メソッドが受け付ける引数の数を返します。
ただし、メソッドが可変長引数を受け付ける場合、負の整数
引用:Ruby2.5.0リファレンスマニュアル:arity

zero? を調べると

自身がゼロの時、trueを返します。そうでない場合は false を返します。 引用:Ruby2.5.0リファレンスマニュアル:arity

つまり、条件式 block.arity.zero? はブロックが受け付ける引数の数がゼロかを判定しています

条件式が真の場合に実行される option_merger.instance_eval(&block) を見てみます
instance_evalを調べてみると

instance_evalメソッドは、渡されたブロックをレシーバのインスタンスの元で実行します。ブロックの戻り値がメソッドの戻り値になります。
ブロック内では、インスタンスメソッド内でコードを実行するときと同じことができます。ブロック内でのselfはレシーバのオブジェクトを指します。なお、ブロックの外側のローカル変数はブロック内でも使えます。
引用:Rubyリファレンス:instance_eval

つまりoption_merger.instance_eval(&block) とはインスタンスoption_mergerでブロックの内容が実行されるということです
このようなのブロックの場合

do
  has_many :customers
  has_many :products
  has_many :invoices
  has_many :expenses
end

で定義されていた has_many メソッドは option_mergerで定義されていないので、 最終的に method_missing となります

※ method_missingについて参考: qiita.com

呼び出されるmethod_missingはoption_mergerのプライベートメソッドでオーバーライドしている method_missingとなります

private
  def method_missing(method, *arguments, &block)
    if arguments.first.is_a?(Proc)
      proc = arguments.pop
      arguments << lambda { |*args| @options.deep_merge(proc.call(*args)) }
    else
      arguments << (arguments.last.respond_to?(:to_hash) ? @options.deep_merge(arguments.pop) : @options.dup)
    end

    @context.__send__(method, *arguments, &block)
  end

method_missing の引数に入る値はさきほどのブロックの例だと method = hash_many , *arguments = [:customers] です

if から else までみてみます

if arguments.first.is_a?(Proc)
  proc = arguments.pop
  arguments << lambda { |*args| @options.deep_merge(proc.call(*args)) }
else

arguments配列の先頭がProcだった場合という意味になります
なぜこの条件式があるのかわからなかったので、調べてみます
RubyMineでdef method_missing end を選択し、選択箇所のコミットを見てみます

f:id:sktktk1230:20180126135748p:plain

option_mergerでlambdaもマージできるようにしたということだけがわかりましたが、どんなコードになるのか想像できなかったので、テストケースをみてみます
同じコミットにテストケースが1つ追加されていました

def test_nested_method_with_options_using_lambda
  local_lambda = lambda { { lambda: true } }
  with_options(@options) do |o|
    assert_equal @options.merge(local_lambda.call),
      o.method_with_options(local_lambda).call
  end
end

ハッシュを返すlambdaを定義しています
@optionsmethod_with_options は同じクラス内に定義されていたので、見てみると

class OptionMergerTest < ActiveSupport::TestCase
  def setup
    @options = { hello: "world" }
  end

省略

  private
    def method_with_options(options = {})
      options
    end
end

ブロック内で定義されているメソッドのoptionに当たる部分がlambdaだった場合に、正しく動くかをテストしてます
テストケースを見てみると、このコミットでの変更はメソッドのoptionsがハッシュを返すlambdaだった場合も正しく動くようにするという修正であることがわかりました

次に proc = arguments.pop を見ます

popメソッドは、配列の末尾の要素を削除し、その要素を返します。レシーバ自身を変更するメソッドです。配列が空のときはnilを返します。
引用:Rubyリファレンス:pop

else から end までみてみます

else
  arguments << (arguments.last.respond_to?(:to_hash) ? @options.deep_merge(arguments.pop) : @options.dup)
end

三項演算子の条件式部分の (arguments.last.respond_to?(:to_hash) をみます

argumentsに入るのはブロックで定義したメソッドの引数部分となります
optionがあるメソッドの定義は基本的に

def hoge(value, option)
end

のように引数の最後に記述することが多いので、引数の最後の値がハッシュを返すかを確認しています
to_hash はHashやArrayなどで定義されています

三項演算子の判定結果が真の場合 @options.deep_merge(arguments.pop) を見てみます

@options は with_optionsメソッドで第一引数に入れたハッシュでした
そのハッシュに対してdeep_mergeしているということです

deep_mergeについて調べてみると

先の例で説明したとおり、キーがレシーバと引数で重複している場合、引数の側の値が優先されます。
Active SupportではHash#deep_mergeが定義されています。ディープマージでは、レシーバと引数の両方に同じキーが出現し、さらにどちらも値がハッシュである場合に、その下位のハッシュを マージ したものが、最終的なハッシュで値として使用されます。
引用:RAILS GUIDES:Active Support コア拡張機能#deep_merge

arguments.pop は、さきほどの例def hoge(value, option) でいうと arguments = [value, option] となっているのを .pop することで option を取り出し arguments = [value] へと破壊的変更をすることです

判定結果が偽の場合 @options.dup も見ます

cloneメソッドとdupメソッドは、レシーバのオブジェクトのコピーを作成して返します。オブジェクトのコピーとは、同じ内容を持つ別のオブジェクトです。具体的には、元のオブジェクトと同じクラスの新しいオブジェクトで、元のオブジェクトのインスタンス変数を新しいオブジェクトにコピーしたものです。
引用:Rubyリファレンス:clone, dup

with_optionsメソッドで第一引数に入れたハッシュをコピーして返却するだけです

arguments << はそれぞれの判定結果の戻り値ハッシュをargumentsに追加しています

最後に @context.__send__(method, *arguments, &block) をみます

@context はwith_optionsのレシーバです
__send__ を調べてみます

sendメソッドは、sendの別名です。レシーバの持っているメソッドを呼び出します。
引用:Rubyリファレンス:send

with_optionsのレシーバに対してブロックで定義したメソッドを実行しています

読んでみて

method_missingを利用したプログラミングを初めて見ることができたので、非常に勉強になりました