Metaprogramming in Ruby

Written by: Leigh Halliday

In this article, we'll be looking at a few different aspects of metaprogramming in Ruby. For starters, what is metaprogramming?

Metaprogramming is the writing of computer programs with the ability to treat programs as their data. It means that a program could be designed to read, generate, analyze, or transform other programs and even modify itself while running.

We'll specifically look at how we can read and analyze our code in Ruby, how we can call methods (or send messages) dynamically, and how we can generate new methods during the runtime of our program.

Asking Our Code Questions

One aspect of metaprogramming that Ruby excels at is being able to ask our code questions about itself during runtime. This is otherwise known as introspection. Just like we can ask ourselves questions such as "Why am I here?", our code can do likewise, albeit the questions may not be so existential.

Am I able to respond to this method call?

We can ask any object whether it has the ability to provide a response to a specific method call before we make it using the respond_to? method.

"Roberto Alomar".respond_to? :downcase
# => true
"Roberto Alomar".respond_to? :floor
# => false

What does my object ancestry chain look like?

If you check an ActiveRecord model in Rails 5, you'll see that it has an astounding 71 ancestors. This includes both direct parents through class hierarchy and also modules that are included in any of the class tree. This is a bit insane and goes to show just how large of a project Rails is.

School.ancestors.size
# => 71
String.ancestors
# => [String, Comparable, Object, Kernel, BasicObject]

What instance variables and methods have been defined?

We can use the methods method to give us a list of all methods available to a specific object and the instance_variables method to give us a list of the instance variables defined/used by this object.

require 'date'
class Alpaca
  attr_accessor :name, :birthdate
  def initialize(name, birthdate)
    @name = name
    @birthdate = birthdate
  end
  def spit
    "Putsuuey"
  end
end
spitty = Alpaca.new('Spitty', Date.new(1990, 10, 10))
spitty.methods
# => [:name, :name=, :birthdate, :spit, :birthdate=, :instance_of?, :public_send, :instance_variable_get, :instance_variable_set, :instance_variable_defined?, :remove_instance_variable, :private_methods, :kind_of?, :instance_variables, :tap, :is_a?, :extend, :define_singleton_method, :to_enum, :enum_for, :<=>, :===, :=~, :!~, :eql?, :respond_to?, :freeze, :inspect, :display, :send, :object_id, :to_s, :method, :public_method, :singleton_method, :nil?, :hash, :class, :singleton_class, :clone, :dup, :itself, :taint, :tainted?, :untaint, :untrust, :trust, :untrusted?, :methods, :protected_methods, :frozen?, :public_methods, :singleton_methods, :!, :==, :!=, :__send__, :equal?, :instance_eval, :instance_exec, :__id__]
spitty.instance_variables
# => [:@name, :@birthdate]

Sending Messages

Ruby is a dynamic language. It consists of a series of objects that can pass messages back and forth among themselves. This message passing is generally what we refer to when we say "call a method." Let's take a look at the downcase method of String objects.

"Roberto Alomar".downcase
# => "roberto alomar"

When we invoke or call this method using the dot-notation, what we are really saying is that we are passing a message to the String, and it decides how to respond to that message. In this case, it responds with a downcased version of itself.

Let's break this down further. We have three parts we are working with: The first, "Roberto Alomar", is the object, the one that will receive this message. The . (dot) tells the receiving object that we will be sending it some command or message. What follows after the dot, downcase, is the message we are sending. In English, we could say that we are sending the downcase message to "Roberto Alomar". It figures out what to do or send back once it receives that message.

In Ruby, this can be done another way, by using the send method:

"Roberto Alomar".send(:downcase)
# => "roberto alomar"

Generally you wouldn't use this form in normal programming, but because Ruby allows us to send messages (or invoke methods) in this form, it gives the option of sending a dynamic message or calling methods dynamically.

method = :downcase
"Roberto Alomar".send(method)
# => "roberto alomar"

This may not seem like much, but this is one of the constructs or ideas in Ruby that allows us to write very dynamic code, code that may not even exist when you write it. In the following section, we will look at how we can generate new code dynamically in Ruby using the define_method method.

Generating New Methods

Another aspect of metaprogramming that Ruby gives us is the ability to generate new code during runtime. We'll do this using a method from the Module class called define_method. The way it works is by passing a symbol which becomes the name of our new method, and by providing a block, we give our new method its body. Here is simple example below:

class Person
  define_method :greeting, -> { puts 'Hello!' }
end
Person.new.greeting
# => Hello!

You may have seen the delegate method before, which comes in ActiveSupport with Rails and extends Module. This allows us to say that when you call a certain method, you call that method on a different object rather than the current one (self). We're going to create a much simpler version of theirs as a way to show some metaprogramming. You can see the source code for the Rails version here.

This example is modeled after the real-life scenario of calling a business and the receptionist takes your call. You might say that the work is delegated to them.

First we will add a new method to the Module class (which all classes have in their ancestry chain) called delegar (the Spanish word for delegate).

class Module
  def delegar(method, to:)
    define_method(method) do |*args, &amp;block|
      send(to).send(method, *args, &amp;block)
    end
  end
end

When this method is called, it will define a new method whose job it is to "pass off" (delegate) the work to another object, like a proxy.

class Receptionist
  def phone(name)
    puts "Hello #{name}, I've answered your call."
  end
end
class Company
  attr_reader :receptionist
  delegar :phone, to: :receptionist
  def initialize
    @receptionist = Receptionist.new
  end
end
company = Company.new
company.phone 'Leigh'
# => "Hello Leigh, I've answered your call."

You can see we call the phone method on the Company, but it is the Receptionist who actually answers the call.

Dollars and Cents

You've probably heard that it's bad to store and use money as a Float because of floating point arithmetic issues. One of the ways to deal with this is to store money in cents. $10.25 would be stored in the database as 1025 cents.

Users aren't going to want to enter things in cents though, so ideally we would have some code to help us convert between dollars and cents. We're going to use a bit of metaprogramming to help us make things easier.

Let's look at a class called Purchase which has a field in the database called price_cents. This is what the class looks like:

class Purchase
  attr_accessor :price_cents
  extend MoneyFields
  money_fields :price
end

If this were an ActiveRecord object in Rails, we wouldn't have to include the line attr_accessor :price_cents because it would do that for us, but for this example we are just using a plain old Ruby object. This code now gives us the ability to interact with the field like so:

purchase = Purchase.new
purchase.price = 10.25
purchase.price_cents
# => 1025
purchase.price_cents = 555
purchase.price
# => #<BigDecimal:7fbc7497ac88,'0.555E1',18(36)>

But where did the methods price and price= come from? Our money_fields method ends up creating these two new methods which interact with the price_cents and price_cents= methods that come from the attr_accessor line or exist for us from ActiveRecord.

module MoneyFields
  require 'bigdecimal'
  def money_fields(*fields)
    fields.each do |field|
      define_method field do
        value_cents = send("#{field}_cents")
        value_cents.nil? ? nil : BigDecimal.new(value_cents / BigDecimal.new("100"))
      end
      define_method "#{field}=" do |value|
        value_cents = value.nil? ? nil : Integer(BigDecimal.new(String(value)) * 100)
        send("#{field}_cents=", value_cents)
      end
    end
  end
end

The money_fields method loops through one or more fields which were passed to it creating reader and writer methods for the dollar form of the field. To show that it works as expected, here is a test suite that tests the different conversions back and forth:

require 'minitest/autorun'
class PurchaseTest < MiniTest::Test
  attr_reader :purchase
  def setup
    @purchase = Purchase.new
  end
  def test_reading_writing_dollars
    purchase.price = 5.00
    assert_equal purchase.price, 5.00
  end
  def test_converting_to_dollars
    purchase.price_cents = 500
    assert_equal purchase.price, 5.00
  end
  def test_converting_to_cents
    purchase.price = 5.00
    assert_equal purchase.price_cents, 500
  end
  def test_writing_dollars_from_string
    purchase.price = "5.00"
    assert_equal purchase.price_cents, 500
  end
  def test_nils
    purchase.price = nil
    assert_equal purchase.price, nil
  end
  def test_creating_methods
    assert_equal Purchase.instance_methods(false).sort, [:price_cents, :price_cents=, :price, :price=].sort
  end
  def test_respond_to_dollars
    assert_equal purchase.respond_to?(:price), true
    assert_equal purchase.respond_to?(:price=), true
  end
end

Conclusion

Metaprogramming is fantastic but only when it is used sparingly. It can help you write repetitive code more easily (such as the money fields example), it can help you debug and analyze what your code is doing, but it also can add indirection and make it much more difficult to figure out what is actually happening in the code. Only use metaprogramming if it provides a clear advantage.

Most of the methods we've looked at today come from either the Object class or the Module class. Explore it more yourself!

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.