💥

Rails の with_options がヤバいし、なんならバグってたって話

2022/06/09に公開
1

3行で

  • Rails の with_options は黒魔術
  • ちゃんと使わないとバグる
  • ちゃんと使っててもバグってた

どんな機能?

with_options というメソッド、ご存じでしょうか?
https://api.rubyonrails.org/classes/Object.html#method-i-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 にはマクロのような、ソースコードを書き換えて実行する機能は備わっていません

裏に邪悪な黒魔術の気配を感じます😈
どうやって実現しているのか、実装を探ってみましょう

黒魔術の正体

https://github.com/rails/rails/blob/main/activesupport/lib/active_support/option_merger.rb#L16-L32

やはりというか method_missing を使って引数の追加作業が行われています
しかし ActiveSupport::OptionMergerFoo と関係のないクラスですから、そのままでは method_missing は呼ばれないはずです🤔

https://github.com/rails/rails/blob/main/activesupport/lib/active_support/core_ext/object/with_options.rb#L92-L100

with_optionsActiveSupport::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

ブロック引数を取らない場合、インスタンス変数の読み書きはできない、と考えたほうがよさそうです

使いづらいとかじゃなく、バグってた

https://github.com/rails/rails/issues/45183
https://github.com/rails/rails/pull/45198

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: :destroyassoc. に変わっただけで、全く重複が取り除けていません

そもそも必要だからそれぞれに記述しているのであって、重複ではありません
ソースコードが似ている、という理由で DRY 原則を振りかざすのは有害なのでやめましょう

with_options、いつ使うの?

使わないです。
今じゃないです。

Discussion

okuramasafumiokuramasafumi

with_optionsはドキュメント的にもコード的にもアソシエーションに渡すdependent: :destroyを書かないで済ませるためのもの、という認識でした。他の使い方している人っているのかなあ? > 誰も使っていない