Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

設定可能なRubyモジュールの作り方:モジュールビルダーパターン徹底解説

本記事では、コードの利用者が自由にカスタマイズできる「設定可能なRubyモジュール」の作成方法を解説します。このパターンを使えば、gemの作者はライブラリに柔軟性を持たせることができます。

Ruby開発者の多くは、モジュールを使って振る舞いを共有する手法に馴染みがあるでしょう。公式ドキュメントによれば、これこそがモジュールの主な用途の一つとされています。

Rubyにおいてモジュールには2つの役割があります。名前空間の提供と、mixin(ミックスイン)機能です。

RailsではActiveSupport::Concernという糖衣構文が追加されましたが、基本的な考え方は変わりません。

課題:静的なモジュールの限界

モジュールでmixin機能を提供するのは、通常とてもシンプルです。いくつかのメソッドをまとめてモジュールに定義し、それを別の場所でincludeするだけです。

module HelloWorld
  def hello
    "Hello, world!"
  end
end
class Test
  include HelloWorld
end
Test.new.hello
#=> "Hello, world!"

ただし、この仕組みはかなり静的です。とはいえ、Rubyのinheritedextendedといったフックメソッドを使えば、includeしたクラスに応じて挙動を変えることは可能です。

module HelloWorld
  def self.included(base)
    define_method :hello do
      "Hello, world from #{base}!"
    end
  end
end
class Test
  include HelloWorld
end
Test.new.hello
#=> "Hello, world from Test!"

先ほどよりは動的になりましたが、それでも例えば「モジュールをincludeするタイミングでhelloメソッドの名前を変更する」といったことは、コードの利用者にはできません。

解決策:設定可能なRubyモジュール

ここ数年で、この問題を解決する新しいパターンが登場しました。「モジュールビルダーパターン」と呼ばれることもあるこの手法は、Rubyの2つの重要な特徴に依拠しています。

  • モジュールも他のオブジェクトと同じく、その場で生成したり、変数に代入したり、動的に変更したり、メソッドの引数として渡したり戻り値として返したりできる。

    def make_module
      # モジュールをその場で生成し、変数に代入
      mod = Module.new
     
      # モジュールを変更
      mod.module_eval do
        def hello
          "Hello, AppSignal world!"
        end
      end
     
      # 明示的に返す
      mod
    end
  • includeextendの引数は、必ずしもモジュールそのものである必要はなく、モジュールを返す式(メソッド呼び出しなど)でもよい。

    class Test
      # make_moduleが返すモジュールをinclude
      include make_module
    end
     
    Test.new.hello
    #=> "Hello, AppSignal world!"

モジュールビルダーを実践してみる

それでは、この知識を活かしてWrapperというシンプルなモジュールを作ってみましょう。実装する振る舞いは以下の通りです。

  1. Wrapperをincludeするクラスは、特定の型のオブジェクトのみをラップできます。コンストラクタは引数の型を検証し、期待される型と一致しない場合はエラーを発生させます。
  2. ラップされたオブジェクトは、original_<クラス名>というインスタンスメソッド(例:original_integeroriginal_string)から取得できます。
  3. コードの利用者は、このアクセサメソッドに別の名前(例:the_string)を指定することもできます。

まず、完成形のコードがどう動くべきかを見てみましょう。

# 1
class IntWrapper
 # 2
 include Wrapper.for(Integer)
end
 
# 3
i = IntWrapper.new(42)
i.original_integer
#=> 42
 
# 4
i = IntWrapper.new("42")
#=> TypeError (not a Integer)
 
# 5
class StringWrapper
 include Wrapper.for(String, accessor_name: :the_string)
end
 
s = StringWrapper.new("Hello, World!")
# 6
s.the_string
#=> "Hello, World!"

ステップ1では、IntWrapperという新しいクラスを定義しています。

ステップ2では、単純にモジュールを名前でincludeするのではなく、Wrapper.for(Integer)の呼び出し結果をmixinしている点に注目してください。

ステップ3では、新しいクラスのインスタンスを生成してiに代入します。要件どおり、このオブジェクトにはoriginal_integerというメソッドが存在します。

ステップ4では、文字列のように誤った型の引数を渡すと、親切なTypeErrorが発生します。最後に、利用者がカスタムのアクセサ名を指定できることを確認しましょう。

そのために、ステップ5でStringWrapperという新しいクラスを定義し、キーワード引数accessor_namethe_stringを渡します。ステップ6でその効果を確認できます。

やや作為的な例ではありますが、モジュールビルダーパターンの仕組みと使い方を示すには十分なバリエーションがあります。

最初の実装

要件と使用例をもとに、実装を始めましょう。必要なのは、クラスを第1引数に取り、キーワード引数を受け取るforというモジュール関数を持つWrapperモジュールです。

module Wrapper
 def self.for(klass, accessor_name: nil)
 end
end

このメソッドの戻り値はincludeの引数になるため、モジュールである必要があります。Module.newで新しい匿名モジュールを作成しましょう。

Module.new do
end

要件に従うと、このモジュールには、渡されたオブジェクトの型を検証するコンストラクタと、適切な名前のアクセサメソッドが必要です。まずはコンストラクタから始めます。

define_method :initialize do |object|
 raise TypeError, "not a #{klass}" unless object.is_a?(klass)
 @object = object
end

このコードではdefine_methodを使って、レシーバにインスタンスメソッドを動的に追加しています。ブロックはクロージャとして機能するため、外側のスコープにあるklassオブジェクトを参照して型チェックを行えます。

適切な名前のアクセサメソッドを追加するのも、それほど難しくありません。

# 1
method_name = accessor_name || begin
 klass_name = klass.to_s.gsub(/(.)([A-Z])/,'\1_\2').downcase
 "original_#{klass_name}"
end
 
# 2
define_method(method_name) { @object }

まず、コードの利用者がaccessor_nameを渡したかどうかを確認します。渡されていれば、それをmethod_nameに代入して完了です。そうでなければ、クラス名をスネークケースの文字列に変換します。たとえばIntegerintegerに、OpenStructopen_structになります。このklass_nameoriginal_という接頭辞を付けて、最終的なアクセサ名を生成します。メソッド名が確定したら、ステップ2のとおり再びdefine_methodでモジュールに追加します。

ここまでの完全なコードが以下です。柔軟で設定可能なRubyモジュールが20行未満で書けています。悪くないでしょう。

module Wrapper
  def self.for(klass, accessor_name: nil)
    Module.new do
      define_method :initialize do |object|
        raise TypeError, "not a #{klass}" unless object.is_a?(klass)
        @object = object
      end
 
      method_name = accessor_name || begin
        klass_name = klass.to_s.gsub(/(.)([A-Z])/,'\1_\2').downcase
        "original_#{klass_name}"
      end
 
      define_method(method_name) { @object }
    end
  end
end

気づいた方もいるかもしれませんが、Wrapper.forは匿名モジュールを返します。これは問題ではありませんが、オブジェクトの継承チェーンを調べる際に少し分かりにくくなります。

StringWrapper.ancestors
#=> [StringWrapper, #<Module:0x0000000107283680>, Object, Kernel, BasicObject]

ここで#<Module:0x0000000107283680>(実際に試すと名前は異なります)が、私たちの匿名モジュールを指しています。

改良版:名前付きモジュールを返す

利用者のために、匿名モジュールではなく名前付きモジュールを返すように改良しましょう。コードはほぼ同じで、若干の変更を加えるだけです。

module Wrapper
  def self.for(klass, accessor_name: nil)
    # 1
    mod = const_set("#{klass}InstanceMethods", Module.new)
 
    # 2
    mod.module_eval do
      define_method :initialize do |object|
        raise TypeError, "not a #{klass}" unless object.is_a?(klass)
        @object = object
      end
 
      method_name = accessor_name || begin
        klass_name = klass.to_s.gsub(/(.)([A-Z])/, '\1_\2').downcase
        "original_#{klass_name}"
      end
 
      define_method(method_name) { @object }
    end
 
    # 3
    mod
  end
end

ステップ1では、「#{klass}InstanceMethods」(例:IntegerInstanceMethods)という名前のネストしたモジュールを作成しています。現時点では「空」のモジュールです。

ステップ2のとおり、forメソッド内でmodule_evalを使用します。これは、呼び出し対象のモジュールのコンテキストでコードブロックを評価するものです。これにより、ステップ3で返却する前にモジュールへ振る舞いを追加できます。

これで、Wrapperをincludeしたクラスの祖先を確認すると、適切な名前のモジュールが表示されるようになります。以前の匿名モジュールよりもはるかに意味が分かりやすく、デバッグもしやすくなりました。

StringWrapper.ancestors
#=> [StringWrapper, Wrapper::StringInstanceMethods, Object, Kernel, BasicObject]

実世界でのモジュールビルダーパターン

本記事以外に、モジュールビルダーパターンや類似のテクニックはどこで見られるのでしょうか。

一つの例がdry-rbファミリーのgem群です。たとえばdry-effectsでは、モジュールビルダーを使って各種エフェクトハンドラに設定オプションを渡しています。

# `counter`エフェクトプロバイダを追加。エフェクトを処理(除去)する
include Dry::Effects::Handler.State(:counter)
 
# スコープの指定は必須
# すべてのキャッシュ値はこのキーでスコープされる
include Dry::Effects::Handler.Cache(:blog)

同様の使い方は、Rubyアプリケーション向けファイルアップロードツールキットを提供する優れたShrine gemにも見られます。

class Photo < Sequel::Model
  include Shrine::Attachment(:image)
end

このパターンはまだ比較的新しいものですが、今後さらに広まっていくと予想されます。特に、RailsアプリケーションよりもピュアなRubyアプリケーションに焦点を当てたgemで採用が進むでしょう。

まとめ

本記事では、Rubyで設定可能なモジュールを実装する方法、いわゆるモジュールビルダーパターンについて解説しました。他のメタプログラミング技法と同様、複雑さが増すという代償を伴うため、正当な理由がない限り使うべきではありません。しかし、そのような柔軟性が本当に必要となる稀なケースでは、Rubyのオブジェクトモデルがエレガントで簡潔な解決策を再度提示してくれるのです。モジュールビルダーパターンは、ほとんどのRuby開発者が頻繁に必要とするものではありませんが、特にライブラリ作者にとっては、ツールボックスに入れておきたい強力な道具と言えるでしょう。

P.S. Ruby Magicの記事を公開と同時にお読みになりたい方は、Ruby Magicニュースレターを購読すれば、記事を見逃すことはありません!

  1. 【Ruby】デコレータデザインパターンでクラスを変更せずにオブジェクトを拡張する方法

    デコレータ(Decorator)デザインパターンとは、どのようなものなのでしょうか? また、このパターンをRubyのプロジェクトではどのように活用すればよいのでしょうか? デコレータデザインパターンを利用すると、クラスそのものを変更することなく、オブジェクトに新しい機能を追加して拡張することができます。 それでは、具体的な例を見ていきましょう! ログ出力とパフォーマンス計測の実装例 この例では、rest-clientのようなGemを使ってHTTPリクエストを送信します。 コードは次のようになります。 require restclient data = RestClient.get(www.

  2. RubyのEnumerableモジュール入門ガイド〜筆者イチオシのeach_consメソッドを徹底解説〜

    Enumerableモジュールとは? Enumerableは繰り返し処理(イテレーション)用メソッドのコレクションで構成されるRubyのモジュールであり、Rubyが多くの開発者に愛される大きな理由のひとつとなっています。 Enumerableには、次のような便利なメソッドが含まれています: map select inject Enumerableのメソッドは、ブロックを渡すことで動作します。 ブロックの中には、各要素に対して実行したい処理を記述します。 例えば: [1,2,3].map { |n| n * 2 } このコードを実行すると、すべての数値が2倍になった新しい配列が得られます