method_missingとfind_by_xxx

Refactormycodeを見てたら、method_missingを使った例があったので、試しに使ってみた。

method_missingとfind_by_xxx

Railsには、このmethod_missingを使ったハックがある。find_by_name、find_all_by_nameとかのfind_xxxメソッドだ。
method_missingは通常はそのメソッドがなかった時にエラーを発生させる仕事をしている。(リファレンスRailsでは、このメソッドをオーバーライドして、find_by_xxxを実現している。

find_by_xxxの仕組み

例えば、Userモデルがあり、usersテーブルにnameカラムがあるとする。この時、find_by_nameは簡単に言えば次のよう処理を行う。

1.User.find_by_name("bob")を呼ぶ
2.Uesrクラスにfind_by_nameメソッドは定義されていないので、method_missingが呼ばれる。
3.method_missingの中でfind_by_nameのbyとnameを切り出す。
4.byを切り出したので、呼び出すメソッドはfind(:first)となる。(find_all_byならfind(:all)を呼ぶ)
5.nameを切り出したので、findメソッドの引数に、conditions => ["name => ?","bob"]が渡される。

この例では、メソッドの名前から、検索対象になるカラムを抽出している。

自分でもやってみた

考えたのはこんな感じ。(:最後に書いたが、この例は良くない)

  • Userクラスと、そのプロフィールを保持するクラスがある
  • Userには、学生と社会人がいて、それぞれ別のプロフィールを持つ
  • 学生のプロフィールをStudentProfile、社会人のプロフィールをWorkerProfileとする
  • プロフィールの親クラスとしてAbstractProfileを定義する(この例では不要だった)
ここまでを実装してみる
  • User
class User
  attr_reader :name,:age,:type,:profile
  
  def initialize(args)
    @name    = args[:name]
    @age     = args[:age]
    @type    = args[:type] 
    @profile = args[:profile] 
  end
end
  • AbstractProfile(Workerクラスにないはずのschoolが親クラスにあるのは変)
class AbstractProfile
  def school;  nil;  end
  def company; nil;  end
end
  • WorkerProfile
class WorkerProfile
  attr_reader :company

  def initialize(company)
    @company = company
  end
end
  • StudentProfile
class StudentProfile 
  attr_reader :school
  
  def initialize(school)
    @school = school
  end
end


このままだと、Userの学校名を知りたい場合に、@user.profile.schoolのようにprofileを挟んで呼び出すことになる。プロフィールは別クラスに持っておきたいが、@user.schoolと呼び出したい。
ここでmethod_missingを使うことができる。(いわゆる委譲を実装する)

method_missingを導入

Userクラスを次のように変更する。

class User
  attr_reader :name,:age,:type,:profile
  
  def initialize(args)
    @name    = args[:name]
    @age     = args[:age]
    @type    = args[:type] 
    @profile = args[:profile] 
  end

  private
  
  def method_missing(method_id,*args)    
    if profile.respond_to?(method_id) 
      profile.send(method_id)
    else
      super
    end
  end
end

インスタンスメソッドに対するNoMethodErrorを補足するので、上のように書く。クラスメソッドに対するエラーを補足したい場合は、method_missingをクラスメソッドとして定義する。
これで、@user.schoolのように書けるようになる。

テスト
  • UserTest
require 'test/unit'
require 'user'
require 'worker_profile'
require 'student_profile'

class UserTest < Test::Unit::TestCase
  def setup
    @bob   = User.new(:name => "bobby",
                      :age => 27,
                      :type => "worker",
                      :profile => WorkerProfile.new("Abc company"))
    
    @becky = User.new(:name => "rebecka",
                      :age => 17,
                      :type => "student",
                      :profile => StudentProfile.new("Xyz school"))
  end
  
  def test_user
    #bob
    assert_equal "bobby",       @bob.name
    assert_equal 27,            @bob.age
    assert_equal "worker",      @bob.type
    assert_equal "Abc company", @bob.profile.company
    
    #becky
    assert_equal "rebecka",     @becky.name
    assert_equal 17,            @becky.age
    assert_equal "student",     @becky.type
    assert_equal "Xyz school",  @becky.profile.school
  end

  def test_method_missing
    assert_equal "Abc company", @bob.company
    assert_equal "Xyz school",  @becky.school    
    assert_raise(NoMethodError){@bob.hoge}
  end
end

rubikitchさんにトラバで突っ込まれたよ

委譲といえばForwardable-’(rubikitch wanna be (a . lisper))

method_missingは多くの場合デバッグが困難になるので乱用厳禁。あくまで最終手段にすべき。DRbのように相手が得体の知れないオブジェクトならばmethod_missingは有効だが、よくわかってるオブジェクト相手には基本的にmethod_missingは使用すべきではない。

Userクラスを読んだときに、取り得るメソッドの集合が一目でわからないところが気持ち悪い。

う、やっぱりつっこまれた。悪例でした。すみませんm(_ _)mトラバ先には、代わりに以下の方法が書いてあった。

  • define_methodを使う

これは知ってた。Refactormycodeにも、define_method使う例があったから。

  • Forwardableを使う

これは知らなかった(・∀・)使うとこう書けるようになる。

extend Forwardable
  def_delegators :@profile, :school, :company

なるほど。確かに、Userクラスのメソッドが明示的に分かる。
委譲としてのmethod_missingはよくなかったな。勉強になった☆ribikitchさんトラバありがとうございます!