DEV Community

Cover image for Metaprogramming in Ruby: Beginner Level
Royce Threadgill for The Gnar Company

Posted on • Edited on • Originally published at thegnar.com

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 the is_used value. It’s not common to see this kind of syntax because we could use attr_reader instead. 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
Enter fullscreen mode Exit fullscreen mode

We define custom_attr_reader on Class, which all Ruby classes inherit from. Calling custom_attr_reader(:is_used) within VehicleClass creates an @is_used method. This means that VehicleClass.is_used is now available for all instances of VehicleClass without the need for a getter method, serving a similar function to attr_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 a VehicleClass that should have a variety of car-specific methods. These methods will be built programmatically with Ruby define_method and included in VehicleClass. 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
Enter fullscreen mode Exit fullscreen mode

The build_vehicle_methods class will accept vehicle_type and is_used arguments. The vehicle_type argument is used to define the name of the “vehicle condition” method, while is_used determines the response from that method.

We then call build_vehicle_methods within VehicleHelper, including our vehicle types and used/new status as arguments. By including VehicleHelper within our VehicleClass, 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 of VehicleClass and verify that you can call Truck_condition and Sedan_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 new build_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 within VehicleHelper 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
Enter fullscreen mode Exit fullscreen mode

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_report and Sedan_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 send method that can address this problem. We can call Truck_condition and Sedan_condition from within the report-related define_method blocks using send(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 the damages array. In this example, that creates Truck_has_front_passenger_door_damage? and Truck_has_windshield_damage? methods that simply return true.

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 Rails enum implementation; 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
Enter fullscreen mode Exit fullscreen mode

By defining body as an enum, we automatically gain access to boolean checks like Vehicle.truck? and status setters like Vehicle.sedan!. Providing the suffix: true config option can make these helpers more readable by appending the column name, yielding Vehicle.truck_body? instead of Vehicle.truck?. Database scopes are also generated for our enum, allowing us to retrieve all truck-body Vehicles with Vehicle.truck (and if you’re using Rails 6+, Vehicle.not_truck will return all Vehicles that 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.

Top comments (0)