Metaprogramming in Ruby
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, █| send(to).send(method, *args, █) 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!
Reference: | Metaprogramming in Ruby from our WCG partner Leigh Halliday at the Codeship Blog blog. |