Bo Yi
Bo Yi

Reputation: 143

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.

Upvotes: 2

Views: 1405

Answers (2)

max
max

Reputation: 101811

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

Upvotes: 5

mechnicov
mechnicov

Reputation: 15248

It's impractical as you asked in the question :)

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

  def test
    @m
  end
end

t = Test.new(9)
t.test # => 9

Test.define_method(:test) { 113 }
t.test # => 113

Test.define_method(:test) { instance_variable_get(:@m) }
t.test # => 9

Test.undef_method(:test)
t.test # will raise NoMethodError

Upvotes: 1

Related Questions