Search code examples
rubymetaprogrammingclass-evalinstance-eval

How to temporarily redefine a method in Ruby?


Say, I have:

class Test
  def initialize(m)
    @m = m
  end

  def test
    @m
  end
end

How can I temporarily make method #test of all instances (both existing and new ones) of Test return 113, and then restore the original method later?

It sounds like such a simple thing, yet I can't find a nice way to achieve it. Probably because of my poor knowledge of Ruby.

What I have found so far is:

# saving the original method
Test.send(:alias_method, :old_test, :test)

# redefining the method
Test.send(:define_method, :test) { 113 }

# restore the original method
Test.send(:alias_method, :test, :old_test)

Which does the job but, as I understand, it would also redefine the existing #old_test if one existed?.. And it just feels like a hack rather than proper use of metaprogramming?..

  1. Is it possible to do something like this without using aliases (but also without modifying the source code of Test)?
  2. If not (or if there are easier/nicer ways) how would you do this if you could modify the source code of Test?

I would appreciate if you could describe multiple ways of achieving the same thing, even those that are hard or impractical. Just to give an idea about the flexibility and limitations of metaprogramming in Ruby :)

Thank you very much 🤗

P.S. The reason I started all of this: I am using gem rack-throttle to throttle requests starting with /api, but other urls shouldn't be affected., and I want to test all of this to make sure it works. In order to test the throttling I had to add the middleware to the test environment too. I've successfully tested it (using minitest), however all other tests that test ApiController shouldn't be throttled because it makes tests take much longer if we need to wait 1 second after each request.

I decided to monkey patch the RequestSpecificIntervalThrottle#allowed? with { true } in minitest's #setups to temporarily disable throttling for all of those tests, and then reenable it again in #teardowns (as otherwise the tests testing the throttling itself will fail). I would appreciate if you tell me how you would approach this.

However now that I've already started digging into metaprogramming, I am also just curious how to achieve this (temporarily redefining a method) even if I am not actually going to use it.


Solution

  • You can use instance_method to get a UnboundMethod object from any instance method:

    class Foo
      def bar
        "Hello"
      end
    end
    old_method = Foo.instance_method(:bar)
    # Redifine the method
    Foo.define_method(:bar) do
      puts "Goodbye"
    end
    puts Foo.new.bar # Goodbye
    
    # restore the old method:
    Foo.define_method(old_method.name, old_method)
    

    Unbound methods are a reference to the method at the time it was objectified and subsequent changes to the underlying class will not affect the unbound method.

    The equivilent for class methods is:

    class Foo
      def self.baz
        "Hello"
      end
    end
    
    old_method = Foo.method(:baz).unbind
    

    If you want to make the worlds smallest (and perhaps the most useless) stubbing library you could do it with:

    class Stubby
      def initialize(klass, method_name, &block)
        @klass = klass
        @old_method = klass.instance_method(method_name)
        @klass.define_method(method_name, &block)
      end
    
      def restore
        @klass.define_method(@old_method.name, @old_method)
      end
    
      def run_and_restore
        yield
      ensure
        restore
      end
    end
    
    puts Foo.new.bar # Hello
    
    Stubby.new(Foo, :bar) do
      "Goodbye"
    end.run_and_restore do
      puts Foo.new.bar # Goodbye
    end
    
    puts Foo.new.bar # Hello