Why am I unable to redefine an included class method after undefining it with undef in ruby?

99 views Asked by At

I'm upgrading a Rails project and had included Mocha in an earlier version. It defined an any_instance method. The new(er) version of Rspec to which I'm upgrading also includes an any_instance method on all classes. I need to stick with Mocha for a little while while I upgrade everything so I don't have to change a bunch of tests, so I want to use the Mocha version of any_instance.

To do that, with Rspec, I'm first removing its own monkey pactching with disable_monkey_patching!. Then there's a config.mock_with :mocha call for Rspec that will cause mocha to define any_instance on Class. I know that monkey patching all classes is bad, but this question is actually a good lesson in why, but I'm curious about the results I'm seeing.

The above is some background about why I'm doing this. What follows is a minimal reproducible example that I can't explain and would appreciate insight on.

# Define a class
class A; end

# Define a module whose class methods I'd like to include in every class
module B
  module ClassMethods
    def a; end
  end
end

# Include method "a" in all classes
Class.send :include, B::ClassMethods

# Try it out!
A.a # <- works

Now I'll use undef to get rid of it, because that's what disable_monkey_patching! does:

Class.class_exec { undef a }
A.a # <- undefined method `a' for A:Class (NoMethodError) -- that's expected 

But now I need to define a different method "a" for Class, which I'll define in module C.

module C
  module ClassMethods
    def a; end
  end
end

Class.send :include, C::ClassMethods

Here's the part that confuses me:

A.a # <- undefined method `a' for A:Class (NoMethodError)

This makes it seem like undef will permanently undefine it, but will not warn anyone when they try to define a method that will ultimately unusable. Why does this happen?

Tried on MRI 3.2.2 and 2.7.2

2

There are 2 answers

0
Stefan On BEST ANSWER

Calling include does not copy the methods into the receiver. It merely adds the included module to the list of modules that are traversed for method lookup.

We can see this list by inspecting the ancestors of A's singleton class:

class A; end

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

When including B::ClassMethods into Class, this list changes accordingly:

Class.include(B::ClassMethods)

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

Note that B::ClassMethods was added after Class.

Now, if you "undefine" method a via undef / undef_method with Class as its receiver, it will cause Class to prevent calls to that method by raising an (artificial) NoMethodError which also ends further method lookup: (I say "artificial" because the method is still there)

#<Class:A>  →  ...  →  Class  →  B::ClassMethods  →  ...
                         |             |
                       undef a         a
                                (never gets here)

If you include another module C::ClassMethods into Class, it will be added to the list, in front of B::ClassMethods but still after Class:

Class.include(C::ClassMethods)

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

And since Class still prevents a from being called, the new a method can't be reached either:

#<Class:A>  →  ...  →  Class  →  C::ClassMethods  →  B::ClassMethods  →  ...
                         |             |                   |
                       undef a         a                   a
                                (still not gets here)

For your actual problem (Mocha), you should first check the ancestors of your object(s) and identify where both any_instance methods are defined.

You can then add a monkey-patch into the right place via include / prepend.

0
Holger Just On

undef is documented as follows:

The undef keyword prevents the current class from responding to calls to the named methods.

undef my_method

[...]

You may use undef in any scope. See also Module#undef_method

As such, it modifies the module it is "called" upon to not only remove a method defined it it, but to mark it so that it never respond to this method, even if it may be defined in a parent class or anywhere else in the inheritance chain.

The only way (I'm aware of) to remove this mark is to explicitly define a method in the module (or class) where you previously undefined the method. This works because by defining a method a on Class, we are reverting the "mark" added by undef or Module#undef_method

With your example, this could be achieved with:

class Class
  def a(...)
    super
  end
end

Here, we define a method a which accepts any arguments and just call super to forward the call along the ancestor chain, in this case either to B::ClassMethods#a or C::ClassMethods#a.

This alone should work out of the box. Now, we could even crank this up an additional notch and remove this newly created method a again, this time however with Module#remove_method which is documented as:

Removes the method identified by symbol from the current class.

This will result in a state as if the method never existed (and was never undefined):

Class.remove_method :a

In the end, you can thus revert the effects of undef and Module#undef_method by defining a new method (with any accepted arguments and any method body) in the exact Module where it was previously undefined, and then removing it again.