Rails の with_options がヤバいし、なんならバグってたって話
3行で
- Rails の with_options は黒魔術
- ちゃんと使わないとバグる
- ちゃんと使っててもバグってた
どんな機能?
with_options
というメソッド、ご存じでしょうか?
簡単に言うと、メソッド呼び出し時に引数を勝手に追加してくれる機能です🪄
早速使ってみましょう!
require 'active_support'
require 'active_support/core_ext'
class Foo
def self.puts_args(arg1, **kwargs)
puts "arg1: #{arg1}, kwargs: #{kwargs}"
end
# arg1: 123, kwargs: {:hello=>"world"}
puts_args(123, hello: 'world')
with_options(fizz: 'buzz') do
# arg1: 123, kwargs: {:fizz=>"buzz", :hello=>"world"}
puts_args(123, hello: 'world')
end
end
with_options
の内部では、引数に渡していない :fizz=>"buzz"
が勝手に追加されていますね!
with_options のブロック内のメソッドコールすべての末尾引数として fizz: 'buzz'
が追加される、という機能と言えそうです
公式のドキュメントでは
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
has_many :customers
has_many :products
has_many :invoices
has_many :expenses
end
end
のように書き換えることで、ソースコードから重複を取り除くことができる、としています
DRYですねー
めでたしめでたし...
ちょっと待って
なぜこんなことが実現できるのでしょうか?
Ruby にはマクロのような、ソースコードを書き換えて実行する機能は備わっていません
裏に邪悪な黒魔術の気配を感じます😈
どうやって実現しているのか、実装を探ってみましょう
黒魔術の正体
やはりというか method_missing
を使って引数の追加作業が行われています
しかし ActiveSupport::OptionMerger
は Foo
と関係のないクラスですから、そのままでは method_missing
は呼ばれないはずです🤔
with_options
は ActiveSupport::OptionMerger
のインスタンスを生成し method_missing
が発生する状況を作っています
with_options
のブロックが引数を取るかどうかで挙動が変わることも分かります
引数なしの呼び出しは次のように書き換えることができます
# 書き換え前
with_options(hash_arg) {
# ...
}
# 書き換え後
ActiveSupport::OptionMerger.new(self, hash_arg).instance_eval {
# ...
}
引数ありの呼び出しは次のように書き換えることができます
# 書き換え前
with_options(my_hash) { |option_merger|
# ...
}
# 書き換え後
option_merger = ActiveSupport::OptionMerger.new(self, hash_arg)
# ...
注文の多いメソッド
method_missing
が発生しない限り、引数の追加は行われません
引数を追加させたければ、しっかり method_missing
を発生させてあげなければなりません
-
self
以外のメソッドを呼ぶ
with_options(fizz: 'buzz') do
Foo.puts_args(123, hello: 'world')
end
- ActiveSupport::OptionMerger のインスタンスメソッドを呼ぶ
with_options(fizz: 'buzz') do
# ActiveSupport::OptionMerger は Kernel を継承しているので Kernel#p は解決可能
p(123, hello: 'world')
end
- ブロック引数を取っておきながら利用しない
with_options(fizz: 'buzz') do |option_merger|
# self は Foo なので Foo.puts_args に解決可能
puts_args(123, hello: 'world')
end
これらの記述では method_missing
が発生せず、引数の追加も行われません
それはそれ これはこれ
- ブロック引数なしだと
instance_eval
- ブロック引数ありだと
block.call
それぞれで self
の値が異なるため、インスタンス変数の取得結果も異なります
class Foo
@context = 10
@options = 20
@val1 = 30
with_options(fizz: 'buzz') do
# Foo
# {:fizz=>"buzz"}
# nil
p @context, @options, @val1
end
with_options(fizz: 'buzz') do |option_merger|
# 10
# 20
# 30
p @context, @options, @val1
end
end
ブロック引数を取らない場合、インスタンス変数の読み書きはできない、と考えたほうがよさそうです
使いづらいとかじゃなく、バグってた
require 'active_support'
require 'active_support/core_ext'
class Foo
def self.puts_args(arg1, **kwargs)
puts "arg1: #{arg1}, kwargs: #{kwargs}"
end
with_options(fizz: 'buzz') do
puts_args(proc {}, hello: 'world')
end
end
第一引数を proc
にするとエラーなるよ、って issue 立てたら修正されました✌️
次のリリースで fix されると思います
しかし、こんな初歩的なパターンのエラーを誰も報告していなかったってことは、ひょっとして 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
(6行 210文字)
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
(8行 207文字)
汚くなってんじゃねーか!
dependent: :destroy
が assoc.
に変わっただけで、全く重複が取り除けていません
そもそも必要だからそれぞれに記述しているのであって、重複ではありません
ソースコードが似ている、という理由で DRY 原則を振りかざすのは有害なのでやめましょう
with_options、いつ使うの?
使わないです。
今じゃないです。
Discussion
with_options
はドキュメント的にもコード的にもアソシエーションに渡すdependent: :destroy
を書かないで済ませるためのもの、という認識でした。他の使い方している人っているのかなあ? > 誰も使っていない