Help us understand the problem. What is going on with this article?

[Ruby] method_missing()を実用レベルで理解する

More than 5 years have passed since last update.

概要

メタプログラミングRuby勉強録。

前回は
[Ruby] メタプログラミングの入り口、オープンクラスを理解する
http://qiita.com/kidachi_/items/b1672f1c16e2d15f2d9c

今回のトピックは「Rubyらしいプログラミング」に欠かせない、method_missing()です。

  • method_missing()とは
  • 何に役立つか
  • 危険性と対策

method_missing()とは

メソッド呼び出しの際、継承チェーン(※)を辿った末に呼び出しメソッドが見つからなかった時、
最終的に呼び出されるメソッド。

※継承チェーンはModule#ancestorsで確認できます。
http://ref.xaio.jp/ruby/classes/module/ancestors

class Ruby
end

r = Ruby.new
r.hello
=> NoMethodError: undefined method `hello' for #<Ruby:0x007fe4bb9ac7a0>

全てのオブジェクトの継承元であるBasicObjectに定義されているため、
あらゆるオブジェクトからの呼び出しに対応できます。

そして、あくまでこいつもメソッドなので、明示的に呼び出すことも可能です。

r.send :method_missing, :hello
=> NoMethodError: undefined method `hello' for #<Ruby:0x007fe4bb9ac7a0>

※Object#sendについてはこちら。
Ruby send メソッド
http://blog.livedoor.jp/badrequest400/archives/2350825.html

method_missing()自体はこれだけですが、
ここでメタプログラマは考えます。

「逆に言えば、method_missing()のタイミングでうまく拾い上げることで、
未定義メソッドに対しても好きな処理を加えられるってことだよね。」

class Ruby
  def method_missing(method, *args)
    # 好きな処理
    puts "#{method}が呼ばれました。"
  end
end
r = Ruby.new
r.hello
=> 'helloが呼ばれました。'

こういうことですね。
'NoMethodError'という、(普通にプログラムを書いている限り)絶対の法則を
ねじ曲げることが出来ました。

何に役立つか

例えば、外部APIに依存するライブラリ。

サンプルとして、仮にqiitaに関する情報を取得できるAPIがあったとしましょう。
(適当です&実在しませんのであしからず)

https://qiita_api.example.com/services/rest/?method=qiita.user.findByName&user_name=kidachi_&api_key=API_KEY
=> 
{
    "result": {
        "user": {
            "id": 8790,
            "name": "kidachi_",
            "profile_image_url": "http://img.example.com/kidachi_.png"
        }
    }
}

そして、これをRubyで手軽に扱えるようにサポートするgem(ライブラリ)があるとします。
その名もrubiita(同じく実在しません。ネーミングx)。

require 'rubiita'

qiita = Rubiita.new(API_KEY)
kidachi_ = qiita.user_findByName(user_name: "kidachi_")
kidachi_.result.user.profile_image_url
=> "http://img.example.com/kidachi_.png"

qiita_users = qiita.user_findAll
qiita_users.result.users
=>
[
    {
      "id": 1,
      "name": "foo",
      "profile_image_url": "http://img.example.com/foo.png"
    },
    {
      "id": 2,
      "name": "bar",
      "profile_image_url": "http://img.example.com/bar.png"
    }   
]

こんな感じですね。
API上での

?method=qiita.user.findByName&user_name=kidachi_」

というパラメータを

qiita.user_findByName(user_name: "kidachi_")

とRubyライクなメソッドとして扱えるようにしています。

さて、もしかするとここで疑問が出るかもしれません。

「このライブラリ、qiitaのAPIがアップデートされて
使えるメソッドが追加・修正される度にあわせて更新しないと
いけないよね?めんどくさいね」と。

実は、その必要はありません。

rubiitaの実装を見てみましょう。

class Rubiita
  require 'net/http'
  require 'uri'
  require 'json'

  END_POINT = 'https://qiita_api.example.com/services/rest/'
  API_KEY = 'hoge'

  def request(method, params)
    json = Net::HTTP.get(get_uri(method, params))
    result = JSON.parse(json)
    puts result
  end

  def method_missing(method, *params)
    request(method.gsub(/_/, '.'), params)
  end

  private
  def get_uri(method, params)
    formatted_params = params.map do |arg|
      param.map do |key, val|
        key.to_s+"="+val.to_s
      end
    end
    formatted_params = formatted_params.join("&")
    method = "?method="+method+"&"
    request_uri = END_POINT+method+formatted_params+API_KEY
  end

end

request()はAPIリクエストを飛ばしているだけなのであまり大事ではないです。
ポイントはmethod_missing()とget_uri()ですね。

method_missing()により、
rubiitaから受け取ったメソッド名のアンダースコアをドットに置換し、
パラメータ群はget_uri()に送ります。
そしてget_uri()でGET形式のAPIに適したURIを動的に生成しています。

これにより、qiitaAPIのメソッドが

?method=qiita.user.findByName&user_name=kidachi_
?method=qiita.user.findAll
?method=qiita.user.newMethodFugaFuga&foo=bar

こういったどのようなパターンであっても、
rubiitaユーザに

qiita.SEGMENT.METHOD_NAME(PARAMS)

というルールに則ってもらいさえすれば、

qiita.user_findByName(user_name: "kidachi_")
qiita.user_findAll
qiita.new_methodFugaFuga(foo: bar)

全てが適切なメソッドとしてサポートされます。

将来、今は存在しない新しいメソッドが提供されようと、
全て吸収できてしまうのです。

便利ですね!

ゴーストメソッド

上記例のような、未定義だがmethod_missing()で拾うことを前提
としたメソッドをゴーストメソッドと呼びます。

method_missing()で陥りやすいバグ

最後に、method_missing()の危険性も見ておきます。
※メタプログラミングRubyの例そのままですが。

ぜひ一度どこに問題が潜んでいるか考えてみてください。

class Roulette
  def method_missing(name, *args)
    person = name.to_s.capitalize
    3.times do
      number = rand(10) + 1
      puts "#{number}..."
    end
    "#{person} got a #{number}"
  end
end

number_of = Roulette.new
puts number_of.bob
puts number_of.frank

このコード、Rouletteの名前通り、以下のように
人物ごとにランダム値が得られる結果を期待しています。

5
6
10
Bob got a 10
7
4
3
Frank got a 3

ですが、実際には

9...
3...
5...
7...


SystemStackError: stack level too deep from..

このように無限ループの後StackOverFlowが発生してします。

原因は、ブロックローカル変数であるnumberです。

  • ブロック内で定義したブロックローカル変数は外部から参照できない
  • そのため、「"#{person} got a #{number}"」のタイミングで Rubyはそれ(number)を「メソッド」だと認識し、探しにいく
  • しかし継承チェーン内に見つからないためmethod_missing()が呼ばれる
  • だが、そもそも今回の問題は(オーバーライドした)method_missing()自体で起きている
  • よってバグは無限ループし続ける

この問題、予期できましたでしょうか?

method_missingにバグが潜んでいると、特定しづらい(問題に発展しやすい)

ということが分かると思います。

対策

シンプルですが、

必要のないゴーストメソッドは導入しない

こと。

つまり、事前に予期されるゴーストメソッド(の名前)以外は受け付けないようにする、ということですね。

対策例
class Roulette
  def method_missing(name, *args)
    person = name.to_s.capitalize
    # 想定される3名以外は通常通りのmethod_missing(super)を呼び出す
    super unless %w[Bob Frank Bill].include? person    
    # numberをブロック外から参照できるように事前にセット。
    number = 0
    3.times do
      number = rand(10) + 1
      puts "#{number}..."
    end
    "#{person} got a #{number}"
  end
end

number_of = Roulette.new

# 受け付けられない
puts number_of.hogehoge
=> NoMethodError: undefined method `hogehoge' for #<Roulette:0x007fe4bb9ac7a0>

puts number_of.bob
=> 3...
   5...
   5...
   Bob got a 5

結論

オープンクラスのエントリの結論と一緒です!
http://qiita.com/kidachi_/items/b1672f1c16e2d15f2d9c#ps1

kidach1
Qiitaの運営方針に疑問があるため、基本的に今後の投稿は考えていません。 主に https://twitter.com/kidach1 で活動報告しています。
aktsk
株式会社アカツキは、スマートフォンゲームの企画開発を中心に事業を展開しております。創業以来全てのゲームを内製しているため、高い技術ノウハウが蓄積されています。今後は、新規事業の立ち上げも行ってまいります。
http://aktsk.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away