Juggling Chainsaws at Machu Picchu: Metaprogramming in Ruby

Juggling Chainsaws at Machu Picchu: Metaprogramming in Ruby

An example of Ruby metaprogramming, by way of juggling chainsaws.

An example of Ruby metaprogramming, by way of juggling chainsaws.

Photo by Sandro Ayalo on Unsplash

Metaprogramming means writing code that writes code itself. In Ruby, this means objects, classes, and methods can be created and modified during runtime.

I’m going to illustrate some of Ruby’s metaprogramming features using the example of a program which creates methods on an existing object as and when they are required.

Imagine we have been tasked to write a program to keep track of who was the first person to do something silly (eg juggling chainsaws) in an inappropriate location (eg Machu Picchu). Specifically, a user should be able to ask a particular location if they were the first person to perform a particular activity there. If so, they’ll get a message of congratulations. If not, they’ll be told which user was the first person to perform that activity in that place.

There are a number of different approaches to this problem, but we’re going to explore a metaprogramming solution. Let’s start by defining some very simple classes:

class User
  attr_accessor :name
end

class Activity
  attr_accessor :description
end

class Location
  attr_accessor :name
end

We have a class to describe users, which allows us to set and retrieve the user’s name, a class to describe the activities, which allows us to set and retrieve the activity’s description, and a class to describe the location, which allows us to set and retrieve the location’s name. We can create objects of these classes as follows:

bob = User.new
bob.name = "Bob Smith"

juggle_chainsaws = Activity.new
juggle_chainsaws.description = "Juggle Chainsaws"

machu_picchu = Location.new
machu_picchu.description = "Machu Picchu"

However, we don’t yet have any code to associate these objects with each other, which will be required if we want to track the fact that Bob Smith was the first user to juggle chainsaws at Machu Picchu. We’re going to implement this using the method_missing method on the Location class:

class Location
  attr_accessor :description

  def method_missing method, *args, &block
    return super method, *args, &block unless method.to_s =~ /^was_i_the_first_person_to_\w+/

    what = method.to_s.gsub('was_i_the_first_person_to_', '').tr('_', ' ')
    where = self.description
    who = args[0].name

    puts "Congratulations! You are the first person to #{what} at #{where}."

    self.define_singleton_method method do |*args|
      puts "The first person to #{what} at #{where} was #{who}."
    end
  end
end

There’s a bit going on here: the method_missing method is called when a method is called on an object, but the object cannot find a method with that name. In this case, we only want to intercept methods whose names start with the string “was_i_the_first_person_to”. Any other unrecognised methods will be referred to the superclass, where they will probably result in an error.

Let’s assume the following call was made:

machu_picchu.was_i_the_first_person_to_juggle_chainsaws bob

Our method name starts with “was_i_the_first_person_to_”, so we now determine what was done by extracting the string “juggle_chainsaws” from the method name. The location already has a description, and who performed the activity is passed in as an argument.

Using this information, we print a message of congratulations, and then define a singleton method on the machu_picchu object called was_i_the_first_person_to_juggle_chainsaws, which will print a string letting us know who the first person to juggle chainsaws at Machu Picchu was.

Our output will look like:

> machu_picchu.was_i_the_first_person_to_juggle_chainsaws bob
"Congratulations! You are the first person to juggle chainsaws at Machu Picchu."

Now imagine another user, wondering if he was the first person to juggle chainsaws at Machu Picchu, calling this function:

> billy = User.new
=> #<User:0x0000560f8c68ccb0>
> billy.name = "Billy Jones"
=> "Billy Jones"
> machu_picchu.was_i_the_first_person_to_juggle_chainsaws billy
=>"The first person to juggle chainsaws at Machu Picchu was Bob Smith."

From now on, all calls to was_i_the_first_person_to_juggle_chainsaws on the machu_picchu object will ignore method_missing, as the method now exists, and the method will print the string: “The first person to juggle chainsaws at Machu Picchu was Bob Smith”.

As a bonus it turns out that we don’t need a separate Activity class, as the method_missing method in the Location class infers this from the method name, so we can get rid of it entirely, leaving us with:

class User
  attr_accessor :name
end

class Location
  attr_accessor :description

  def method_missing method, *args, &block
    return super method, *args, &block unless method.to_s =~ /^was_i_the_first_person_to_\w+/

    what  = method.to_s.gsub('was_i_the_first_person_to_', '').tr('_', ' ')
    where = self.description
    who   = args[0].name

    puts "Congratulations! You are the first person to #{what} at #{where}."

    self.define_singleton_method method do |*args|
      puts "The first person to #{what} at #{where} was #{who}."
    end
  end
end

In the real world, meta-programming is widely used when creating Domain-Specific Languages (DSLs). For example, FactoryBot is a library which provides a clear and simple interface for setting up test data, which relies heavily on metaprogramming. It’s worth taking a look at their code: https://github.com/thoughtbot/factory_bot

A word of caution, however. Code which makes extensive use of metaprogramming can be hard to debug, not least because instead of trying to work out why the code you’ve written doesn’t do what you want it to, you’re trying to work out why the code written by the code you’ve written doesn’t do what you want it to.

I’d love to hear your thoughts on metaprogramming in Ruby, and the uses you’ve found for it. Why not leave a comment below?


LET’S WORK TOGETHER

We would love to hear from you so let's get in touch!

CONTACT US TODAY!