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

異色のRubyメソッド: `Module.class_exec`(翻訳)

概要

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

異色のRubyメソッド: Module.class_exec(翻訳)

Rubyには、普段はさほど使われないのに、ある種のメタプログラミング魔術をかけるときに必要になる異色の機能がゴロゴロしています。もちろんこれらの機能は簡単に使えます。Object.instance_execなんかは、ちょっと凝ったDSLを構築したことがあればおなじみではないかと思います。

Object#instance_execのスゴイ点は、渡されたオブジェクトのコンテキストから外れたところでコードを実行できることです。しかし見方を変えれば、引数を現在のコンテキストから渡すこともできるわけです。そのおかげで、こんなふうになかなかステキなDSLなどの機能を構築できます。

role_filter = ->(role) { where(role: role) }
role = "admin"
User.all.instance_exec(role, &role_filter)
# ↑User.all.where(role: "admin")と同じ

ここでひとつ面白いのが、Object#instance_execと同じようなものがクラスにもあることで、それがModule.class_execです。理論上の利用例についてはすぐわかりますが、現実世界でこのメソッドを利用することで最善の問題解決につながるユースケースにはどんなものがあるでしょうか?

問題の分析

あるモデルのあらゆるインスタンスでカスタムJSONを使いたいとしましょう。しかもさまざまな条件(モデルに属するカテゴリとか)に応じてインスタンスごとにJSONの属性をすべて違うものにしたいとしましょう。さらにややこしいことに、ユーザーがスキーマを自由にカスタマイズ可能で、最終的にどんな属性になるのか事前に予測しようがないとしましょう。

この機能は、このカスタムJSONのラッパークラスをいくつか提供することで実装し、ハッシュを直接操作する必要をなくしつつ、このオブジェクトでメソッドを呼び出せば属性にアクセスできるようにします。

この問題をつぶすにはOpenStructsを使うのが手っ取り早そうな感じですが、この例ではそうは問屋がおろしません。このクラスをLiquidテンプレートで公開する必要もあるのです。つまりLiquid::Dropからの継承が必要になるということです。

payloadを引数として取り、コンストラクタでObject#define_singleton_methodを用いてそのpayloadのキー/値を元にカスタムメソッドを定義するWrapperクラスたちをどうやって書くかです。シングルトンメソッドを定義するのが正しそうに思えます。実際、必要なメソッドがインスタンスごとに異なりそうです。まずはやってみましょう。

# app/drops/wrapper.rb
class Wrapper < Liquid::Drop
  def initialize(payload)
    @payload = payload

    payload.each do |key, value|
      define_singleton_method key do
        value
      end
    end
  end
end

問題解決の糸口が見えてきた感があります。

payload =  { ruby: "is freakin' awesome!" }
wrapper = Wrapper.new(payload)
wrapper.ruby
# => "is freakin' awesome!"

しかしここには重大な問題がひとつあります。これらのシングルトンメソッドはWrapper.public_instance_methodsの配列に含まれていないのです。

Wrapper.public_instance_methods.include?(:ruby)
# => false

場合によっては大した問題にならないこともありますが、LiquidDropクラスは明示的にpublic_instance_methodsをチェックしているのです。

最も頑丈な他の問題解決方法は何かないのでしょうか?

解決方法

あるのです!ただしこちらの方法は前述のものより少しばかりトリッキーですが。

まずClass自身のコンストラクタを用いて、Liquid::Dropを継承する「無名クラス」を作成します。次はpayloadを元に必要なメソッドを定義します。しかしそのクラスのコンテキストでpayloadが使えないときはどうしたらよいのでしょうか?どうにかして、そのクラスのコンテキストでpayloadを利用できる状態にしてコードを実行できるようにする必要があります。

ありがたいことに、私たちにはRubyという強い味方がついています。Module.class_execメソッドはまさにここで必要なことを行ってくれるのです。

こんな感じの実装になるでしょう。

payload =  { ruby: "is freakin' awesome!" }
magic_drop_class = Class.new(Liquid::Drop)
magic_drop_class.class_exec(payload) do |payload|
  payload.each do |key, value|
    define_method key do
      value
    end
  end
end
example = magic_drop_class.new
example.ruby
# => "is freakin' awesome!"

今度のpublic_instance_methodsはどうなったでしょうか?

magic_drop_class.public_instance_methods.include?(:ruby)
# => true

つまり目標は達成できたということです。

まとめ

Rubyはその強力さで知られており、その場でちょいと変更するとか相手のコンテキストで実行するといったあらゆることをオブジェクトに対して簡単に行なえます。このパワーと、Module.class_execというあまり陽の当たらないメソッドのおかげで、ある種の珍しいトリッキーな問題を、とてもエレガントに解決することができるのです。

関連記事

[Rails5] Active Support Core ExtensionsのString#inquiryでメタプログラミング

Ruby: 「マジック」と呼ぶのをやめよう(翻訳)

Ruby: delegate.rb標準ライブラリの動作を解明する(翻訳)


CONTACT

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