普段仕事で使っている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
読んでみましたが、イマイチ使い方がわからなかったので、さらに調べてみます
with_optionsの引数に渡したHashのオプションの値がブロック内に適用されます。上の例ではhash_manyのdependentオプションを共通化しましたが、他のパターンでも適用できます。例えば、モデルの例では他にもvalidatesメソッドのオプションを共通化など使えます。
引用:Railsのwith_options
上記例であれば、ハッシュである dependent: :destroy
を共通化して各 has_many :xxx
の引数に渡すという感じです
ソースコードを読んでみる
1. railsプロジェクトのactivesupportにある機能ですので、activesupportディレクトリのlib配下で def with_options
を探してみます
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
まず引数を見てみます
さきほどの例だと options
は dependent: :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
を選択し、選択箇所のコミットを見てみます
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を定義しています
@options
と method_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を利用したプログラミングを初めて見ることができたので、非常に勉強になりました