Archives

#ruby Method_missing Gotchas

Forgetting ‘super’ with ‘method_missing’

‘method_missing’ is one of Ruby’s power that makes frameworks like Rails seem magical. When you call a method in an object (or “send a message to the object”), the object executes the first method it finds. If the object can’t find the method, it complains. This is pretty much what every modern programming language does. Except in Ruby you can guard against a non-existent method call by having the method ‘method_missing’ in your object. If you are using Rails, this technique enables dynamic record finders like User.find_by_first_name.

require "rspec"

class RadioActive
  def to_format(format)
    format
  end

  def method_missing(name, *args)
    if name.to_s =~ /^to_(\w+)$/
      to_format($1)
    end
  end
end

describe RadioActive do
  it "should respond to to_format" do
    format = stub
    subject.to_format(format).should == format
  end

  it "should respond to to_other_format" do
    subject.to_other_format.should == "other_format"
  end

  it "should raise a method missing" do
    expect do
      subject.undefined_method
    end.to raise_error
  end
end

However, improper use of ‘method_missing’ can introduce bugs in your code that would be hard to track. To illustrate, our example code above intercepts methods whose name are in the ‘to_name’ format. It works fine as our tests tell us except when we try to call an undefined method that does not follow the “to_name” format. The default behavior for undefined method is for the object to raise a NoMethodError exception.

$ rspec method_missing_gotcha-01.rb
..F

Failures:

  1) RadioActive should raise a method missing
     Failure/Error: expect do
       expected Exception but nothing was raised
     # ./method_missing_gotcha-01.rb:30:in `block (2 levels) in <top (required)>'

Finished in 0.00448 seconds
3 examples, 1 failure

Failed examples:

rspec ./method_missing_gotcha-01.rb:29 # RadioActive should raise a method missing

You can easily catch this bug if you have a test. It would be a different story if you just use your class straight away.

irb(main):001:0> require './method_missing_gotcha-01.rb'
=> true
irb(main):002:0> r = RadioActive.new
=> #<RadioActive:0x007fd232a4d8a8>
irb(main):003:0> r.to_format('json')
=> "json"
irb(main):004:0> r.to_json
=> "json"
irb(main):005:0> r.undefined
=> nil

The undefined method just returns nil instead of raising an exception. When we defined our method_missing, we removed the default behavior accidentally. Oops!

Fortunately, the fix is easy. There is no need to raise the ‘NoMethodError’ in your code. Instead, simply call ‘super’ if you are not handling the method. Whether you have your own class or inheriting from another, do not forget to call ‘super’ with your ‘method_missing’. And that would make our tests happy :)

--- 1/method_missing_gotcha-01.rb
+++ 2/method_missing_gotcha-02.rb
@@ -9,6 +9,8 @@ class RadioActive
   def method_missing(name, *args)
     if name.to_s =~ /^to_(\w+)$/
       to_format($1)
+    else
+      super
     end
   end

$ rspec method_missing_gotcha-02.rb
...

Finished in 0.00414 seconds
3 examples, 0 failures

Calling ‘super’ is not just for ‘missing_method’. You also need to do the same for the other hook methods like ‘const_missing’, ‘append_features’, or ‘method_added’.

Forgetting respond_to?

When we modified ‘method_missing’, we are essentially introducing ghost methods. They exist but you cannot see them. You can call them spirit methods if that suits your beliefs. In our example, we were able to use a method named ‘to_json’ but if we look at the list of methods defined for RadioActive, we will not see a ‘to_json’.

irb(main):002:0> RadioActive.instance_methods(false)
=> [:to_format, :method_missing]
irb(main):003:0> r = RadioActive.new
=> #<RadioActive:0x007f88b2a151c0>
irb(main):004:0> r.respond_to?(:to_format)
=> true
irb(main):005:0> r.respond_to?(:to_json)
=> false

Before we introduce a fix, let us first write a test that shows this bug. It’s TDD time baby!

@@ -32,4 +34,8 @@ describe RadioActive do
     end.to raise_error
   end

+  it "should respond_to? to_other format" do
+    subject.respond_to?(:to_other_format).should == true
+  end
+
 end

...F

Failures:

  1) RadioActive should respond_to? to_other format
     Failure/Error: subject.respond_to?(:to_other_format).should == true
       expected: true
            got: false (using ==)
     # ./method_missing_gotcha-02.rb:38:in `block (2 levels) in <top (required)>'

Finished in 0.00444 seconds
4 examples, 1 failure

Failed examples:

rspec ./method_missing_gotcha-02.rb:37 # RadioActive should respond_to? to_other format

The fix is every time you modify ‘method_missing’, you also need to update ‘respond_to?’. And don’t forget to include ‘super’.

+  def respond_to?(name)
+    !!(name.to_s =~ /^to_/ || super)
+  end
+
 end

And with that, we are all green.

....

Finished in 0.00443 seconds
4 examples, 0 failures