概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Exotic Ruby: Module.class_exec, custom JSON And Liquid Drops In Action - Karol Galanciak - Ruby on Rails and Ember.js consultant
- 原文公開日: 2018/03/27
- 著者: Karol Galanciak
異色の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
場合によっては大した問題にならないこともありますが、LiquidのDrop
クラスは明示的に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でメタプログラミング