How do module level method overrides work in ruby?

104 Views Asked by At

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".

1

There are 1 best solutions below

8
engineersmnky On BEST ANSWER

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

  • C - No method
  • B::ClassMethods - No method
  • A::ClassMethods - Found singleton_test

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

  • D - No method
  • B::ClassMethods - Found singleton_test

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).