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
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 = "美味しい夏野菜いっぱい!!"
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
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>
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
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
=> "夏のボックス"
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
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
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>')
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
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
The methods can now also be objectified:
method = @veggie_box.method(:title)
I18n.locale = :en
method.call
=> "Summer box"
I18n.locale = :ja
method.call
=> "夏のボックス"
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
Hope you enjoyed the article and if you haven't used #method_missing
before, hopefully this will help!
References
- "The Well-Grounded Rubyist" by David A. Black and Joseph Leo III
- Ruby Style Guide
- Method_missing, Politely blog article
- Ruby Documentation on method_missing (English)
- Ruby Documentation on method_missing (Japanese - has a nice code example even if you can't read the explanation)
Top comments (0)