What Is Metaprogramming? Pros and Cons of Using Metaprogramming

What Is Metaprogramming? Pros and Cons of Using Metaprogramming
If you have ever written has_many :comments in a Rails model and suddenly got article.comments, article.comments.create and a dozen other methods for free, you have already used metaprogramming. It is one of the most powerful features of Ruby and one of the most controversial. In this article we will look at what metaprogramming is, which tools Ruby offers for it, how Rails uses it, and what its advantages and drawbacks are.
What is metaprogramming?
Metaprogramming is writing code that writes, reads or changes other code (or itself). A regular program works with data: numbers, strings, users, orders. A metaprogram works with the program itself: it creates classes and methods, inspects their structure, and changes their behavior while the application is running.
There are two main kinds of metaprogramming:
- Compile-time: code is generated before the program runs. Examples: macros in C and Rust, templates in C++, code generators.
- Runtime: the program changes itself while running. This is the Ruby way: classes stay open, methods can be defined on the fly, and any object can be asked what it can do.
Why Ruby is ideal for metaprogramming
- Everything is an object, including classes and modules. A class is just an instance of
Class, so you can call methods on it and change it. - Classes are open. You can reopen any class, even a built-in one like
String, and add methods to it. - Class bodies are executable code.
attr_accessor :nameis not special syntax, it is an ordinary method call that runs while the class is being defined. - Rich reflection API. Objects can tell you their methods, instance variables, ancestors and where each method was defined.
Main metaprogramming tools in Ruby
define_method: creating methods dynamically
Let us write our own version of attr_accessor:
module SimpleAttributes
def simple_attr(*names)
names.each do |name|
define_method(name) { instance_variable_get("@#{name}") }
define_method("#{name}=") { |value| instance_variable_set("@#{name}", value) }
end
end
end
class User
extend SimpleAttributes
simple_attr :name, :email
end
user = User.new
user.name = "Anna"
puts user.name # => "Anna"
A more practical example is generating predicate methods for statuses instead of writing each one by hand:
class Order
STATUSES = %w[pending paid shipped cancelled].freeze
attr_reader :status
def initialize(status)
@status = status
end
STATUSES.each do |s|
define_method("#{s}?") { status == s }
end
end
order = Order.new("paid")
order.paid? # => true
order.shipped? # => false
Adding a new status now means adding one word to the array.
send and public_send: calling methods by name
%w[daily weekly monthly].each do |period|
report.public_send("generate_#{period}")
end
send can call private methods too, while public_send respects visibility. Prefer public_send unless you really need to reach a private method.
method_missing: handling calls to methods that do not exist
When Ruby cannot find a method, it calls method_missing. By overriding it, you can respond to methods that were never defined:
class Settings
def initialize(data)
@data = data
end
def method_missing(name, *args)
key = name.to_s
return @data[key] if @data.key?(key)
super
end
def respond_to_missing?(name, include_private = false)
@data.key?(name.to_s) || super
end
end
settings = Settings.new("host" => "localhost", "port" => 5432)
settings.host # => "localhost"
settings.respond_to?(:port) # => true
Two rules here: always call super for names you do not handle (otherwise typos will silently return nil), and always define respond_to_missing? so that respond_to? and method(...) work correctly.
class_eval, instance_eval and hooks
class_evalruns code in the context of a class, for example to add methods to it from the outside.instance_evalruns code in the context of a specific object. It is the foundation of many DSLs, such as configuration blocks.- Hooks such as
included,inheritedandmethod_addedlet a module or class react when it is included, subclassed or extended.
Refinements: safer monkey patching
Instead of changing a built-in class globally, you can limit the change to one file or module:
module StringExtensions
refine String do
def shout
upcase + "!"
end
end
end
using StringExtensions
"hello".shout # => "HELLO!"
Metaprogramming in Rails
Rails is probably the best-known example of metaprogramming in action. Look at an ordinary model:
class Article < ApplicationRecord
belongs_to :author
has_many :comments, dependent: :destroy
enum :status, { draft: 0, published: 1, archived: 2 }
validates :title, presence: true, length: { maximum: 200 }
scope :recent, -> { order(created_at: :desc) }
delegate :name, to: :author, prefix: true
end
Every line here is a method call that generates code:
- Attributes such as
titleandtitle=are created from the database schema. They are not written anywhere in the model. has_many :commentsaddscomments,comments=,comment_idsand more.enumgeneratespublished?,published!and the scopesArticle.published,Article.draft.scope :recentdefines the class methodArticle.recent.delegatecreatesarticle.author_name.ActiveSupport::Concernuses theincludedhook to add callbacks and class methods to models.
Thanks to this, a model that is ten lines long can offer hundreds of methods. That is the power of metaprogramming, and also the reason newcomers often ask: “Where is this method defined?”
Advantages of metaprogramming
- Less repetitive code (DRY). One loop with
define_methodreplaces dozens of nearly identical methods. - Expressive DSLs. Routes in Rails, specs in RSpec, migrations and Rake tasks read almost like plain English.
- Flexibility. Code can adapt to data that is known only at runtime, such as database columns, configuration or an external API schema.
- Powerful frameworks and libraries. ActiveRecord, RSpec, Devise and many other gems are built on metaprogramming, and they save developers enormous amounts of time.
- Easier extension. Adding a new status, field or handler often means adding one item to a list rather than writing new methods.
Disadvantages of metaprogramming
- “Magic” and poor readability. When methods are generated on the fly, you cannot see them in the code. A new developer has to understand the mechanism before understanding the class.
- Hard to navigate. Searching the project for
def paid?finds nothing, because the method was created bydefine_method. IDE “go to definition” often fails too. - Harder debugging. Stack traces go through
method_missing,define_methodblocks and library internals, which makes it harder to find the real source of an error. - Weaker static analysis. Linters, type checkers (Sorbet, RBS/Steep) and autocompletion cannot always see dynamically created methods.
- Performance.
method_missingis slower than a regular method call, because Ruby first searches the entire ancestor chain.evalwith strings has to parse code at runtime. - Security risks. Calling
sendorevalwith user input can let an attacker run any method or any code. - Conflicts from monkey patching. Two gems that add a method with the same name to
StringorHashwill quietly overwrite each other.
An example of the security problem and how to fix it:
# Dangerous: the user decides which method is called
record.send(params[:operation])
# Safe: only allow-listed methods can be called
ALLOWED_OPERATIONS = %w[archive publish].freeze
operation = params[:operation]
record.public_send(operation) if ALLOWED_OPERATIONS.include?(operation)
Best practices
- Start with plain code. Use metaprogramming only when it removes real duplication, not just because it looks clever. A good rule is to wait until the same pattern appears at least three times.
- Prefer
define_methodovermethod_missing. Defined methods are faster, visible to reflection and easier to debug. - If you use
method_missing, always callsuperand implementrespond_to_missing?. - Use
public_sendinstead ofsendand never pass unchecked user input to either of them. - Avoid
evalwith strings. Blocks withdefine_methodandclass_evalare almost always enough. - Use refinements or modules with
prependinstead of global monkey patching of built-in classes. - Document generated methods with comments or YARD tags such as
@!method, so people and tools know they exist. - Keep the magic in libraries and infrastructure, not in business logic. Framework code can be clever; domain code should be obvious.
- Cover generated behavior with tests. It is easy to break something you cannot see.
Ruby also helps you find “invisible” methods when debugging:
order = Order.new("paid")
order.method(:paid?).source_location # => ["app/models/order.rb", 11]
Order.instance_methods(false) # => [:status, :pending?, :paid?, ...]
Article.instance_method(:author_name).owner
Conclusion
Metaprogramming is code that writes code. In Ruby it is part of everyday work: every attr_accessor, has_many and validates is metaprogramming. It helps remove duplication, build elegant DSLs and create frameworks like Rails. The price is less explicit code, harder debugging and potential performance and security issues. Use metaprogramming deliberately: when it truly makes the code simpler for the people who will read it, not just shorter.
Back



