Josh Bodah
Josh Bodah

Reputation: 1243

Define an instance method given a class and an UnboundMethod

I'm writing a library which wraps methods with spies (basically transparently allows you to add callbacks to methods). I currently have it working fine for dealing with bound methods

class FakeClass
  def self.hello_world
    'hello world'
  end

  def age
    25
  end
end

module Spy
  def self.on(receiver, msg)
    original = receiver.method(msg)
    wrapped = Proc.new {|*args| original.call(*args)}
    original.owner.instance_eval do
      define_method msg, wrapped
    end
  end
end

# works
Spy.on(FakeClass, :hello_world)

# also works
Spy.on(FakeClass.new, :age)

# what I want to do
Spy.on_any_instance(FakeClass, :age)

So far I've noticed a couple things:

FakeClass.methods.include? :age
# => false
FakeClass.instance_method :age
# => #<UnboundMethod FakeClass#age>

Which leads me to my question:

Since instance_method returns an UnboundMethod, how can I define a replacement method to override that (e.g. I'd like a define_instance_method)

Also how can I wrap the UnboundMethod so that it is bound to the instance when the instance is created?

Can I do any of this without messing with the initialize method?

EDIT:

With Ajedi32's advice I was able to change my method to something like this (the API is slightly more complex in my library, but you'll get the gist):

def wrap_original
  context = self
  @original.owner.instance_eval do
    define_method context.msg do |*args|
      if context.original.respond_to? :bind
        result = context.original.bind(self).call(*args)
      else
        result = context.original.call(*args)
      end
      context.after_call(result, *args)
      result
    end
  end
end

def unwrap_original
  context = self
  @original.owner.instance_eval do
    define_method context.msg, context.original
  end
end

EDIT:

Link to my gem in the wild. This is the main entry point (API) which interacts with Core and then instantiates a new Instance https://github.com/jbodah/spy_rb/blob/a40ed2a67b088bfb7d40d12c5b6ffc882a5097c8/lib/spy/api.rb

Upvotes: 2

Views: 365

Answers (2)

Ajedi32
Ajedi32

Reputation: 48368

define_method actually does define an instance method by default. The only reason it's defining a class method instead in your case is because you're calling it inside of instance_eval. (For details on the exact behavior of instance_eval, you may want to read this article). A simpler way to do what you want in that first case is to define a singleton method on the receiver object using define_singleton_method:

module Spy
  def self.on(receiver, msg)
    original = receiver.method(msg)

    # Use `define_singleton_method` to define a method directly on the receiver
    receiver.define_singleton_method(msg) do |*args, &block|
      puts "Calling #{msg} with args #{args}" # To verify it's working
      original.call(*args, &block) # Call with &block to preserve block args
    end
  end
end

Spy.on(FakeClass, :hello_world)
FakeClass.hello_world # Prints: Calling hello_world with args []

f = FakeClass.new
Spy.on(f, :age)
f.age # Prints: Calling age with args []

As for your on_any_instance method, you can use the regular define_method method for that. You will have to call it using send though, since define_method is private. And since the original method you're trying to call is an UnboundMethod, you'll have to bind it to the instance you want to call it on before calling it:

module Spy
  def self.on_any_instance(receiver, msg)
    original = receiver.instance_method(msg) # Get `UnboundMethod`

    # We must call `define_method` with `send`, since it's private
    receiver.send(:define_method, msg) do |*args, &block|
      puts "Calling #{msg} with args #{args}"

      # Method gets bound to `self`, the current instance
      original.bind(self).call(*args, &block)
    end
  end
end

Spy.on_any_instance(FakeClass, :age)
FakeClass.new.age # Prints: Calling age with args []

Upvotes: 1

Josh Bodah
Josh Bodah

Reputation: 1243

So it turns out I can do this:

original = FakeClass.instance_method(:age)
FakeClass.instance_eval {:define_method, :age, original}

But I need to see how I can wrap the method now

Upvotes: 0

Related Questions