🔗 前提となる環境
以下の環境で動作確認をしています。
- Ruby 3.3 + Rails 7.1
- Ruby 3.2 + Rails 6.1
🔗 課題
Active Model オブジェクトでは、デフォルトでコンストラクタに attribute をキーワード引数で渡すことができますが、それに加えてオプションを渡したいことがあります。特に多いのは、Form Object に行わせようとしている処理の文脈に関する情報(例えば操作しているユーザーの Active Record オブジェクト)を与えたい場合でしょうか。
🔗 TL;DR
🔗 初歩的な方法: 位置引数を使う
こういうとき、次のようなコンストラクタを書くと、位置引数で渡すことができます。
class MyForm
include ActiveModel::Model
def initialize(user, attributes = {})
@user = user
super(attributes)
end
end
# 使うとき
form = MyForm.new(user, { attr_a: 1 })
ですが、引数は往々にして増えるもので、取り違えが怖いです。Ruby は動的型付け言語なので、本来 MyForm.new(a, b)
と呼ぶべきところを MyForm.new(b, a)
と書いてしまった場合でも、単に呼び出されてメソッドに入っただけの段階ではエラーになりません。仮に静的型付け言語であったとしても、a
と b
の型に互換性がある場合はコンパイルエラーにできないことがあります。これは位置引数の宿命です。
🔗 キーワード引数にしたい
そこでキーワード引数の出番です。キーワード引数では呼び出し側でそれぞれの引数が何であるかを指定し、その順番は問いません。理想は次のような呼び出し形式です。
form1 = MyForm.new(user:, attr_a: 1) # OK
form2 = MyForm.new(attr_a: 1, user:) # これもOK
この形式には好みもあるようです。あるBPSメンバーは、渡すものが attribute なのか、そうではないオプションであるのかは呼び出し側で明確に区別したいと言っていました。そういう方は一例として次のような方法を取るでしょう。
class MyForm
include ActiveModel::Model
def initialize(attributes:, user:)
@user = user
super(attributes)
end
end
form = MyForm.new(user:, attributes: { attr_a: 1 })
ですが、私はメソッドに対してパラメータとして渡すものは、それが何であるべきかだけが公開されるべきで、attribute なのかそうではないのかはインターフェイスから隠蔽されるべきだと思っています。よってこの方法は避けたい。
🔗 attribute にしてはいけない理由
user
を attribute とすることで、一見理想が実現できたかに思えます。次のようなコードです。
class MyForm
include ActiveModel::Model
def user=(user)
@user = user
end
end
しかし、これにも問題があります。
まず、初期化後にもう一度 #user=
を呼ぶことができてしまいます。何も対策処理を書かないと、次のようなコードがエラーを起こすことなく実行できてしまいます。
form = MyForm.new(user:)
form.user = another_user
form.assign_attributes(user: some_other_user)
また、attribute の writer は、コンストラクタや #assign_attributes
に渡ったハッシュ状のオブジェクト中の値の順番に従って呼び出されます。既存の attribute writer に、ここで言う @user
に依存する処理があった場合、次のように呼び出すと前提が崩れます。
form = MyForm.new(attr_a: 1, user:)
つまり、キーワード引数でありながら順番に依存してしまうのです。もちろん、うっかり指定し忘れても、それだけではエラーになりません。これはダメ。
🔗 double splat
Ruby には double splat という機能があります。これを使うと、次のように書くことができます。
class MyForm
include ActiveModel::Model
def initialize(user:, **attributes)
@user = user
super(attributes)
end
end
form = MyForm.new(user:, attr_a: 1) #OK
form = MyForm.new(attr_a: 1, user:) #OK
この方法では、attribute にした場合と異なり、MyForm.new({ user:, attr_a: 1 })
と呼ぶことができなくなります。ですが、これは全く許容範囲内です。なぜなら、Form Object では Controller 内で params
を操作して取り出したサブセットの ActionController::Parameters
に、user
をオプションとして加えるという形で使うことになりますが、これは MyForm.new(user:, **my_form_params)
と書くことができるからです。attribute なのかそうではないのかはインターフェイスから隠蔽したいとは言ったものの、実際に区分しない書き方をするのは Form Object ではテストコードぐらいのものでしょうか。全パラメータ入りの Hash
を渡したい場合には、MyForm.new(**my_form_params)
と書くこともできます。
🔗 思わぬ落とし穴: ActionController::Parameters
vs Hash
しかし、これにもまだ問題がありました。
ActionController::Parameters
ではキーはシンボルではなく文字列になります。Active Model はキーがシンボルであろうと文字列であろうと、attribute の writer を想定通り呼んでくれます。問題はリクエストパラメータのハッシュ形式がネストしているときです。次のコードを見てください。
class MyForm
# 省略
def sub_objects_attributes=(attributes)
attributes.each do |_, h|
if h[:id].present?
# 既存オブジェクトの処理
else
# 新規オブジェクトの処理
end
end
end
end
#sub_objects_attributes=
は ActionView::Helpers::FormBuilder
で fields_for :sub_objects
するために必要なよくあるパターンの attribute writer です。個々のパラメータに id があるかどうかを h[:id]
で判別しています。これはコンストラクタに渡る attributes
が ActionController::Parameters
である場合は、キーがシンボルであろうと文字列であろうと正常に動きます。#sub_objects_attributes=
に渡ってくる attributes
もまた ActionController::Parameters
だからです。
しかし、MyForm.new(user:, **my_form_params)
した場合だとこれは動きません。なぜなら、double splat を使って my_form_params
と user
を組み合わせた段階で、コンストラクタに渡る attributes
は ActionController::Parameters
ではなく Hash
に暗黙に変換され、その段階でオブジェクトに入っている値も Hash
になってしまうからです。
解決策: Hash#with_indifferent_access
そこで Hash#with_indifferent_access
の出番です。以下のように super
するところで使います。
class MyForm
include ActiveModel::Model
def initialize(user:, **attributes)
@user = user
super(attributes.with_indifferent_access)
end
# 省略
end
Hash#with_indifferent_access
は ActiveSupport::HashWithIndifferentAccess
オブジェクトを返します。これは ActionController::Parameters
同様、キーにシンボルを渡しても文字列を渡しても同じように要素にアクセスすることができます。これで目的が達成できました。めでたい!
🔗 ちょっと待った: attribute 性が隠蔽されていない
「attribute なのかそうではないのかはインターフェイスから隠蔽されるべきだ」と言いましたね。その観点からすると、まだ問題があります。my_form_params
が ActionController::Parameters
である場合、MyForm.new(**my_form_params)
と書けないことです。ActionController::Parameters
のキーは文字列なので、double splat で展開した場合に user
にマッチしないのです。
これを回避するためには MyForm.new(**my_form_params.to_h.symbolize_keys)
と書く必要がありますが、これでは「user
は attribute ではないのでシンボルで渡してくださいね」と言っているに等しく、要件を満たしているとは言えません。そこで、コンストラクタで工夫をします。
🔗 最終バージョン
以下がこの問題を解決するコードです。
class MyForm
include ActiveModel::Model
def initialize(options = {})
process_options(**options.to_h.symbolize_keys) do |attributes|
super(attributes.with_indifferent_access)
end
end
private
def process_options(user:, **attributes)
@user = user
yield(attributes)
end
end
副次的な効果として、MyForm.new(my_form_params)
と呼ぶこともできるようになりました。
このコードのキモは、#process_options
をオーバライドすることで、サブクラスでもコンストラクタのオプションを増やすことができ、その際にスーパークラスのコンストラクタを呼んだ後にオプションを使った初期化処理を書くこともできる点です。例えば次のようにオーバーライドします。
class MySpecificForm < MyForm
private
def process_options(group:, message:, **rest)
@group = group
super(**rest)
puts message
end
end
#initialize
と #process_options
はモジュールに切り出しておくのも良さそうです。
module ActiveModelWithOptions
extend ActiveSupport::Concern
include ActiveModel::Model
def initialize(options = {})
process_options(**options.to_h.symbolize_keys) do |attributes|
super(attributes.with_indifferent_access)
end
end
private
def process_options(**attributes)
yield(attributes)
end
end
include ActiveModel::Model
しないほうが取り回しが良いかもしれませんが、その辺はお好みで。
使用例はこちら。
class MyForm
include ActiveModelWithOptions
private
def process_options(user:, **rest)
@user = user
super(**rest)
end
end
かなりシンプルになりました。
🔗 余談: どうしてこうなるの?
double splat 周りで、なぜこうなってしまうのかよく分からないものがありました。以下のように、通常のキーワード引数に加えて double splat がある場合と、double splat のみの場合とで挙動が異なります。
def f(**h)
puts h.class
end
def g(a:, **h)
puts h.class
end
h = ActionController::Parameters.new.permit
f(**h) # Hash
g(a: 1, **h) # Hash
h = {}.with_indifferent_access
f(**h) # ActiveSupport::HashWithIndifferentAccess
g(a: 1, **h) # Hash
f(**h)
だと ActiveSupport::HashWithIndifferentAccess
を渡したとき文字列キーでしかアクセスできない問題が起きないのに、g(a: 1, **h)
ではどちらも問題が起きるのが謎です。ActionController::Parameters.new.permit.is_a?(Hash)
が false
を返すのに対して {}.with_indifferent_access.is_a?(Hash)
は true
を返すので、その場合に f(**h)
でだけ暗黙の変換が行われなくなるものと予想できます。
レシーバのクラス | to_h 後のクラス | to_hash 後のクラス |
---|---|---|
ActiveSupport::HashWithIndifferentAccess |
Hash |
Hash |
ActionController::Parameters |
ActiveSupport::HashWithIndifferentAccess |
Hash |
恐らく呼ばれているのは #to_hash
でしょう。まぁ、どうでもいいか。