Metaprogramming is an extremely powerful tool. When mastered, it can help make great progress on a project; when misused, it can cause chaos.
What is metaprogramming?
Put simply, Metaprogramming is the concept of code that produces more code.
Generally, programs take inputs such as text from a file, a user's click, or information submitted in a form. The information is processed, and we get our desired result. When we make a program that applies the concept of Metaprogramming, we are making a program that can take not only common inputs but also code as input and, as a result, produce more code.
Metaprogramming is present in many languages, from a very pure implementation in Lisp and its dialects to a more modern macro implementation in Rust.
Why use metaprogramming?
A good tell that a problem can benefit from a metaprogramming approach is when developers find themselves writing functions that are very similar but not identical.
How to detect when metaprogramming can be useful.
Say you like going to a fast food restaurant called Mexican Lunch Spot. They make food dishes with rice, beans, proteins, veggies, and add-ons. They have three menu items (Lime Chicken, Spicy Chicken, and Guac Veg) with instructions for how to serve each part of the dish.
For the Lime Chicken:
1. Grab a bowl
2. Serve one scoop of white rice
3. Serve one scoop of black beans
4. Serve two scoops of lime chicken
5. Serve one scoop of corn
6. Serve a spoonful of cilantro
7. Add on some salsa verde
For the Spicy Chicken:
1. Grab a bowl
2. Serve one scoop of white rice
3. Serve one scoop of pinto beans
4. Serve two scoops of spicy chicken
5. Serve one scoop of corn
6. Serve a spoonful of cilantro
7. Add on some salsa roja
For the Guac Veg:
1. Grab a bowl
2. Serve one scoop of white rice
3. Serve one scoop of black beans
4. Serve two scoops of guac and pico de gallo
5. Serve one scoop of corn
6. Serve a spoonful of cilantro
7. Add on some salsa roja
General patterns are evident in the instructions, and the restaurant now plans to add 3 new bowls.
Imagine these instructions hanging on a wall for the employees, with more bowls meaning more surface area of instructions. It could get confusing and disorienting. This would be a good time to apply metaprogramming principles when a repetitive but not-quite-exact procedure becomes tedious to write and read.
Metaprogramming allows us to raise the level of abstraction to avoid repeating similar steps.
To apply this to our metaphor, we can create a base instruction like:
All Bowl Instructions:
1. Serve in a standard bowl
2. Serve one scoop of rice
3. Serve one scoop of beans
4. Serve two scoops of protein or veg
5. Serve one vegetable topping
6. Serve one spoon of cilantro
7. Dress in salsa
And our three dishes can be simplified to:
Lime Chicken:
Lime Chicken, Salsa Verde
Spicy Chicken:
Spicy Chicken, Pinto Beans, Salsa Roja
Guac Veg:
Guac and Pico de Gallo, Salsa Roja
When we assume the base bowl is our starting point and tailor our changes to each specific dish, the instructions become less verbose and easier to digest (no pun intended). Now the prospect of adding three new dishes isn't so daunting.
By raising the level of abstraction, we have avoided lots of repetition and instruction that would come off as confusing noise. However, there is always a trade-off. To make a dish correctly, we must first fully grasp the concept of a bowl dish, as the new instructions alone will not take us to completion of the menu item, unlike the original set, which, while repetitive, paints a clearer picture of what a bowl is.
Let’s look at how this metaphor aligns with metaprogramming in Rails.
Where do we see metaprogramming in Rails?
Rails uses metaprogramming extensively in many areas, such as
- Tests
- Controllers
- Routes
- Environment Configurations
- Migrations
- Associations
You might not know exactly what Controllers or Associations mean in the context of Rails, but all you need to know is that they are important abstractions, such as that of a bowl dish in our restaurant metaphor. They allow us to easily create many instances of something without repeating ourselves or producing too much instruction (code). This saves lots of time.
Just as our bowl abstraction in the restaurant takes in other instructions that modify the base bowl, these abstractions in Rails have core behavior that is modified when we provide them with additional instructions in code.
Instructions that modify instructions, code that modifies code, Metaprogramming.
Metaprogramming abstractions are important, if not crucial, to the experience of building with Rails. Allowing for easier creation of useful variations is what Rails does with the list of aforementioned abstractions. The conventions in Convention over Configuration largely live here in the metaprogramming abstractions, such as Controllers, Models, and Associations. The conventions are the default behaviors associated with these concepts, just as the bowl abstraction provided default portions and step order. The modifications we add are the 20% businesses add to make things their own.
Taking a closer look at an important Rails metaprogramming area
Tests are one of the more digestible metaprogramming abstractions in Rails.
Hypothetically, we could write out a test for a specific file, then write another for every other file we want to test. All our test files would have very similar functionality, as we would want to include behaviors such as pointing to the desired file, storing the desired outcome, checking whether the expectation meets reality, and a final message indicating how things went.
Instead of repeating these similar instructions for each file, we can abstract things away into the concept of Tests. This is exactly what Rails does, and, in turn, we can provide much clearer, smaller instructions to modify the base instructions and achieve a desirable outcome at low cost.
Here is an example of additional instructions (code) we can give to the base abstraction of Tests that Rails provides:
require "application_system_test_case"
class ArticlesTest < ApplicationSystemTestCase
test "creating an article" do
visit articles_url
click_on "New Article"
fill_in "Title", with: "Creating an Article"
click_on "Create Article"
assert_text "Article was successfully created"
end
end
Due to the powerful abstraction provided by Tests implemented using Metaprogramming, this is all we need to write to create and use all the functionality that comes with Tests. The reason it is so readable has to do with how Ruby was designed, with developer happiness at the forefront.
With great power comes great responsibility.
As mentioned before, there is a trade-off. Just as we first had to understand the concept of bowls at the Mexican Lunch Spot to correctly make the Lime Chicken dish, we have to first understand the Tests abstraction provided by the Rails team to effectively add our own instructions and take advantage of the inherited conventions. Once you know how to make one test, though, it doesn't take too much more to learn how to make another.
Metaprogramming, for all its power, is a double-edged sword. Teams that wield it carelessly often find themselves lost in a maze of obscure bugs and impenetrable abstractions.
The Rails core team understands this well. Through years of collective experience, they have channeled metaprogramming's potential into something disciplined and purposeful, guided by the principle of Convention Over Configuration. The result is a framework that quietly does remarkable things on your behalf, things you never have to think about, debug, or explain. It is the mark of metaprogramming done right.
Rey Gutierrez