Metaprogramming, and When Not To
Ruby lets a program inspect and modify itself while running. It is how
attr_accessor works, and how the frameworks you will use define methods you
never wrote. It is also the easiest way to make a codebase no one can search.
class Learner
attr_reader :name, :score
def initialize(name, score) = (@name, @score = name, score)
end
aisha = Learner.new("Aisha", 92)
puts aisha.respond_to?(:name)
puts aisha.public_methods(false).sort.inspect
puts aisha.instance_variables.inspect
puts aisha.instance_variable_get(:@score)
puts Learner.instance_method(:name).arity
puts aisha.method(:name).owner
Open this example in the editor and change it. Nothing you type is saved.
send, and public_send
send calls a method by name, including private ones. public_send respects
visibility and is the one to use when the name came from outside your code.
class Report
def initialize(rows) = @rows = rows
def total = @rows.sum
def average = (@rows.sum.to_f / @rows.size).round(2)
def largest = @rows.max
private def secret = "internal"
end
report = Report.new([10, 20, 30])
%i[total average largest].each do |metric|
puts format("%-8s %s", metric, report.public_send(metric))
end
# send ignores private. That is useful in a test and dangerous in a controller.
puts report.send(:secret)
begin
report.public_send(:secret)
rescue NoMethodError => e
puts "public_send refused: #{e.message.lines.first.strip}"
end
# Never do this with unchecked input: params[:method] would let a caller name
# any method on the object. Allow-list first.
ALLOWED = %i[total average].freeze
requested = "average"
name = requested.to_sym
puts ALLOWED.include?(name) ? report.public_send(name) : "not permitted"
Open this example in the editor and change it. Nothing you type is saved.
define_method
Writing the same method six times with a different constant in it is what
define_method is for. Note it takes a block, so it closes over local
variables - which def cannot do.
class Money
UNITS = { pounds: 100, pence: 1 }.freeze
attr_reader :pence
def initialize(pence) = @pence = pence
UNITS.each do |unit, multiplier|
define_method("in_#{unit}") { (pence / multiplier.to_f).round(2) }
define_method("#{unit}?") { pence % multiplier == 0 }
end
end
money = Money.new(1999)
puts money.in_pounds
puts money.in_pence
puts money.pounds?
puts Money.new(500).pounds?
puts Money.instance_methods(false).sort.inspect
Open this example in the editor and change it. Nothing you type is saved.
method_missing, and the rule about it
method_missing catches calls to methods that do not exist. It must always be
paired with respond_to_missing?, or the object will lie about what it can do
and method(:x) will fail.
class Settings
def initialize(values) = @values = values
def method_missing(name, *args)
key = name.to_s.chomp("?").to_sym
return super unless @values.key?(key)
name.to_s.end_with?("?") ? !!@values[key] : @values[key]
end
def respond_to_missing?(name, include_private = false)
@values.key?(name.to_s.chomp("?").to_sym) || super
end
end
settings = Settings.new(theme: "dark", beta: false)
puts settings.theme
puts settings.beta?
puts settings.respond_to?(:theme)
puts settings.respond_to?(:nonsense)
puts settings.method(:theme).class
# Falling through to super is what makes a real typo raise, instead of
# silently returning nil.
begin
settings.colour
rescue NoMethodError => e
puts "raised properly: #{e.message.lines.first.strip}"
end
Open this example in the editor and change it. Nothing you type is saved.
Every method call on this object now walks the whole lookup chain, fails, and
then runs your handler. define_method up front is faster and greppable;
prefer it whenever you know the names in advance.
Reopening classes
Any class can be reopened, including the core ones. This is a genuine power and a genuine hazard: two libraries adding the same method to String is a bug that appears only when both are loaded.
class String
def shout = upcase + "!"
end
puts "hello".shout
# The safe version of the same idea: a module you include where you want it,
# rather than a change everyone in the process gets.
module Shoutable
def shout = to_s.upcase + "!"
end
class Announcement
include Shoutable
def initialize(text) = @text = text
def to_s = @text
end
puts Announcement.new("release day").shout
Open this example in the editor and change it. Nothing you type is saved.
If you must add to a core class, add a namespaced name (shout_for_reports)
so a collision is at least unlikely. A plain shout is a landmine.
Building a small DSL
Metaprogramming earns its place when it removes real repetition. This is a validation DSL in fifteen lines - the sort of thing you meet in every Ruby framework.
module Validations
def self.included(base) = base.extend(ClassMethods)
module ClassMethods
def validations = @validations ||= []
def validates(field, message, &check)
validations << [field, message, check]
end
end
def errors
self.class.validations.filter_map do |field, message, check|
"#{field} #{message}" unless check.call(public_send(field))
end
end
def valid? = errors.empty?
end
class Registration
include Validations
attr_reader :email, :age
validates(:email, "must contain @") { |v| v.to_s.include?("@") }
validates(:age, "must be 18 or over") { |v| v.to_i >= 18 }
def initialize(email:, age:) = (@email, @age = email, age)
end
good = Registration.new(email: "a@example.com", age: 30)
bad = Registration.new(email: "nope", age: 12)
puts good.valid?
puts bad.valid?
puts bad.errors.inspect
Open this example in the editor and change it. Nothing you type is saved.
self.included is the hook: when the module is included, it runs, and here it
extends the class with the module holding validates. That two-step is the
standard Ruby idiom for adding both class and instance methods at once.
When not to
# Metaprogramming has a real cost. This is the same work twice: once with a
# method defined up front, once resolved through method_missing.
class Direct
def value = 42
end
class Indirect
def method_missing(name, *) = name == :value ? 42 : super
def respond_to_missing?(name, priv = false) = name == :value || super
end
def timed(label)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
puts format("%-10s %.1fms", label, (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000)
end
direct = Direct.new
indirect = Indirect.new
timed("direct") { 200_000.times { direct.value } }
timed("missing") { 200_000.times { indirect.value } }
Open this example in the editor and change it. Nothing you type is saved.
The rule that holds up: reach for metaprogramming when the alternative is real, repeated code, and the names are decided by data rather than by you. If you can write the methods out, write them out. Someone will need to grep for them.
What to take away
public_sendoversend, and allow-list any name that came from input.define_methodwhen the names are known; it closes over locals and is fast.method_missingalways needsrespond_to_missing?and asuperfallback.- Reopening core classes is a collision waiting to happen; use a module.
self.includedplus an innerClassMethodsmodule is the standard hook.- The cost is speed and searchability. Pay it only for real repetition.