異色の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標準ライブラリの動作を解明する(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

BPSアドベントカレンダー

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ