Josh Thompson     about     golden     archive     tags

Metaprogramming in Ruby: method_missing

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!


Get occasional emails

If I've written any new posts, you'll get an email with summaries on Friday. If I've not, you'll not hear from me.