Metaprogramming in Ruby: Beginner Level

  • December 30, 2022
  • Royce Threadgill
  • 9 min read

Metaprogramming in Ruby: Beginner Level


This post is the first in a series focused on the application of Ruby metaprogramming. If you’re just starting to learn about metaprogramming, this is a great place to get started. For those who already know the basics, stay tuned for future installments that will cover intermediate and advanced topics. 

Metaprogramming is a phrase you’ve probably heard once or twice in your career. Some may have uttered it with reverence and others may have flown into an apoplectic fit of rage at the very mention of it. In this article, we’ll discuss the basics of Ruby metaprogramming so that you can decide for yourself how and when to use it.

Ruby Metaprogramming Questions: What is it?

Generally speaking, metaprogramming is the art and science of creating a piece of code that generates more code for you. This can be useful if you have recurring logic that you want to DRY (i.e., Don't Repeat Yourself) out. Let’s look at an example:

class Truck
   attr_accessor :is_used

  def initialize(is_used:)
    @is_used = is_used
  end

  def Truck_condition?
    @is_used ? 'used' : 'new'
  end
  end

  class Sedan
  attr_accessor :is_used

  def initialize(is_used:)
    @is_used = is_used
  end

  def Sedan_condition?
    @is_used ? 'used' : 'new'
  end
end

In the above code, we have two classes, Truck and Sedan with very similar logic but slightly different method names: Truck_condition and Sedan_condition (we’re breaking a couple of Ruby naming conventions here for the sake of illustration). Instead of repeating ourselves, we could programmatically generate these methods using Ruby metaprogramming.

But why wouldn’t we simply refactor these classes and get the same result by inheriting from a parent class? Well, that leads to our next question….

Ruby Metaprogramming Questions: When is it Useful?

When you introduce metaprogramming into your code, you start to create complexity that may confuse other developers later on – especially if they didn’t work directly on that code. In most examples like the one given above, you can and should favor simple inheritance.

That being said, here are a handful of cases in which metaprogramming might come in handy:

  • If you’re working with data that doesn’t easily map to a database (e.g., calling a method whose response varies with time)
  • If you’re working on a well-aged Rails monolith and you’re worried that refactoring could break something critical in the application
  • If you’re purposefully trying to obfuscate the underlying Ruby code for a specific use-case (more on that later)

Metaprogramming is very powerful, but the truth is that it’s often overkill for the task at hand. It’s like when the Ikea manual firmly instructs you to hand-tighten, but you decide to grab your drill driver: We all want to take the opportunity to play with our power tools, but we’ll probably just end up breaking the furniture.

Ruby define_method

One of the most important methods in Ruby metaprogramming is define_method. Here’s a basic example:

class VehicleClass
  def initialize(is_used:)
    @is_used = is_used
  end

  define_method('Truck_condition') do
    @is_used ? 'used' : 'new'
  end

  def is_used
    @is_used
  end
end

This would create a method Truck_condition on VehicleClass
that would return “used” if is_used == true and “new” otherwise.

Ruby define_method With Arguments

You can pass arguments to methods created via define_method. We’ll go over this in more detail in the upcoming example, but here’s an isolated code snippet:

define_method('Truck_report') do |name|
  "Car is used. Report made for #{name}"
end

In this example, we create a method Truck_report. When we call that method, we can pass in a string name that is added to the response. So calling
Truck_report(‘Alex’)would generate the string Car is used. Report made for Alex.

Implementing attr_reader with define_method

You may have also noticed that we defined a getter method for theis_used value. It’s not common to see this kind of syntax because we could use attr_readerinstead. As a practical example, let’s use define_method to create our own custom attr_reader so that we no longer need a getter method in this class.

class Class
  def custom_attr_reader(*attrs)
    attrs.each do |attr|
      define_method(attr) do
        instance_variable_get("@#{attr}")
      end
    end
  end
end

class VehicleClass
  custom_attr_reader(:is_used)

  def initialize(is_used:)
    @is_used = is_used
  end
  
  define_method('Truck_condition') do
    @is_used ? 'used' : 'new'
  end
end

We define custom_attr_reader on Classwhich all Ruby classes inherit from. Calling custom_attr_reader(:is_used) within VehicleClass creates an @is_usedmethod. This means thatVehicleClass.is_used is now available for all instances ofVehicleClass without the need for a getter method, serving a similar function toattr_reader.

Ruby Metaprogramming Example

With that out of the way, let’s go over a basic metaprogramming example using Ruby 3.1.0 and ActiveSupport::Concern.

In this example, we’re creating aVehicleClass that should have a variety of car-specific methods. These methods will be built programmatically with Rubydefine_method and included inVehicleClass. To begin, let’s define a “builder” method that will build the “vehicle condition” methods we worked with above.

module VehicleBuilder
  extend ActiveSupport::Concern

  included do
    def self.build_vehicle_methods(vehicle_type:, is_used:)
      condition_method_name = "#{vehicle_type}_condition"

      define_method(condition_method_name) do
        is_used ? 'used' : 'new'
      end
    end
  end
end

module VehicleHelper
  extend ActiveSupport::Concern

  include VehicleBuilder

  included do
    build_vehicle_methods(
      vehicle_type: 'Truck',
      is_used: true,
    )
    build_vehicle_methods(
      vehicle_type: 'Sedan',
      is_used: false,
    )
  end
end

class VehicleClass
  include VehicleHelper
end

Thebuild_vehicle_methods  class will accept vehicle_typeandis_used arguments. The vehicle_typeargument is used to define the name of the “vehicle condition” method, while is_used determines the response from that method.

We then callbuild_vehicle_methodswithin VehicleHelper, including our vehicle types and used/new status as arguments. By including VehicleHelper within ourVehicleClass, we end up with the same methods defined earlier in the article:Truck_condition and Sedan_condition?

You can easily verify this with an Interactive Ruby session. Simply open your terminal and enter irb. On your first line, enter require "active_support/concern", then copy/paste the above code into your window. Once you’ve done that, create a new instance ofVehicleClass and verify that you can callTruck_condition andSedan_condition?on that instance.

This approach is obviously a more roundabout way of defining those methods. But one advantage here is that you can build out new methods with consistent naming conventions by simply adding a newbuild_vehicle_methods call to VehicleHelper. That would be helpful if you have or plan to have a large number of “vehicle” classes; rather than copy/pasting a bunch of classes like Truck and Sedan, you can create them all withinVehicleHelper and have guaranteed consistency.

And that advantage is compounded as the number of methods and complexity of logic increases. We can demonstrate by adding a few new methods to our VehicleBuilder:

module VehicleBuilder
  extend ActiveSupport::Concern

  included do
    def self.build_vehicle_methods(vehicle_type:, serial_number:, is_used:, damages:)
      report_method_name = "#{vehicle_type}_report"
      condition_method_name = "#{vehicle_type}_condition"

      define_method(condition_method_name) do
        is_used ? 'used' : 'new'
      end

      define_method(report_method_name) do |name|       # The “condition” method for this vehicle isn’t yet defined
        condition_attr = send(condition_method_name)
        "#{vehicle_type} (SN #{serial_number}) is #{condition_attr}. Report made for #{name}."
      end

      damages.each do |damage|
        damage_method_name =
          "#{vehicle_type}_has_#{damage}_damage?"

        define_method(damage_method_name) do
          true
        end
      end
    end
  end
end

module VehicleHelper
  extend ActiveSupport::Concern

  include VehicleBuilder

  included do
    build_vehicle_methods(
      vehicle_type: 'Truck',
      serial_number: '123',
      is_used: true,
      damages: %w[windshield front_passenger_door]
    )
    build_vehicle_methods(
      vehicle_type: 'Sedan',
      serial_number: '456',
      is_used: false,
      damages: []
    )
  end
end

class VehicleClass
  include VehicleHelper
end

In the example above, we’re still generating the “condition” methods from above, but we’ve expanded the use of Ruby define_method to include (1) a “report” method and (2) multiple “damages” methods

The “report” methods, Truck_reportandSedan_report, will generate report strings similar to those demonstrated earlier in the article. But that string includes the condition of the car – how do we get that condition if the condition method isn’t yet defined? 

Ruby provides a sendmethod that can address this problem. We can callTruck_conditionand Sedan_condition?from within the report-related
define_method blocks usingsend(condition_method_name). While this particular example is a bit contrived, the ability to call
as-of-yet-undefined methods is quite useful for Ruby metaprogramming.

Finally, the “damages” methods are created by looping over thedamagesarray. In this example, that creates Truck_has_front_passenger_door_damage?
and Truck_has_windshield_damage?methods that simply returntrue.

Ruby Metaprogramming Questions: Who Actually Uses This?

Earlier, we briefly touched on the “what” and the “when” of Ruby metaprogramming, but we didn’t discuss the “who”. If this is such a niche strategy, who actually uses it?

For guidance, we can turn to an oft-cited quote from Tim Peters, a major Python-language contributor, regarding Python metaprogramming: 

"[Metaclasses] are deeper magic than 99% of users should ever worry about"

Regardless of the language(s) you work in on a day-to-day basis, metaprogramming is probably not a tool you’ll need to reach for often. There is a notable exception though: metaprogramming is perfect for designing a domain-specific language.

A domain-specific language (DSL) has its own classes and methods that obfuscate the underlying language they’re built with, reducing complexity and focusing on providing tools to accomplish specific tasks.

Gradle
is a good example of such a use-case; it takes advantage of Groovy metaprogramming to deliver a product focused solely on build automation.

Expanding upon this idea, anyone building a framework will likely find metaprogramming helpful (or even essential).

Rails
is one such framework built using the metaprogramming capabilities provided by Ruby. This can be illustrated by the Railsenumimplementation; when you define an enum, the framework provides a variety of helpers out-of-the-box:

class Vehicle < ApplicationRecord
  enum :body, [ :truck, :sedan, :minivan, :suv, :delorean ], suffix: true
end

By defining body as anenum, we automatically gain access to boolean checks likeVehicle.truck?and status setters likeVehicle.sedan!. Providing thesuffix: trueconfig option can make these helpers more readable by appending the column name,
yielding
Vehicle.truck_body?instead ofVehicle.truck?.  Database scopes are also generated for ourenum, allowing us to retrieve all truck-body Vehicleswith Vehicle.truck(and if you’re using Rails 6+, Vehicle.not_truckwill return all Vehiclesthat do not have truck bodies).

This is Ruby metaprogramming at its best: taking Ruby code and augmenting it with human-readable, intuitive helpers for interacting with your database.

If this article piqued your interest, keep an eye out for the next installment in this series. In our intermediate level post, we’ll dive into some practical examples of Ruby metaprogramming for those of us not building the newest “blazingly fast” framework.

Learn more about how The Gnar builds Ruby on Rails applications.

Interested in building with us?