こんにちは。サービス開発課の丸山です。
今回は最近触っていて疑問に思ったwith_options
について調べてみました。
with_optionsはRailsでよく使われる、こんな感じのメソッドです。
class Account < ActiveRecord::Base with_options presence: true, if: -> { foo? } do validates :name validates :email end end
これは以下のコードと等価です。
複数のメソッド呼び出しにまとめて同じオプションを指定する時に便利なメソッドですね。
参考: APIドキュメント
class Account < ActiveRecord::Base validates :name, presence: true, if: -> { foo? } validates :email, presence: true, if: -> { foo? } end
ブロックの中でさらにオプションを指定することもできます。
class Account < ActiveRecord::Base with_options presence: true, if: -> { foo? } do validates :name validates :email, unique: ture # presence: true, if: -> { foo? } に加えて unique: true が適用される end end
と便利な使い方ができるメソッドなのですが、こんな使い方をしようとすると自分の想定とは異なる動作をしました。
class Account < ActiveRecord::Base with_options presence: true, if: -> { foo? } do validates :name validates :email, unique: ture validates :phone_number, if: -> { bar? } end end
この場合、bar? かつ foo? の場合に phone_number
に対するバリデーションが動く想定でしたが、
実際には foo? は無視され、 bar? がtrueであれば バリデーションが動いてしまいました。
調べてみると、これはバグ(不具合)ではなく仕様のようです。
これを回避するにはif オプションを避けて、unlessを使うという方法があります。
# unless を使う方法 class Account < ActiveRecord::Base with_options presence: true, if: -> { foo? } do validates :name validates :email, unique: ture validates :phone_number, unless: -> { !bar? } end end
これで foo? かつ bar? の場合に phone_number のバリデーションが動く、想定した動作になります。
なぜこのような動作になるのかというと、「with_optionsはデフォルトのオプションをまとめて指定するためのメソッドであり、制御構文ではないから」ということでした。
パッと見てif
が出てくると制御構文のようにコードを読もうとしてしまい、foo?やbar?がその都度評価されると捉えてしまうのですが、
実際にはwith_optionsに渡しているのは「デフォルトのオプション」であり、if: の値もlambdaで作られたProcオブジェクトなので、
ifオプションが評価されるのは実際にメソッドが実行される時だけになります。
また、あくまでwith_optionsに渡すのはデフォルトのオプションなので、ブロックの中で同じキーのオプションを指定するとそこで上書きしてしまうことになります。
これはif
ではない別のオプションで考えるとわかりやすいです。
class Account < ActiveRecord::Base with_options presence: true, if: -> { foo? } do validates :phone_number, presence: false end end
ここではwith_optionsに渡している presence: true
と矛盾する presence: false
をブロックの中で指定しています。
この場合は trueとfalseの両方が適用されるわけではなく、ブロックの中で指定したfalseのみが適応されます。
ifオプションを指定した場合も、これと同じ原理で内側のifオプションだけが実行されることになります。
コードを読んでみる
せっかくなので、with_options
のコードを読んでみます。
activesupport/lib/active_support/core_ext/object/with_options.rb
で定義されていました。
モデル定義でよくみるイメージがありましたが、Objectに対するモンキーパッチなのですね。
そのため、こんな風にレシーバーを明示しても使えるようです。
I18n.with_options locale: user.locale, scope: 'newsletter' do |i18n| subject i18n.t :subject body i18n.t :body, user_name: user.name end
メソッド定義は以下のようにになっています。
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
rails/with_options.rb at 6-1-stable · rails/rails · GitHub
まず引数で渡されたオプションからoptionsとselfからOptionMergerというオブジェクトを作っています。
selfは先ほどの例ではActiveRecord(を継承したモデルクラス)やI18nですね。
その後、blcokの引数がなければOptionMergerオブジェクトのコンテキストでブロックの中身を実行、
blockの引数があればblock.callにOptionMergerオブジェクトを渡して実行しています。
肝心のOptionMergerの定義はこんな感じです。
# frozen_string_literal: true require "active_support/core_ext/hash/deep_merge" require "active_support/core_ext/symbol/starts_ends_with" module ActiveSupport class OptionMerger #:nodoc: instance_methods.each do |method| undef_method(method) unless method.start_with?("__", "instance_eval", "class", "object_id") end def initialize(context, options) @context, @options = context, options end private def method_missing(method, *arguments, &block) options = nil if arguments.first.is_a?(Proc) proc = arguments.pop arguments << lambda { |*args| @options.deep_merge(proc.call(*args)) } elsif arguments.last.respond_to?(:to_hash) options = @options.deep_merge(arguments.pop) else options = @options end invoke_method(method, arguments, options, &block) end if RUBY_VERSION >= "2.7" def invoke_method(method, arguments, options, &block) if options @context.__send__(method, *arguments, **options, &block) else @context.__send__(method, *arguments, &block) end end else def invoke_method(method, arguments, options, &block) arguments << options.dup if options @context.__send__(method, *arguments, &block) end end end end
rails/option_merger.rb at 6-1-stable · rails/rails · GitHub
最初のinstance_methods.each do ~
でObjectから継承された多くのメソッドを未定義状態にしています。
残っているのはclass
, object
, instance_eval
, その他__send__
などの__
から始まるメソッドだけですね。
method_missing
(定義されていないメソッドが呼び出された時に実行されるメソッド)が再定義されているので、
OptionMergerオブジェクトのコンテキストで実行されるメソッドは多くがここを通ることになりそうです。
method_missingの中で追加のオプションが与えられていた場合の処理を行って、最終的なオプションを作っています。
with_optionsの内側でさらにvalidatesにオプションをつけた場合などがここに当たりますね。
# ... elsif arguments.last.respond_to?(:to_hash) options = @options.deep_merge(arguments.pop) else # ...
ここで追加のオプションとwith_optionsの引数に渡した@options
がdeep_merge
されているのでifを2つ使った場合は最初のifが上書きされて無視されてしまう、ということになります。
最後は整形したoptionsを使って、@context
(初期化時に渡したself)に__send__
で引数とoptions付きでメソッドを呼び出しています。
if options @context.__send__(method, *arguments, **options, &block) else @context.__send__(method, *arguments, &block) end
validateの例なら@context
はActiveRecord(を継承した)クラスなので、無事with_optionsに渡したデフォルトのオプション付きでvalidateなどの実際のメソッドが呼び出されることがわかりました。
終わりに
かなりメタプログラミングな感じのコードでしたが、普段使っているrails のコードの仕組みを知るのは面白いですね。
実際にdeep_mergeでオプションがマージされるところまで確認できたので、もうifなどのオプションを誤って重ねて使ってしまうこともなさそうです。
丸山 礼 (記事一覧)
サービス開発課でCloud Automatorを開発しています。