Thursday, July 17, 2008

Ruby: Redefine Method Behavior Conditionally

I was recently working on the integration between Mocha and expectations. Expectations promotes the idea that you should only have one expectation per test; therefore, I wanted to display a warning if you call Object#expects within an expected block.

For example, the following code will print a warning.

Expectations do
expect 1 do
Object.expects(:something).returns 1
Object.something
end
end
# >> Expectations allows you to to create multiple mock expectations, but suggests that you write another test instead.
# >> expects method called from /Users/jay/example.rb:6
# >>
# >> Expectations .
# >> Finished in 0.001 seconds
# >>
# >> Success: 1 fulfilled

Usually, I'd use one of the various Alternatives for Redefining Methods, but redefining the Object#expects method had two additional constraints that complicated matters.
  • There's no (reasonable) way to extend all instances with a new module since the expects method is defined on Object.
  • I didn't want to unconditionally redefine the expects method. Within the framework I call Object#expects, and I don't want those calls to cause invalid warnings. I need a solution that prints a warning when you use Object#expects within an expectation block, but does not print a warning if Object#expects is called from anywhere else.
At first I attempted to solve this problem by aliasing methods and pointing to different method definitions based on the context in which the code was currently executing. This turned into a complicated mess, and also required me to define a few methods that could potentially collide with methods defined by applications using Expectations. After going down that path for a bit it became clear that it would be simpler to define modules and delegate the expects call to the appropriate module based on the context.

The actual implementation involves several moving parts, so here's a much simpler example. Start with some behavior defined on Object. This behavior will have been defined by another framework so you cannot (easily) alter the original method definition.

class Object
def say_hello
"hello"
end
end

Next, you've decided to create a framework that says hello in Spanish also. You still want to be able to return "hello" when English is required, but you want the say_hello method to return "hola" when you are expecting Spanish.

Below is the output we are looking for.

in_english do
say_hello # => "hello"
end

in_spanish do
say_hello # => "hola"
end

Currently our code returns "hello" both in English and Spanish.

# Framework Object::say_hello
class Object
def say_hello
"hello"
end
end

# Your Object class
class Object
def in_english(&block)
instance_eval(&block)
end

def in_spanish(&block)
instance_eval(&block)
end
end

in_english do
say_hello # => "hello"
end

in_spanish do
say_hello # => "hello"
end

We can make the say_hello message sent to Object return "hola" by removing the say_hello method, defining an InSpanish module, and including the InSpanish module.

# Framework Object::say_hello
class Object
def say_hello
"hello"
end
end

# Your Object class
class Object
module InSpanish
def say_hello
"hola"
end
end
include InSpanish

remove_method :say_hello

def in_english(&block)
instance_eval(&block)
end

def in_spanish(&block)
instance_eval(&block)
end
end

in_english do
say_hello # => "hola"
end

in_spanish do
say_hello # => "hola"
end

Now we have Spanish working, but we've lost our English. Remember the actual implementation needed to preserve the original behavior in some circumstances. The in_english method is our circumstance where we need to preserve original behavior. This can be done easily enough by Moving the say_hello definition from Object to an InEnglish module.

# Framework Object::say_hello
class Object
def say_hello
"hello"
end
end

# Your Object class
class Object
module InSpanish
def say_hello
"hola"
end
end
include InSpanish

module InEnglish
expects_method = Object.instance_method(:say_hello)
define_method :say_hello do |*args|
expects_method.bind(self).call(*args)
end
end
include InEnglish

remove_method :say_hello

def in_english(&block)
instance_eval(&block)
end

def in_spanish(&block)
instance_eval(&block)
end
end

in_english do
say_hello # => "hello"
end

in_spanish do
say_hello # => "hello"
end

Now we have the original behavior of the say_hello method, but we've lost our ability to speak Spanish.

The final step is to define Object#say_hello in a way that delegates the say_hello message to the appropriate module instead of removing the method.

# Framework Object::say_hello
class Object
def say_hello
"hello"
end
end

# Your Object class
class Object
module InSpanish
def say_hello
"hola"
end
end
include InSpanish

module InEnglish
expects_method = Object.instance_method(:say_hello)
define_method :say_hello do |*args|
expects_method.bind(self).call(*args)
end
end
include InEnglish

def say_hello
(@language || InEnglish).instance_method(:say_hello).bind(self).call
end

def in_english(&block)
@language = InEnglish
instance_eval(&block)
end

def in_spanish(&block)
@language = InSpanish
instance_eval(&block)
end
end

in_english do
say_hello # => "hello"
end

in_spanish do
say_hello # => "hola"
end

The final change was setting the @language instance variable to the module who's say_hello method definition was required. As you can see from the printed output, our code works as desired.

This isn't a technique that you'll use often, but it's a good trick to know when you need it. If you're interested in the actual application you can check out the expectations framework code.

1 comment:

  1. Jay, thanks for the great post. It occurred to me that this is very similar to some of the code used in Neal Ford's Design Patterns in Ruby talk (RubyConf 2008 and others).

    In that presentation, he briefly showed off the features of a gem (by your coworkers, oddly enough) called mixology that may be a nice substitute for the code in this article.

    References:
    blog article


    Neal Ford's Design Patterns in Ruby presentation
    (pdf)

    ReplyDelete

Note: Only a member of this blog may post a comment.