DEV Community

Felice Forby
Felice Forby

Posted on • Edited on

Dynamic method handling with #method_missing and #respond_to_missing? in Ruby and Rails

I just finished reading "The Well-Grounded Rubyist" and finally learned about the mysterious #method_missing method.

#method_missing gets called whenever an object receives a method call that it doesn't know how to handle. Maybe it was never defined, misspelled, or for some reason, ruby can't find it on the object's method look-up path.

The cool thing is is that you can use #method_missing to catch certain methods that you want to dynamically handle. Here's an example borrowed from "The Well-Grounded Rubyist":

class Student
  def method_missing(method, *args)
    if method.to_s.start_with?("grade_for_")
      puts "You got an A in #{method.to_s.split("_").last.capitalize}!"
    else
      super
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

This code would allow you to dynamically any method like grade_for_english, grade_for_science, etc. called on Student objects without needing to explicitly define several similar methods for each subject.

The name of the method that was called is always provided as a symbol through the method argument, so you can use it accordingly within your redefined version. The *args parameter grabs up any of the arguments that were passed along. It can also take an optional block, but I won't get into that here.

Make sure you always include super at the end of the redefined #method_missing so that any methods that cannot actually be handled in the end get passed back to the original definition of #method_missing

Thinking about my personal projects, I realized that #method_missing would finally let me solve one of the problems that had been bugging me for a while, so I'll use that as a real-world albeit simple example to illustrate how I used #method_missing to refactor my code.

Background & Original Code

I have a basic web app for a farmer that sells organic vegetables as a boxed set, kind of like a CSA but on a one-time basis instead of a seasonal one. Anyway, the site is available in both English and Japanese so the data for objects needs to be stored in both languages. In this case, I have a class called VeggieBox that keeps track of the seasonal veggie boxes.

I set up the app so that it pulls out the data from the database depending on the page's locale (:en or :ja). This enables me to share a single view file between the languages instead of separate views for each language.

The VeggieBox class makes objects with a title and description. To handle the different languages, the attributes are stored as title_ja, title_en, description_ja, and description_en.

Let's create a sample box now:

@veggie_box = VeggieBox.new
@veggie_box.title_en = "Summer box"
@veggie_box.title_ja = "夏のボックス"
@veggie_box.description_en = "Tons of delicious summer veggies!!"
@veggie_box.description_ja = "美味しい夏野菜いっぱい!!"
Enter fullscreen mode Exit fullscreen mode

Because the view file is shared, I had to figure out how to display the correct data depending on whether the user was looking at the English or the Japanese site. To do so, I originally created #localized_title and #localized_description that used #send to parse in the I18n locale and call the appropriate method:

# app/models/veggie_box.rb

class VeggieBox < ApplicationRecord
  # ... other methods

  def localized_title
    self.send("title_#{I18n.locale}")
  end

  def localized_description
    self.send("description_#{I18n.locale}")
  end
end
Enter fullscreen mode Exit fullscreen mode

Depending on which page the user views, the locale is set to either en or ja. So, if the user is on the English page, @veggie_box.localized_title would be equivalent to saying @veggie_box.title_en and pulling out the correct text in the view is as simple as:

<div class="description">
  <h3><%= @veggie_box.localized_title %></h3>
  <p><%= @veggie_box.localized_description %></p>
</div>
Enter fullscreen mode Exit fullscreen mode

It's a fairly simple solution, but not as flexible as it could be. What happens if I started adding more and more attributes to the VeggieBox class? I would have to add a new localize_*** instance method for every single attribute!

That's where #method_missing comes in to save the day.

Refactor 1

Instead of having to use methods that have localized_ attached to it, I decided to simplify it to the base attribute name so that I could just write @veggie_box.title or @veggie_box.description instead.

My first attempt to use #method_missing was something like this:

# app/models/veggie_box.rb

class VeggieBox < ApplicationRecord
  # ... other methods

  def method_missing(method, *args)
    if method == "description" || "title"
      localized_method = "#{method}_#{I18n.locale}"
      self.send(localized_method)
    else
      super
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

If the app were to encounter something like @veggie_box.title, it wouldn't immediately know what to do because the VeggieBox object is calling a method that isn't explicitly defined; in this case title. It gets redirected to the #method_missing method defined in the VeggieBox class, which catches the unknown method title in the if-clause if method == "description" || "title". Instead of throwing a NoMethodError, the call to title gets processed into title_en if the user was looking at the English page, or title_ja if it was the Japanese page.

We can try it out in the console:

I18n.locale = :en
@veggie_box.title
 => "Summer box"

I18n.locale = :ja
@veggie_box.title
 => "夏のボックス"
Enter fullscreen mode Exit fullscreen mode

Slightly better, but the method names are still hard-coded into the if-clause!

My next refactor will get rid of the hard-coding in the #method-missing definition.

Refactor 2

To get rid of the hard-coded values, I decided to transform the method name into the localized attribute method (e.g. title_ja) by adding on the locale and then simply checking whether or not the veggie box object responds to that method. If so, the localized method is called on the object with send. If not, it gets sent to super.

Here is the refactored code:

def method_missing(method, *args)
  localized_method = "#{method}_#{I18n.locale}"

  if self.respond_to?(localized_method)
    self.send(localized_method)
  else
    super
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, if another locale-based attribute were to be added to the VeggieBox class, #method_missing will be able to handle it without editing the code.

There is one slight problem that comes up when using #method_missing though, which I'll solve in the final refactor.

Refactor 3

Although #method_missing will let your objects properly handle certain method calls dynamically, it doesn't allow them act like "real methods". That means ruby won't recognize them in other places, for example, when you use object.respond_to?:

@veggie_box.respond_to?(:title)
 => false

@veggie_box.respond_to?(:description)
 => false
Enter fullscreen mode Exit fullscreen mode

respond_to? returns false for the methods handled by #method_missing! Well, that's not right because it technically does respond to these methods even though they aren't defined.

Things like object.method, which let you turn methods into objects, won't work either:

@veggie_box.method(:title)
=> NameError (undefined method `title' for class `#<Class:0x00007fc56289b2a8>')
Enter fullscreen mode Exit fullscreen mode

I realized this after checking the Ruby Style Guide's section on metaprogramming and this great article. They recommended also defining #respond_to_missing? when using #method_missing, which allows ruby to properly recognize that your objects do indeed respond to the meta-programmed methods. (Actually, the Ruby Style Guide recommends not using #method_missing at all... oops).

Here's how I defined #respond_to_missing? in my case:

def respond_to_missing?(method, *)
  localized_method = "#{method}_#{I18n.locale}"
  attribute_names.include?(localized_method) || super
end
Enter fullscreen mode Exit fullscreen mode

Here, I used Rails' ActiveRecord method #attribute_names to make sure the attributes were registered to the class. If so, it passes the respond_to test.

Each object would now return true when asked if they respond to #title or #description.

@veggie_box.respond_to?(:title)
=> true

@veggie_box.respond_to?(:description)
=> true
Enter fullscreen mode Exit fullscreen mode

The methods can now also be objectified:

method = @veggie_box.method(:title)

I18n.locale = :en
method.call
 => "Summer box"

I18n.locale = :ja
method.call
 => "夏のボックス"
Enter fullscreen mode Exit fullscreen mode

Tests for Confidence

Considering this was the first time I used #method_missing, I wasn't 100% confident that the code would work correctly, so I added a test in the test suite (MiniTest) for the meta-programmed methods to give myself more confidence. Even if you are 100% confident, it would be a good idea to have a test anyway :)

The assert_respond_to tests make sure that instances of veggie boxes actually respond to the meta-programmed methods. The subsequent assert_equal tests first set the locale to :en or :ja and make sure the meta-programmed methods return the same values as the original attribute methods.

require 'test_helper'

class VeggieBoxTest < ActiveSupport::TestCase

  def setup
    # sets up a test object from a fixture file
    @veggie_box = veggie_boxes(:box_one)
  end

  # ...other tests...

  test "should respond correctly to localized methods" do
    # Make sure it actually responds to the meta-programmed methods
    assert_respond_to @veggie_box, :title
    assert_respond_to @veggie_box, :description

    # Make sure the method gets the correct values for each locale by 
    # comparing to the original attributes
    I18n.locale = :ja
    assert_equal @veggie_box.title_ja, @box.title
    assert_equal @veggie_box.description_ja, @box.description

    I18n.locale = :en
    assert_equal @veggie_box.title_en, @box.title
    assert_equal @veggie_box.description_en, @box.description
  end
end
Enter fullscreen mode Exit fullscreen mode

Hope you enjoyed the article and if you haven't used #method_missing before, hopefully this will help!

References

Top comments (0)