Metaprogramming in Ruby: method_missing
Article Table of Contents
I’m working through Metaprogramming in Ruby
It’s a great read. There are examples in the books, but I wanted to take them out and apply them to some easy Exercisms.
I feel some disclosure may be useful. In no way, at all, should you ever implement any of the “solutions” I’m exploring here. I’m intentionally breaking things and doing them wrong to grow my understanding of how Ruby works.
If you’re following along, I’m doing the hamming_test.rb
Exercism. It’s got a few tests. Here’s the first one:
# hamming_test.rb
# ...
def test_identical_strands
assert_equal 0, Hamming.compute('A', 'A')
end
# ...
my working solution (in regular, non-metaprogramming ruby), is:
# hamming.rb
# ...
def self.compute(string1, string2)
raise ArgumentError if (string1.length != string2.length)
counter = 0
string1.split('').each_with_index do |char, index|
if char != string2[index]
counter += 1
end
end
counter
end
# ...
It’s a simple class method, takes two arguments, and even has an enumerable anti-pattern of using a counter outside the loop. This solution of mine is many months old, I’m not going to refactor that #each_with_index
method. I know it’s itching for a #reduce
, but let’s metaprogram it instead.
I’m going to implement a ‘method_missing’ metaprogramming approach. I’m going to experiment with how Ruby throws “method missing” errors, and try to creatively break it.
My Constraints #
I want to make all of the tests pass without using ::compute
anywhere, and without modifying the test file at all.
# hamming.rb
class Hamming
def self.method_missing(method, *args)
p method
p args
# `method` will be anything I want it to be
# args is just an array of any given arguments
end
end
With the above snippet, you can jump into a Pry or IRB session and require the file (enter $ pry -r ./hamming.rb
in your terminal to open the file in a Pry session…) and enter the following:
Hamming.compute('A', 'A')
=> :compute
=> ["A", "A"]
When you call a method on an object, Ruby looks up the “inheritance chain” all the way to BasicObject
. To see what the inheritance chain is for our Hamming
class, try:
Hamming.ancestors
=> [Hamming, Object, Kernel, BasicObject]
When you call a method that isn’t part of the Hamming
class, Ruby will look to see if it exists in Object
, Kernel
, and BasicObject
. BasicObject
has a private method called method_missing
(method_missing docs), and will call it on whatever object started the whole fiasco. In this case, it will be called on Hamming
.
You can call method_missing
yourself:
Hamming.send(:method_missing, :imaginary_method)
NoMethodError: undefined method `imaginary_method' for Hamming:Class
send
does a few things, but one of them is lets you call private methods. Useful, since method_missing
is a private method.
Now, this gets us some of the way to a solution.
I can simply re-label my method, make a few changes to pluck the first and second arguments from args
, and I’m in business:
class Hamming
def self.method_missing(method, *args)
raise ArgumentError if (args[0].length != args[1].length)
counter = 0
args[0].split('').each_with_index do |char, index|
if char != args[1][index]
counter += 1
end
end
counter
end
end
Since the Hamming
class doesn’t have a compute
method, when my test file calls this non-existent method, it eventually ends up with BasicObject
calling method_missing
, which I’ve now over-ridden to give me this particular output.
A very obvious problem is now every method that I might call that doesn’t exist will get passed into this replacement compute
method, and will return all sorts of errors.
For example, the following method calls all give the same error:
Hamming.compute()
Hamming.c
Hamming.why_wont_you_work
=> NoMethodError: undefined method `length' for nil:NilClass
Fortunately, we can add one more element to “scope” the kind of missing method errors we’re intercepting. That would aid future developers who won’t be quite as flummoxed by this random error code.
def self.method_missing(method, *args)
super unless %w[compute].include? method.to_s
raise ArgumentError if (args[0].length != args[1].length)
# ...
super
(docs) passes the call along to the parent object if certain conditions are/are not met. In this case, we’re passing the method_missing
call up the inheritance chain unless the method matches a list we give it.
This passes all the tests! It’s horrible code, but was an educational journey for me.
Oh, and lets just switch to a #reduce
real quick. My eye is twitching:
class Hamming
def self.method_missing(method, *args)
super unless %w[compute].include? method.to_s
raise ArgumentError if (args[0].length != args[1].length)
args[0].split('').each_with_index.reduce(0) do |counter, (char, index)|
counter += 1 if char != args[1][index]
counter
end
end
end
There. Slightly better.
That’s it for now. A small journey into one metaprogramming concept. More soon!