rubymetaprogrammingruby-module

How do module level method overrides work in ruby?


I have some code based on this SO answer: https://stackoverflow.com/a/2136117/2158544. Essentially it looks like this (note: in the actual code, I do not control module A):

module A
  def self.included(base)
    base.extend ClassMethods
  end
  module ClassMethods
    def singleton_test
    end
  end
end

module B
  def self.included(base)
    base.extend ClassMethods
  end

  module ClassMethods
    def self.extended(base)
      puts base.method(:singleton_test).owner
      define_method(:singleton_test) do |*args, &blk|
        super(*args, &blk)
      end
    end
  end
end

First inclusion:

class C
  include A
  include B
end
A::ClassMethods # <- output

Second inclusion:

class D
  include A
  include B
end
B::ClassMethods # <- output

Although the call to super still gets correctly routed to module A, I'm confused why singleton_test is already "wrapped" when it gets included into class D (owner is B::ClassMethods). My theory is that it's because when module B redefines singleton_test, it's redefining it on the included module level (module A) and thus every time module A gets included subsequently, the method has already been "wrapped".


Solution

  • When you call a method ruby walks up the ancestral chain looking for the method, if it reaches the top (BasicObject) and cannot find a matching method it will throw an error (unless method_missing is defined)

    How this works in your example

    First Inclusion

    C.singleton_class.ancestors
    #=> [#<Class:C>, B::ClassMethods, A::ClassMethods, #<Class:Object>, #<Class:BasicObject>, Class, Module, Object, Kernel, BasicObject]
    

    When you lookup singleton_test it checks the ancestral chain

    Now in the B::ClassMethods::extended hook you are defining singleton_test using define_method. Since you called this method without a receiver the implicit receiver is self and self in the context of this method is B::ClassMethods so in essence you are calling

    module B 
      module ClassMethods 
        def singleton_test(*args,&blk) 
          super(*args, &blk)
        end 
      end 
    end 
    

    You can see this more clearly as

    puts "Before B inclusion: #{B::ClassMethods.instance_methods(false)}"
    class C
      include A
      include B 
    end
    puts "After B inclusion: #{B::ClassMethods.instance_methods(false)}"
    

    Output:

    Before B inclusion: []
    After B inclusion: [:singleton_test]
    

    Second Inclusion

    I think you can see where this is going

    D.singleton_class.ancestors
    #=> [#<Class:D>, B::ClassMethods, A::ClassMethods, #<Class:Object>, #<Class:BasicObject>, Class, Module, Object, Kernel, BasicObject]
    

    When you lookup singleton_test it checks the ancestral chain

    So it is not that the method is "already wrapped" or that the method is being redefined on A it is that you have defined a new method in B::ClassMethods and since B is included after A it's definition takes priority (overrides that of A in this context).