Tech Racho エンジニアの「?」を「!」に。
  • 開発

RubyのModule Builderパターン #2 Module Builderパターンとは何か(翻訳)

元記事が非常に長いので次のように分割しました。

追記(2017/10/27)

  • 元記事からTechRachoにリンクいただきました🙇

概要

原著者の許諾を得て翻訳・公開いたします。

RubyKaigi 2017@広島でも発表されたshioyamaさんです。このときのセッションでもModule Builderパターンを扱っています。

本記事の図はすべて元記事からの引用です。また、パターン名は英語で表記しました。

RubyのModule Builderパターン #2 Module Builderパターンとは何か(翻訳)

Module Builder

ここまでに扱った3種類のadder系モジュールを振り返ってみましょう。

  1. Addable: 2つの変数xy#+メソッドを追加する。

  2. AdderDefiner: クラスメソッドdefine_adder(任意の変数のセットに#+メソッドを定義する)を追加する。

  3. AdderIncluder: これもクラスメソッドdefine_adder#+メソッドを定義する)を追加するが、このメソッドはsuperでコンポジションにできる。

1.のAddableモジュールはさほど柔軟ではありませんが、今思えば、これはこれでいくつかよい点があります。

  • #+だけをincludeするので、define_adderのような人工的なブートストラップが残らない(AdderDefinerAdderIncluderはそうではない)
  • superで親クラスからメソッドにアクセスできる(AdderIncluderでもできるがAdderDefinerではできない)
  • モジュールに名前があるのでancestorsチェインで簡単に確認できる。AdderDefinerAdderIncluderの場合、それ自身ancestorsチェインに表示されるが、ブートストラップメソッドが呼び出されたかどうか、どのように呼び出されたかの確認が困難(または確認不能)。

このセクションでは、こうしたadder系モジュールの利点を保つ方向で問題を解決する方法をご紹介します。前のセクションで追求した柔軟性もこの方法で得られます。

Module Builder

最初に、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で使ったコードですが、xyはそれぞれamounttaxに差し替えられます。しかしこのパワーは、モジュールの動的な定義で任意の変数名を指定できるという事実から生み出されています。

そして、このパターンの真の創意はまさしくここに潜んでいるのです。すなわち、モジュールを「メソッドや定数が固定されたコレクション」としてではなく、「欲しいコレクションをその場でビルドできる設定可能なプロトタイプ」として定義できるということです。これならクラスメソッドも不要ですし、モジュールのインクルード手順をブートストラップするメタプログラミング的トリックも不要です。このビルダはモジュールを直接ビルドするので、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章「継承よりコンポジションを選ぶ」」をご覧ください。

これは、先のセクションで説明したログ出力コードについても同様です。AdderBuilderLoggerBuilderのインスタンスを両方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は、「何か特別な処理を行う」べきかどうかを調べる正規表現です。

実際私は、MobilityFallthroughAccessorsという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

関連記事

[保存版]人間が読んで理解できるデザインパターン解説#1: 作成系(翻訳)

[保存版]人間が読んで理解できるデザインパターン解説#2: 構造系(翻訳)

Rubyで学ぶデザインパターンを社内勉強会で絶賛?連載中


  1. 実際には、これらのキーはクロージャを用いて定義されています。 
  2. amounttaxといった変数をincludeするこのModule Builderクラスに#inspectメソッドも定義しておけば、ancestorsチェインがさらに読みやすくなります。 
  3. MethodFoundでは他にも行っていることがあります。正規表現やprocに一致するメソッド名を「キャッシュ」してインターセプタモジュール上で定義し、以後の呼び出しを大幅に高速化しています。method_missingでメソッド名と一致した後にインターセプタ上のメソッドを調べてみればおわかりいただけると思います。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。