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 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
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
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
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
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)