Ruby の BasicObject + method_missing で遊んでみる

Twitter 上で BasicObject についてやり取りしている流れで『BasicObject を使うことってあるんですか?』みたいな質問があったので簡単に BasicObject + #method_missing を使ったデバッグ用のクラスを定義してみました。
任意のオブジェクトを以下の DebugCalled クラスでラップすると『そのオブジェクトから呼び出されたメソッド』をデバッグ情報として出力する事ができます。

BasicObject#method_missing について

  • BasicObject : このクラスを継承すると Object クラスが継承されなくなり最小限のメソッドのみが組み込まれる
  • #method_missing : 定義されていないメソッドが呼びされるとこのメソッドにフォールバックされる

DebugCalled クラス

class DebugCalled < BasicObject
  using ::Module.new {
    refine ::Object do
      def __apply__(name, *args)
        __send__(name, *args)
      end
    end
    refine ::DebugCalled do
      def __apply__(name, *args)
        @target.__send__(name, *args)
      end
    end
  }
  def initialize(target)
    @target = target
  end

  def ==(other)
    @target == other
  end

  def equal?(other)
    @target.equal? other
  end

  # いずれかのメソッドが呼び出されたときに処理をフックする
  # 呼び出されたメソッドは @target へ委譲する
  def method_missing(name, *args)
    result = @target.__send__(name, *args)
  ensure
    ::Kernel.puts "[debug called]: #{@target.__apply__(:inspect)}.#{name}(#{args.map { _1.__apply__(:inspect) }.join(", ")}) => #{result.inspect}"
  end

  module Refine
    refine Object do
      def debug
        DebugCalled.new(self)
      end
    end
  end
end

やってることはそこまで難しくなくて単に #method_missing を利用して DebugCalled クラスに定義されていないメソッドが呼ばれた時に @target にメソッド呼び出しを委譲しているだけになります。

obj = DebugCalled.new("homu")

# DebugCalled には #length メソッドは存在しない
# なので DebugCalled#method_missing が呼ばれっる
# #method_missing 内で "hoge".length が呼び出される
obj.length
# output: called: homu.length() => 4

ここで肝なのは表題の BasicObject を継承している点で、 BasicObject を形容することで DebugCalled クラスは最小限のメソッドのみが定義されます。
例えば #tap#then #class などと言ったメソッドは DebugCalled クラスでは使えなくなります。
なので obj.class などを呼び出した場合は #method_missing を経由して @target#class が呼びされれることになります。

obj = DebugCalled.new("homu")
# "homu".class が呼びされる
obj.class
# output: called: homu.class() => String

このように汎用的なメソッドも委譲したい場合に BasicObject を継承することで利用する事ができます。
で、このクラスを利用すると実際にどのメソッドが呼びされるのかを調べることができます。

def plus(a, b)
  a + b
end

def twice(a)
  plus(a, a)
end

using DebugCalled::Refine


# メソッド内でどのメソッドが呼び出されるのかを調べる
plus(1.debug, 2)
# output: [debug called]: 1.+(2) => 3
plus(1, 2.debug)
# output: [debug called]: 2.coerce(1) => [1, 2]

puts "---"

twice("homu".debug)
# output:
# [debug called]: "homu".to_str() => "homu"
# [debug called]: "homu".+("homu") => "homuhomu"

puts "---"

# 標準のメソッドでも出力される(こともある
%w(homu mami mado).grep "homu".debug
# output:
# [debug called]: "homu".===("homu") => true
# [debug called]: "homu".===("mami") => false
# [debug called]: "homu".===("mado") => false

結構雑につくってみたんですが意外と便利そうな気がしています。
gem にしちゃってもいいかもなあ。
と、いう感じで BasicObject#method_missing を使うとこんな面白いことができるので Ruby は楽しい言語です。