追記(2017/10/27)
- 元記事からTechRachoにリンクいただきました🙇
- shioyamaさんがRailsに投げたプルリクを知らせていただきました: #30895 Convert AttributeMethodMatcher to Module Builder
元記事が非常に長いので次のように分割しました。
- #1 モジュールはどのように使われてきたか
- #2 Module Builderパターンとは何か
- #3 Rails ActiveModelでの利用例(本記事)
- あとがき: Module Builderパターンという名前について
概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: The Ruby Module Builder Pattern
- 公開日: 2017/05/20
- 著者: Chris Salzberg
- サイト: dejimata.com
RubyKaigi 2017@広島でも発表されたshioyamaさんです。このときのセッションでもModule Builderパターンを扱っています。
- RubyKaigi 1日目セッション: The Ruby Module Builder Pattern
本記事の図はすべて元記事からの引用です。また、パターン名は英語で表記しました。
RubyのModule Builderパターン #3 Rails ActiveModelでの適用例(翻訳)
Module Builderとカプセル化
ここまでに学んだすべてを投入して、Rubyで最も有名な例のアプリフレームワークのコアで実際に動くいくつかのコードに適用してみることにしましょう。
直前のセクションの議論に続いて、ここではモジュールを使った典型的なステート設定方法(ステートをモジュールに保存する)と、この方法でモジュールを設定する場合の問題点に絞り込みたいと思います。私は、Module Builderならステートを本来あるべき場所、つまりモジュールそれ自身にずっと自然な方法でカプセル化できることを皆さんにご理解いただければと願っています。
ActiveModel::AttributeMethods
今回チェックするモジュールは、ActiveModelのAttributeMethodsです。このモジュールは、プレフィックスやサフィックスをサポートするattribute用メソッドをRailsに追加します1。Railsを少しでも扱ったことがあれば、ActiveModel::Dirtyのname_changed?
などの変更追跡系メソッドで、このようにプレフィックスやサフィックスを使うパターンに既に慣れ親しんでいることでしょう。このモジュールは、前述のAdderIncluder
のようなモジュールブートストラップと、MethodFound::Interceptor
のようなmethod_missing
によるメッセージインターセプトの両方を実装しています。
メソッドのプレフィックスとアフィックス(訳注: affix: プレフィックスとサフィックスを両方とも指定すること)を1つずつ実装する単純なクラスから始めることにしましょう2。
class Person
include ActiveModel::AttributeMethods
attribute_method_affix prefix: 'reset_', suffix: '_to_default!'
attribute_method_prefix 'clear_'
define_attribute_methods :name
attr_accessor :name, :title
def attributes
{ 'name' => @name, 'title' => @title }
end
private
def clear_attribute(attr)
send("#{attr}=", nil)
end
def reset_attribute_to_default!(attr)
send("#{attr}=", "Default #{attr.capitalize}")
end
end
上の属性メソッドは次のように使います。
person = Person.new
person.name = "foo"
person.name
#=> "foo"
person.clear_name
person.name
#=> nil
person.reset_name_to_default!
person.name
#=> "Default Name"
上のコードを見ると、渡されたクラスについてどのようなプレフィックス/サフィックス/アフィックスが定義されているかを知るために、ステートがモジュールのどこかに保存されていることがわかります。そしてこのステートはattribute_method_prefix
のコードに保存されることもわかります。これはPerson
内で呼び出されてメソッドのプレフィックスを設定します。
def attribute_method_prefix(*prefixes)
self.attribute_method_matchers += prefixes.map! { |prefix| AttributeMethodMatcher.new prefix: prefix }
undefine_attribute_methods
end
これによりプレフィックス文字列のarrayがAttributeMethodMatcher
クラスのいくつかのインスタンスにmap
され、Person
クラスのattribute_method_matchers
のarrayに保存されます(アフィックスやサフィックスのメソッドも同様です)。これらのマッチャはモジュールのコア設定ですが、モジュール自身の外部に保存されます。
これらのマッチャの中身はどうなっているのでしょうか。理解のために、このクラスの初期化メソッドを見てみましょう。
def initialize(options = {})
@prefix, @suffix = options.fetch(:prefix, ""), options.fetch(:suffix, "")
@regex = /^(?:#{Regexp.escape(@prefix)})(.*)(?:#{Regexp.escape(@suffix)})$/
@method_missing_target = "#{@prefix}attribute#{@suffix}"
@method_name = "#{prefix}%s#{suffix}"
end
つまり、マッチャは本質的に2つの要素「プレフィックスとサフィックスのペアと、そこから生成される正規表現」でできています(それ以外はほとんどが利便性のためのものです)。この内部ステートは、2つの異なる(しかし関連する)目的に用いられます。
2つの目的のうち1つは重要であるにもかかわらず、満足なドキュメントがまったくありません(このインラインコメントをご覧ください)。第1の目的とは、あるattributes
メソッドから返されるキー/値ペアのハッシュ1つを、定義済みのプレフィックス/サフィックス/アフィックスメソッドをすべてサポートするファーストクラス(first-class)属性メソッドに変換する手段を提供することです。上の例では、属性はキーname
とtitle
を含むハッシュを1つ返し、これがclear_attribute
やreset_attribute_to_default!
などのメソッドに割り当てられる属性になります。
MethodFoundの場合と同様に、これもmethod_missing
を用いて実装されています。マッチャーの正規表現とマッチし、かつ正規表現でキャプチャされた対象がattributes
のキーに含まれているメソッド呼び出しは、すべてインターセプトされて属性のハンドラに割り当てられます。このようにしてreset_title_to_default!
はreset_attribute_to_default!
に割り当てられ、属性の名前(title
)がその引数になります。
メソッドのインターセプトは属性のハッシュを制約なしに変更できる点では素晴らしいのですが、method_missing
はシロップのように遅いのです。どんな属性が欲しいかが事前にわかっているのであれば、一般にはメソッドを明示的に定義する方がよいでしょう。
これがメソッドマッチャの第2の目的です。プレフィックス/サフィックス/アフィックスを設定(属性ごとにプレフィックスやサフィックスのペアを定義する)した後にdefine_attribute_methods
に1つ以上の属性を渡して呼び出すことでマッチャがトリガされます。Person
はname
の属性を定義しているので、clear_name
とreset_name_to_default!
はそれぞれclear_attribute
とreset_attribute_to_default!
に割り当てられます。
これらのメソッドは、Person
クラスに追加される別のメソッド(generated_attribute_methods
)で定義およびinclude
される1個の無名モジュールに紐付けられます。これらはインスタンスメソッドとしてメソッドインターセプトより優先されるので、clear_title
やclear_name
は両方とも同じ結果を返しますが、前者はmethod_missing
にフォールスルーし、後者はこの属性メソッドで扱われます(後者の方がずっと高速です)。
次のようにPerson
インスタンスのメソッドをgrepすれば、定義済みの属性メソッドを一覧できます。
person = Person.new
person.methods.grep /name/
#=> [:name, :name=, :reset_name_to_default!, :clear_name, ...]
モジュールのancestors
とinstance_methods
を調べてみると、生成されたメソッドが(他の場所ではなく)このモジュールで定義されていることも確認できます。
Person.ancestors
#=> [Person, #<Module:Ox...>, ActiveModel::AttributeMethods, ...]
Person.ancestors[1].instance_methods
#=> [:name, :name=, :reset_name_to_default!, :clear_name]
また、前述したmethod_missing
のオーバーライドがActiveModel::AttributeMethods
のこの箇所、クラス階層における無名モジュールの直後で行われていることもわかります。
属性メソッドの2つの実装(method_missing
によるメソッドインターセプトと、メソッド定義による実装)は、それぞれ適切なアクセスパターンが異なる(前者は大規模な属性セットに、後者は小規模な固定の属性セットに向いています)ので、互いに補完し合います。私のMobility gemでは、i18nアクセサの定義でこれと同じアプローチを実際に採用しています3。RailsのAttributeMethods
はクラスメソッドや変数を多数追加しますが、私のMobilityは何ひとつ追加しません。代わりに、すべてのステートはそれらモジュール内に隠蔽されます。
いよいよAttributeMethods
のもうひとつの実装をお見せしましょう。この実装は私のMobility gemと同様、Module Builderを用いてステートをモジュール内にカプセル化します。
MethodFound::AttributeMethods
このセクションでは、これまで議論してきたすべてのアイデアを集約して現実のアプリに適用します。コードの詳細を解説する前に、クラスに属性メソッドを実装する要素をおさらいしておきましょう。
- クラス変数
(attribute_method_matchers
): プレフィックス/サフィックス/アフィックスのマッチャーのarrayを保持する method_missing
のオーバーライド: 一致する属性マッチャにメソッド呼び出しを割り当て、対応するハンドラにattributes
ハッシュを割り当てるinclude
された無名モジュール(generated_attribute_methods
): 定義済みの属性メソッドを保持する
今度は、上の各コア要素が別の場所に置かれているところを考えてみましょう。
- メソッドマッチャは、
include
する側のクラスで定義されている - 生成された属性メソッドは、このクラスに
include
されている無名モジュールで定義されている method_missing
はAttributeMethods
自身に定義され、そのクラスにもinclude
されている
これで、私たちの実装で使われる3つの要素が揃いました。3つの要素はメソッドマッチャのクラス変数を通じて互いに結合し、システム実装のさまざまな部分に広がります。しかしそれだけではありません。メソッドマッチャの機能と生成されたメソッドの機能は(この後でも説明するように)本質的に独立していますが、同じ場所(メソッドマッチャはarrayに、生成されたメソッドは無名モジュールに)に保存されます。
ステートを別の方法で分散する、別の実装を考えます。この実装は、Module Builderを用いて以下の要素を単一のモジュールにまとめます。
- 単独のプレフィックス/サフィックスペア: ここからマッチャの正規表現が生成される
- 単独の
method_missing
オーバーライド: メソッドを正規表現でインターセプトする - 単独のメソッド: 渡されたプレフィックス/サフィックスペアに対応する属性メソッドを定義する
このModule BuilderにはAttributeInterceptor
という名前が付けられており、MethodFound
にあります。これは、前のセクションでお見せしたMethodFound::Interceptor
を継承し、形式の制約がないデフォルトのインターセプタビルダではなく、プレフィックスやサフィックスを受け取れるようにイニシャライザをカスタマイズします。コードのサイズはそれほど大きくありませんが、ここではコードをまるごと引用するのはやめておき、代わりにクラスで実際に行われている内容に注目します。
まずはインターセプトを見てみましょう。上のPerson
クラスのdefine_attribute_prefix
呼び出しやdefine_attribute_affix
呼び出しを以下のように属性インターセプタに置き換えることでインターセプトを再現できます。
class Person
include MethodFound::AttributeInterceptor.new(prefix: 'clear_')
include MethodFound::AttributeInterceptor.new(prefix: 'reset_', suffix: '_to_default!')
attr_accessor :name, :title
def attributes
{ 'name' => @name, 'title' => @title }
end
# ...
end
ここでは2つの属性インターセプタをそれぞれインスタンス化してクラスにinclude
しています。アフィックスのための特別な対応は不要なので、単にプレフィックス用とサフィックス用のインターセプタをビルドしています。
これらのモジュールがinclude
されると、Person
は外部からはActiveModelモジュールを用いた場合と完全に同一に振る舞っているように見えます。しかし内部の実装はかなり異なっており、ancestors
でその違いを確認できます。
Person.ancestors
#=> [Person,
#<MethodFound::AttributeInterceptor: /\A(?:reset_)(.*)(?:_to_default!)\z/>,
#<MethodFound::AttributeInterceptor: /\A(?:clear_)(.*)(?:)\z/>,
... ]
前述のMethodFoundインターセプタと同様に、2つのモジュールがそれぞれmethod_missing
をオーバーライドし、メソッド名が(モジュールに保存されている)正規表現と一致すればコードパスを分岐します。
このようにして得られる分散/ネスト条件セットは、ActiveModel
のこの行と同等です。このコードは、一致をチェックする属性メソッドマッチャを列挙します。
matchers.map { |method| method.match(method_name) }.compact
2つの実装の主要な相違点は、この行がActiveModel::AttributeMethods
(これはモジュールです)で実行され、かつPerson
(これはクラスです)に保存されたクラス変数を用いるのに対し、私のMethodFoundバージョンではsuper
を用い、メソッドのコンポジションを通じて、独立した各モジュールで実行されるという点です。
生成された属性メソッドの実装も同じ要領です。AttributeInterceptor
にはdefine_attribute_methods
というメソッドがあり、これは1つ以上の属性名を受け取って、各属性メソッドにモジュールのプレフィックスやサフィックスを加えたものをそのモジュール自身に定義します。繰り返しになりますが、このモジュールには自身のプレフィックスやサフィックスが含まれているので、この作業に必要な情報はすべてモジュール内に揃っています。
したがって、機能は真の意味でカプセル化されます。単一のモジュールが自身のプレフィックスやサフィックスを含み、属性メソッドの呼び出しをキャッチするmethod_missing
のオーバーライドや、自身の属性メソッドを生成するためのメソッドも含んでいます。4。
このModule Builderを使えば、クラスメソッドやクラス変数をひとつも書かずにActiveModelの実装を以下のように再現できます。
class Person
[ MethodFound::AttributeInterceptor.new(prefix: 'clear_'),
MethodFound::AttributeInterceptor.new(prefix: 'reset_', suffix: '_to_default!')
].each do |mod|
mod.define_attribute_methods(:name)
include mod
end
#...
end
MethodFound
はMethodFound::AttributeMethods
という別のモジュールをinclude
しています。このモジュールは上のコードをシンプルにするクラスメソッド群を追加するので、ActiveModel::AttributeMethods
をまるごと置き換えることができます。このモジュールに含まれるdefine_attribute_methods
の実装も興味深いものです。
def define_attribute_methods(*attr_names)
ancestors.each do |ancestor|
ancestor.define_attribute_methods(*attr_names) if ancestor.is_a?(AttributeInterceptor)
end
end
このクラスはマッチャを自分のステートに保存しないので、このモジュールではマッチャを単に列挙してメソッドを定義するという手が使えません。代わりに、自身のancestors
を通じて列挙します。列挙されるのはプレフィックスやサフィックスのペアを持つ各モジュールインスタンスなので、先祖が属性インターセプタの場合にdefine_attribute_methods
メソッドを呼び出します。これによってインターセプタモジュールごとに属性メソッドが生成され、クラスのインスタンスから呼び出せるようになります。
こうして得られた実装では、前述の関連する要素が独立したモジュールにカプセル化されて、しかもクラス変数やクラスメソッドで結合されていません。このようにカプセル化できるということは、まったく新しい属性インターセプタを設計して従来と同じ方法でinclude
できるようになったということです。(属性メソッド生成のための)インターフェイスが同じである限り、内部が完全に変わってしまっても変更は不要です。
これは私にとって「モジュールとはそもそもどうあるべきか?」という問いかけに思えます。モジュールは、必要なものをすべて含み、必要な一部の機能のある側面だけを実行できる、独立した交換可能な単位であるべきです。しかしそれだけではなく、こうしたモジュールを構築するインターフェイスは、Ruby自身のオブジェクトモデル(言語そのものと同じぐらい長い歴史を持つモデル)から直接逸脱してしまいます。このような飾りが長年に渡ってRubyの衣装の袖口に縫い付けられていたにもかかわらず、その存在はほぼ誰にも気づかれずじまいだったのです。
Module Builderパターンという名前について
私がご紹介した「Module Builderパターン」について、こんなのはRubyの単なるサブクラスで、そのクラスがたまたまModule
クラスだったというだけじゃないか、何を大げさな、とお思いの方もいらっしゃるかもしれません。技術的な観点からは、おっしゃるとおりでしょう。私はこのアイデアの第一発見者でもなければ、最初に記述したわけでもありません。単にFoo < Module
してみたらできたということです。
しかし、プログラミング言語を読むのも書くのも人間なのですから、名前は人間にとって大きな意味があります。もし仮にこのメソッドを持つモジュールがancestors
のリストに埋もれたまま見過ごされていたとしたら誰も気づかなかったでしょうし、どこかのブロガーがサブクラス化について書いたことをすっかり忘れてしまったら、この手法を使うこともないでしょう。このパターンがこれまでほぼ誰にも気づかれることなく存在し続けていたという事実そのものが、その証しです。
だからこそ、私はこれにキャッチーな名前を付けたのです。
私もRubyistとして、このパターンは私たちに必要だと考えます。ある巨大なRubyプロジェクトのコードを手にとって、モジュールが現場でどのように用いられ(かつ誤用され)ているかを考えてみてください。モジュールがトリガする多数のコールバックはありとあらゆる余分なステートをクラスにブートストラップし、クラスがinclude
するモジュールをがっちりと結合してしまいます。モジュールは自己完結すべきですが、実行時にモジュールを設定できないことを言い訳に、いろんな場所に設定をばらまくことが正当化されてしまっています。
Rubyはこうしたことをもっとうまく扱えるのですから、私たちももっとうまくやれるはずです。だからこそ、肥大化したモジュールに、アプリの隅々にまでおぞましい触手を伸ばすタコのようなモジュールに今一度目を向け、自由になるチャンスを与えてやってください。それがモジュールのためにも、ひいては皆さまのためにも良いことであると信じています。
バックナンバー
- #1 モジュールはどのように使われてきたか
- #2 Module Builderパターンとは何か
- #3 Rails ActiveModelでの利用例(本記事)
- あとがき: Module Builderパターンという名前について
関連記事
-
実際には、ActiveRecordのモデルには
ActiveRecord::AttributeMethods
がinclude
されています。これ自身がActiveModel::AttributeMethods
をinclude
して、本記事で扱うメソッドの一部(特にdefine_attribute_methods
)をオーバーライドしています。2つのモジュールの関連や、永続化(persisted)や、非永続化の属性メソッドの扱いはある意味で複雑なのですが、ここで議論されているアイデアはこの両方に関連しています。 ↩ - 以後の説明では、標準的なドキュメントでは指摘されていない部分をあえて強調するために、文中でクラス例から引用するコードを少し変えてありますのでご了承ください。 ↩
-
Mobility gemでは
FallthroughAccessors
とLocaleAccessors
というModule Builderで2つのケースを取り扱っています。私はこれらもMobilityからI18nAccessors
というgemに切り出しました。 ↩ -
実際には
ActiveModel::AttributeMethods
と同様、属性をエイリアスするalias_attribute
も含まれています。 ↩