Metaprogramming, and When Not To

Article · 14:00

Ruby lets a program inspect and modify itself while running. It is how attr_accessor works and how your frameworks define methods you never wrote - and it is the easiest way to make a codebase nobody can navigate, so this says when not to.

This lesson has 7 runnable examples. Press Try yourself! under any of them to open it in the editor.

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.

RB Example 1 13 lines
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.

RB Example 2 30 lines
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.

RB Example 3 19 lines
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.

RB Example 4 30 lines
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.

RB Example 5 19 lines
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.

RB Example 6 37 lines
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

RB Example 7 21 lines
# 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_send over send, and allow-list any name that came from input.
  • define_method when the names are known; it closes over locals and is fast.
  • method_missing always needs respond_to_missing? and a super fallback.
  • Reopening core classes is a collision waiting to happen; use a module.
  • self.included plus an inner ClassMethods module is the standard hook.
  • The cost is speed and searchability. Pay it only for real repetition.