元記事が非常に長いので次のように分割しました。
- #1 モジュールはどのように使われてきたか
- #2 Module Builderパターンとは何か(本記事)
- #3 Rails ActiveModelでの利用例
- あとがき: Module Builderパターンという名前について
追記(2017/10/27)
- 元記事からTechRachoにリンクいただきました🙇
- shioyamaさんがRailsに投げたプルリクを知らせていただきました: #30895 Convert AttributeMethodMatcher to 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パターン #2 Module Builderパターンとは何か(翻訳)
Module Builder
ここまでに扱った3種類のadder系モジュールを振り返ってみましょう。
Addable
: 2つの変数x
とy
の#+
メソッドを追加する。-
AdderDefiner
: クラスメソッドdefine_adder
(任意の変数のセットに#+
メソッドを定義する)を追加する。 -
AdderIncluder
: これもクラスメソッドdefine_adder
(#+
メソッドを定義する)を追加するが、このメソッドはsuper
でコンポジションにできる。
1.のAddable
モジュールはさほど柔軟ではありませんが、今思えば、これはこれでいくつかよい点があります。
#+
だけをinclude
するので、define_adder
のような人工的なブートストラップが残らない(AdderDefiner
やAdderIncluder
はそうではない)super
で親クラスからメソッドにアクセスできる(AdderIncluder
でもできるがAdderDefiner
ではできない)- モジュールに名前があるので
ancestors
チェインで簡単に確認できる。AdderDefiner
やAdderIncluder
の場合、それ自身はancestors
チェインに表示されるが、ブートストラップメソッドが呼び出されたかどうか、どのように呼び出されたかの確認が困難(または確認不能)。
このセクションでは、こうしたadder系モジュールの利点を保つ方向で問題を解決する方法をご紹介します。前のセクションで追求した柔軟性もこの方法で得られます。
最初に、Module
クラスを継承するAdderBuilder
クラスを定義します。
class AdderBuilder < Module
def initialize(*keys)
define_method :+ do |other|
self.class.new(*(keys.map { |key| send(key) + other.send(key) }))
end
end
end
小さなクラスですが、とても密度の高い内容です。このクラスで行われていることを詳しく説明する前に、動作を確認してみましょう。ここでは、このクラスをインスタンス化してモジュールにしたものをLineItem
に(extend
ではなく)include
します。
class LineItem < Struct.new(:amount, :tax)
include AdderBuilder.new(:amount, :tax)
end
l1 = LineItem.new(9.99, 1.50)
l2 = LineItem.new(15.99, 2.40)
l1 + l2
#=> #<LineItem:... @amount=25.98, @tax=3.9>
すると、このちっぽけなクラスはAdderDefiner
内のブートストラップメソッド#define_adder
と同じように動作します。唯一違う点は、この動作のためのクラスメソッドをまったく定義していないことです。
いったいどうやっているのでしょうか?まず、ここではModule
クラスをサブクラス化しています。Module
クラスはnew
を呼べるので、サブクラス化してinitialize
などのメソッドを独自に定義できるだろうと推測できます。そして先に示したとおり、本当にできるのです。
クラスの初期化部分を再録します。
def initialize(*keys)
define_method :+ do |other|
self.class.new(*(keys.map { |key| send(key) + other.send(key) }))
end
end
このコードには2つのレベル(訳注: ネストのこと)があり、両方のレベルを認識することが重要です。1つのレベルでは、Module
クラスの新しいインスタンスを初期化しています。このインスタンスはそれ自身がいわゆる(小文字の)モジュールであり、他のクラスにmixinできるメソッドコレクションです。次のレベルではこのモジュールを初期化し、メソッドのひとつとして#+
を定義しています。
この#+
メソッド自身は、Module.new
に渡される引数(keys
は上述の:amount
と:tax
です)に含まれるメソッド名に沿って定義されます1。このメソッドの中でself.class.new
を呼ぶと、この新しいモジュールをinclude
するクラスのコンテキストでこのnew
が評価され、そこでLineItem
(またはPoint
のようにこのモジュールをinclude
したクラスなら何でもよい)を評価します。
すなわちAdderBuilder.new(:amount, :tax)
は、以下と機能的に同等な方法でモジュールを評価します。
Module.new do
def +(other)
self.class.new(amount + other.amount, tax + other.tax)
end
end
上はAddable
で使ったコードですが、x
とy
はそれぞれamount
とtax
に差し替えられます。しかしこのパワーは、モジュールの動的な定義で任意の変数名を指定できるという事実から生み出されています。
そして、このパターンの真の創意はまさしくここに潜んでいるのです。すなわち、モジュールを「メソッドや定数が固定されたコレクション」としてではなく、「欲しいコレクションをその場でビルドできる設定可能なプロトタイプ」として定義できるということです。これならクラスメソッドも不要ですし、モジュールのインクルード手順をブートストラップするメタプログラミング的トリックも不要です。このビルダはモジュールを直接ビルドするので、1ステップでモジュールをインクルードできます。
それだけではありません。先のセクションで解説したテクニックで生成される無名モジュールと異なり、この方法で作成されるモジュールには名前があるので、次のようにancestors
チェインではっきりと確認できます。
LineItem.ancestors
[LineItem, #<AdderBuilder:0x...>, Object, ... ]
これはまさしく、Module
クラスのサブクラス化で達成したカプセル化の素敵な副産物です2。無名モジュールでは、以下のようにデバッグ時に読み取りにくくなります。
LineItem.ancestors
[LineItem, #<Module:0x...>, Object, ... ]
Module Builderパターンはこのように、無名モジュールのメリット(モジュールのインスタンスを「その場で」定義でき、かつグローバル定数の名前空間を汚さない)を得ながら、通常のモジュールと同様にインスタンスが名前を持つので、デバッグトレースが簡単です。そのうえ、ブートストラップするクラスをinclude
するクラスでは新しいメソッドを定義する必要も呼び出す必要もありません。
それだけではありません。先ほどのコードでは、include
する側のクラスにログ出力のコードを少々追加しましたが、これもModule Builderとして書き直せます。これをLoggerBuilder
と呼ぶことにします。
class LoggerBuilder < Module
def initialize(method_name, start_message: "", end_message: "")
define_method method_name do |*args, &block|
print start_message
super(*args, &block).tap { print end_message }
end
end
end
後はログ出力するメソッドの名前でLoggerBuilder
のインスタンスを作成すれば、include
または継承したメソッドのログを出力できるようになります。
class LineItem < Struct.new(:amount, :tax)
include AdderBuilder.new(:amount, :tax)
include LoggerBuilder.new(:+, start_message: "Enter Adder...\n",
end_message: "Exit Adder...\n")
end
これだけで、足し算できる行項目からログが出力されます。
l1 = LineItem.new(9.99, 1.50)
l2 = LineItem.new(15.99, 2.40)
l1 + l2
Enter Adder...
Exit Adder...
#=> #<LineItem:... @amount=25.98, @tax=3.9>
もちろん、include
または継承されるどのメソッドも必要に応じてログ出力できます。
LineItem.include(LoggerBuilder.new(:to_s, start_message: "Stringifying...\n"))
l1.to_s
Stringifying...
=> "#<struct LineItem amount=9.99, tax=1.5>"
他にもいろんな側面がありますが、ここではごく表面的な説明にとどめます。現実に使われている、よりシンプルかつパワフルな実例については、Dry-rbのDry Equalizerをご覧ください。これは、AdderBuilder
で使ったModule
クラスのサブクラス化と同じように、Equalizer
クラスのインスタンスを使って同等性(equality)メソッドを動的にクラスに定義します。
Module Builderと「コンポジション」
本記事では、クラスにモジュールをinclude
する文脈で「コンポジション(composition、composed)」という言葉が多用されていることにお気づきでしょうか。あるメソッドをオーバーライドするモジュールをinclude
してからsuper
を呼ぶと、実質的に必ず関数(メソッド)のコンポジションになるのですが、このことは一般には指摘されていません。
訳注: コンポジションについては「Effective Java 16章「継承よりコンポジションを選ぶ」」をご覧ください。
これは、先のセクションで説明したログ出力コードについても同様です。AdderBuilder
とLoggerBuilder
のインスタンスを両方include
すると、#+
は関数コンポジションになるので、次のようにも書けます。
class LineItem < Struct.new(:amount, :tax)
def add(other)
self.class.new(amount + other.amount, tax + other.tax)
end
def log(result)
print "Enter Adder..."
result.tap { print "Exit Adder..." }
end
def +(other)
log(add(other))
end
end
したがって、super
は本質的に最後のメソッド呼び出しの出力を受け取って、現在のモジュールのメソッドの結果に組み入れます。
偶然にも、コンポジションでは、あるModule Builderから生成された複数のインスタンスがきわめて興味深い方法で束ねられます。上の例では、ログ出力モジュールと追加用モジュールが束ねられます。Module Builderを使えば、さまざまなモジュールを設定して束ね、メソッドやクラスのより複雑な振舞いをビルドすることができます。
その方法のひとつは、メソッドフローの「分岐」です。モジュールをいくつか定義し、それぞれのモジュールが同じメソッドをオーバーライドします。そしてメソッド内の条件と引数がモジュールのステートと一致したら何か特別な処理を行い、一致しない場合はsuper
によって制御フローがクラス階層の上のモジュールに移動する、というものです。
「コンポジションの分岐」といえば、Rubyを使った経験が少しでもある方なら例のmethod_missingメソッドを連想することでしょう。super
せずにmethod_missing
をオーバーライドする人はまずいないはずです。そんなことをしたら、クラスに定義されていないメソッドまでキャッチしてしまう可能性があり、一般的にはそうした動作を望む人はいないでしょう。
method_missing
の典型的なオーバーライドでは、以下のようにsuper
するのが普通です。
def method_missing(method_name, *arguments, &block)
if method_name =~ METHOD_NAME_REGEX
# ... 何か特別な処理を行う ...
else
super
end
end
上のMETHOD_NAME_REGEX
は、「何か特別な処理を行う」べきかどうかを調べる正規表現です。
実際私は、MobilityのFallthroughAccessorsというModule Builderでまさにこのフロー分岐を使いました。FallthroughAccessors
の各インスタンスは属性のセット(=ステートを表す)で初期化され、属性の1つにロケールサフィックスを追加したメソッド名(例: フランス語のタイトル属性はtitle_fr
)が生成される場所でメソッド呼び出しをインターセプト(intercept: 奪い取る)します。私はこれを「i18nアクセサ」と呼んでいます。メソッド名が一致すれば、そのサフィックスのロケールに含まれる属性値が返され、一致しない場合、制御は引き続きクラス階層を上昇します。
Module Builderでこのようにmethod_missing
のコンポジションを行うと、正規表現のネストした条件が一連のモジュール全体に広がります。私はこれをインターセプタ(interceptor)と呼んでいます。これらのインターセプタは全体がカプセル化され、そのクラス自身が外部に依存しないという事実があります。そのためMobilityのi18nアクセサは、翻訳された各属性に自由に「プラグイン」できるようになります。しかも両者は互いに完全に独立しています。
私はこのパターンがMobilityで有用であることに気づき、MethodFoundというgemに切り出しました。MethodFoundは本質的にMethodFound::Interceptor
という1つのModule Builderであり、1つの正規表現やproc(ここではステート)に一致するメソッド呼び出しをインターセプトし、メソッド名/正規表現でキャプチャできたマッチ(またはprocの戻り値やメソッド)のすべての引数をブロック(上の「何か特別な処理を行う」の部分)に渡します3。このブロックは、モジュールをinclude
するクラスのインスタンスのコンテキストで評価されます。
次に例を示します。
class Greeter < Struct.new(:name)
include MethodFound::Interceptor.new(/\Asay_([a-zA-Z_]+)\Z/) { |method_name, matches|
"#{name} says: #{matches[1].gsub('_', ' ').capitalize}."
}
include MethodFound::Interceptor.new(/\Ascream_([a-zA-Z_]+)\Z/) { |method_name, matches|
"#{name} screams: #{matches[1].gsub('_', ' ').capitalize}!!!"
}
include MethodFound::Interceptor.new(/\Aask_([a-zA-Z_]+)\Z/) { |method_name, matches|
"#{name} asks: #{matches[1].gsub('_', ' ').capitalize}?"
}
end
上のmethod_missing
は3つのインターセプタのコンポジションです。Greeter
クラスにはこれら以外にトレースがないにもかかわらず、含まれている正規表現マッチャーに対応する3つのモジュールビルダをancestors
で直接表示できます(表示のため、インターセプタはModule#inspect
をオーバーライドしています)。
Greeter.ancestors
=> [Greeter,
#<MethodFound::Builder:0x...>,
#<MethodFound::Interceptor: /\Aask_([a-zA-Z_]+)\Z/>,
#<MethodFound::Interceptor: /\Ascream_([a-zA-Z_]+)\Z/>,
#<MethodFound::Interceptor: /\Asay_([a-zA-Z_]+)\Z/>,
Object, ...]
つまり、クラスにないメソッドが呼び出されると、呼び出しはクラス階層を上昇して最初の「ask」インターセプタの正規表現と一致するかどうかをチェックします。一致する場合は値を1つ返し、一致しない場合はその上の階層にある「scream」インターセプタで次の正規表現と一致するかどうかをチェックし、という具合に繰り返します。
結果は次のとおりです。
greeter = Greeter.new("Bob")
greeter.say_hello_world
#=> "Bob says: Hello world."
greeter.scream_good_morning
#=> "Bob screams: Good morning!!!"
greeter.ask_how_do_you_do
#=> "Bob asks: How do you do?"
上の例ではmethod_missing
とModule Builderが使われていますが、ここで1つの疑問が生じます。上のような実装をModule Builderを使わずに、しかも同じ程度に柔軟な方法で行うことは果たして可能でしょうか。
仮にModule Builderを使わないことにすると、まずモジュールのステートの置き場所がなくなってしまいます。「普通の」モジュールにはそんなものを置く場所はありません。つまり、インターセプタ内の正規表現やブロックは別の場所に置く必要があるでしょう。これらを自然に置ける場所があるとすれば、include
する側のクラス自身しかありません。
このような方法には多くの問題があり、現実の例を1つ見てみれば問題点が明確になるはずです。
(#3 Rails ActiveModelでの利用例に続く)
図版(#2で使用しているもの)
c “Proposed demonstration of simple robot self-replication.” reference