Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Understanding ruby metaprogramming using method_added to overwrite instance methods dynamically

I have the following code from Programming Ruby 1.9 (slightly adapted) I just want to ensure my thought process is accurate

module Trace
  def self.included(culprit)
    #Inject existing methods with tracing code:
    culprit.instance_methods(false).each do |func|
      inject(culprit, func)
    end

    #Override the singletons method_added to ensure all future methods are injected.
    def culprit.method_added(meth)
      unless @trace_calls_internal
        @trace_calls_internal = true
        Trace.inject(self, meth) #This will call method_added itself, the condition prevents infinite recursion.
        @trace_calls_internal = false
      end
    end
  end

  def self.inject(target, meth)
    target.instance_eval do
      #Get the method
      method_object = instance_method(meth)
      #Rewrite dat sheet
      define_method(meth) do |*args, &block|
        puts "==> Called #{meth} with #{args.inspect}"
        #the bind to self will put the context back to the class.
        result = method_object.bind(self).call(*args, &block)
        puts "<== #{meth} returned #{result.inspect}"
        result
      end
    end
  end
end

class Example
  def one(arg)
    puts "One called with #{arg}"
  end
end
#No tracing for the above.
ex1 = Example.new
ex1.one("Sup") #Not affected by Trace::inject

class Example #extend the class to include tracing.
  include Trace #calls Trace::inject on existing methods via Trace::included
  def two(a1, a2) #triggers Example::method_added(two) and by extension Trace::inject
    a1 + a2
  end
end

ex1.one("Sup again") #Affected by Trace::inject
puts ex1.two(5, 4) #Affected by Trace::inject

I'm still trying to wrap my head around how this works. I was hoping if someone could confirm my thought process as I want to make sure I understand what is going on here. The comments were added by myself.I really consider my understanding of method binding, singleton classes, and meta-programming to be novice at best.

Firstly, Trace::included is called by any class that includes it. This method does two things, gets a list of existing functions in that class (if any) and overrides their methods using inject. Then it modifies the singleton class of the class that included the module and overrides the default method_added method to ensure every time a method is added past the additional include inject will affect it. This method uses a variable to prevent infinite recursion because the call to inject will evoke method_added by its nature.

Trace::works as follows: set self to the context that exists within the class definition using instance_eval. Therefore the scope(?) is modified to how it would be within that class definition.

Then we set method_object to instance_method(meth) which will get the original method that will added. Since instance_method does not have an explicit receiver, it will default to self which will be the same as the context of being within the class definition?

Then we use define_method to define a method with the same name. Because we are in the context of instance_eval, this is equivalent to defining an instance method and will override the existing method. our method accepts an arbitrary number of arguments and a block if one exists.

We add some flare to place our "Tracing" then we also call the original method that we stored in method_object, as the original is being overwritten. This method is unbound, so we must bind it to the current context using bind(self) so it has the same context as it would originally? we then use call and pass through the arguments and the block, storing its return value, and returning its return value after printing it.


I really hope that I am describing this adequately. Is my description accurate? I am particularly uncertain about the bolded content and the following line:

method_object.bind(self).call(*args, &block)
like image 416
Senjai Avatar asked Oct 04 '22 00:10

Senjai


1 Answers

Trace::works as follows: set self to the context that exists within the class definition using instance_eval. Therefore the scope(?) is modified to how it would be within that class definition.

Using instance eval you evaluate the block with self bound to the object, which in this case will be the class that is including the module. (I.e. the culprit). Just for clarity, there is a difference between:

o = Object.new
o.instance_eval do
  puts self
end

and

class Foo < Object end
Foo.instance_eval do  puts self end

Answer: So yes you are correct in this assumption!


Then we set method_object to instance_method(meth) which will get the original method that will added. Since instance_method does not have an explicit receiver, it will default to self which will be the same as the context of being within the class definition?

Yes, you are correct in your assumption. Do note that, with asking:

culprit.instance_methods(false) => [:someselector, :someotherselector]

And calling instance method in this context is indeed the same as calling self.instance_method.


This method is unbound, so we must bind it to the current context using bind(self) so it has the same context as it would originally?

Yes. When you get a method in the way defined in the trace module, you get an unbound method object which can be bound again as described.


If you want to dive into Ruby's metaprogramming, I do recommend the following book: http://pragprog.com/book/ppmetr/metaprogramming-ruby it explains all the gritty details behind Ruby's object system, mixins, blocks and anything you can imagine.

like image 100
froginvasion Avatar answered Oct 13 '22 09:10

froginvasion