Dynamic Dispatch and Method Definition
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them.
Ruby has no separate mechanism for "calling a function by name" or "adding a member at run time". Both are ordinary method calls:
an object receives a message, the interpreter looks the name up, and the answer can be changed while the program runs. This page
is the toolbox for doing that deliberately: calling a method whose name is only known at run time (send, public_send,
Method objects), defining methods and constants from data (define_method, define_singleton_method, const_set,
Class.new, Module.new), answering messages that have no method at all (method_missing and respond_to_missing?), and the judgement
about when any of it is worth the cost.
The rules that decide which method runs are in
Method Lookup and super, and the hooks that fire when a method, a
class or a constant appears (inherited, method_added, const_added), together with instance_eval, class_eval and string
eval, are in Hooks, eval and DSLs. The reflection API that lists what
exists is in
Reflection, Introspection and Tracing. The definition syntax is in
Methods and Parameters, visibility in
Visibility and Encapsulation, singleton classes in
Singleton Classes and Class-Level Behavior, and blocks, which supply the bodies of most
generated methods, in Blocks, Procs and Lambdas. Replacing behaviour
on existing classes, safely or not, is
Refinements and Monkey Patching. For the same ideas in other
languages: Go resolves methods statically and offers only its reflection package for run-time access, and
Rust macros generate code at compile time instead of run time.
Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 (and re-run on 3.4 and 3.3 where a version note
says so) and linted with RuboCop 1.92 using the configuration of the running shelf gem (target Ruby 4.0, double-quoted strings, the
documentation and one-class-per-file cops switched off). The output shown in # => comments and in the output blocks is the real output.
The examples reuse the shelf library catalogue used throughout this section (Shelf, Book, Isbn, Loan, Catalog, Store::Memory);
each file is self-contained and assumes the gem’s lib directory is on the load path (ruby -I lib example.rb; see the shelf gem source). Where a file
defines its own helper classes they live in the Shelf namespace so that the output reads like the real library.
Dispatch is a message send
catalog.add(book) does not "call a function". It sends the message add with one argument to the object catalog; the object’s class
(and its ancestors) decide what to do. Everything else on this page is a consequence of that sentence: if the message name is just data, it
can come from a variable; if the lookup table is just data, it can be changed; and if the lookup fails, the object gets a last chance to
respond.
The diagram has two details worth remembering. A call that violates visibility (a private method with an explicit receiver) is also
routed to method_missing, so a catch-all method_missing swallows visibility errors, and the final error depends on how the call was
written: NoMethodError for receiver.name, NameError for a bare name that could have been a local variable. Both are shown with their real
messages below.
Calling a method by name
send, __send__ and public_send
Three methods take the name of a method (a Symbol, or a String that is converted to one) followed by the arguments, keyword arguments and block for that method:
| Call | Visibility | Notes |
|---|---|---|
|
Ignores it: private and protected methods run |
Defined on |
|
Ignores it |
Defined on |
|
Respects it: private and protected methods raise |
The right default whenever the name comes from outside the object that is calling. |
The Symbol or String is checked: nil or an Integer raises TypeError ("nil is not a symbol nor a string"), and a call with no argument at all raises
ArgumentError ("no method name given"). Operators and setters are methods with ordinary names (:+, :[], :loan_days=), so they can be sent too.
Symbol#to_proc (&:name) behaves like public_send: [object].map(&:private_method) raises NoMethodError for a private method.
# frozen_string_literal: true
require "shelf"
# A reservation desk with one public, one protected and one private method.
class Desk
def open? = true
def priority(other) = other.score <=> score
protected
def score = 10
private
def key_box = "keys"
def respond(name) = "responding to #{name}"
end
desk = Desk.new
name = :open?
desk.send(:open?) # => true
desk.public_send(name) # => true
desk.send(:key_box) # => "keys"
desk.__send__(:key_box) # => "keys"
desk.send(:respond, :bell) # => "responding to bell"
def message_of
yield
rescue StandardError => e
e.message
end
# public_send honours visibility; send does not.
%i[key_box score nope].each { |hidden| puts(message_of { desk.public_send(hidden) }) }
message_of { desk.key_box } # => "private method 'key_box' called for an instance of Desk"
desk.priority(Desk.new) # => 0
# Operators and setters are methods too, so they can be sent.
operator = :+
1.send(operator, 2) # => 3
Shelf::Config.new.send(:loan_days=, 21) # => 21
# A class that defines its own `send` hides Object#send, but __send__ stays reachable.
class Postman
def send(*) = "letter sent"
end
Postman.new.send(:anything) # => "letter sent"
Postman.new.__send__(:class) # => Postman
# Bare send with no arguments is an ArgumentError.
message_of { desk.send } # => "no method name given"
Output:
private method 'key_box' called for an instance of Desk
protected method 'score' called for an instance of Desk
undefined method 'nope' for an instance of Desk
Source: Object#send, BasicObject#__send__ and Object#public_send
send and __send__ reach the private key_box and respond; public_send reports the visibility of each method in the error message
(the same text the ordinary call desk.key_box produces). The Postman class shows why __send__ exists: a class that defines its own send method breaks every caller
that relied on the generic one, while __send__ and public_send are still there.
Three practical rules follow:
-
Prefer a plain call when the name is known when you write the code.
desk.open?says what it does;desk.send(:open?)only adds noise, and RuboCop’sStyle/SendWithLiteralMethodNameflags it (forpublic_send;Style/Send, which asks for__send__instead ofsend, is disabled by default). -
Use
public_sendwhen the name is data, andsendonly when bypassing visibility is the point: tests of private methods, framework code that calls hooks such asinitialize_copy, and serialisation. -
Do not build the name from untrusted input without a filter (next section).
Names from untrusted input
public_send stops private methods, but it does not make a name safe. Every public method of the receiver is reachable, including the ones that
change or expose state: freeze, instance_variable_set, instance_variable_get, extend, instance_eval, define_singleton_method and, on a Class, class_eval and const_set.
(String eval is a separate danger covered in Hooks, eval and DSLs, and
Security collects the injection patterns.) The remedy is an allow-list: check the name against a fixed set before sending.
# frozen_string_literal: true
require "shelf"
module Shelf
# Runs a command typed by a user, but only if it is on the allow-list.
class Commands
ALLOWED = %i[count first to_a].freeze
def initialize(catalog)
@catalog = catalog
end
def run(name)
command = name.to_sym
raise ArgumentError, "unknown command: #{name}" unless ALLOWED.include?(command)
@catalog.public_send(command)
end
end
end
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
catalog = Shelf::Catalog.new(store: Shelf::Store::Memory.new).add(book)
commands = Shelf::Commands.new(catalog)
commands.run("count") # => 1
commands.run(:first).title # => "Dune"
def message_of
yield
rescue StandardError => e
"#{e.class}: #{e.message}"
end
message_of { commands.run("freeze") } # => "ArgumentError: unknown command: freeze"
message_of { commands.run("instance_variable_get") } # => "ArgumentError: unknown command: instance_variable_get"
# Without the allow-list, public_send still reaches every public method, not just the ones you had in mind.
untrusted = "freeze"
catalog.public_send(untrusted).frozen? # => true
message_of { catalog.instance_variable_set(:@store, nil) }[/\AFrozenError/] # => "FrozenError"
catalog.add(book).count # => 2
Source: Object#public_send and Object#freeze
The ALLOWED list is a frozen Array of Symbols, and the check happens before the dispatch. The last lines show what an unfiltered public_send would allow: the catalogue was frozen by a
string that arrived from "outside", and instance_variable_set then fails with FrozenError. The freeze is shallow, so the catalogue
still accepts new books because the store inside it is a separate, unfrozen object (see
Object Lifecycle, Freezing and Copying). A hash of Symbols to Method objects or lambdas, shown at the end of the next
section, is an even tighter allow-list because it contains the callables themselves, not names to look up.
Converting input to a Symbol with to_sym is safe on every supported Ruby: Symbols created from strings are garbage-collected, so the old advice
to avoid to_sym on user input for memory reasons no longer applies. The remaining reason to filter is what the name can do.
respond_to?, Method objects and dispatch tables
object.respond_to?(name) answers whether a call from outside would be accepted. It returns true for public methods, false for private and protected ones (pass true
as the second argument, include_all, to include them), and it consults respond_to_missing?, which is how ghost methods announce themselves (see
Ghost methods with method_missing). The name may be a Symbol or a String; anything else raises TypeError. Prefer respond_to? over is_a? when you
care about capability rather than ancestry: it is the duck-typing check.
When a method is going to be called more than once, or passed around, capture it as an object. object.method(:name) returns a Method bound to its receiver; Module#instance_method(:name) returns an
UnboundMethod that is not tied to one object. Both are inspectable (owner, receiver, name, original_name, arity, parameters, source_location, super_method) and callable.
| API | What it does |
|---|---|
|
The |
|
The same, but only for public methods. |
|
Only methods defined directly on the object’s singleton class. |
|
Invoke it. |
|
A lambda that calls the method. This is what |
|
An |
|
Attach it to an object of the right class ( |
|
Where the method was found, what it is bound to, and both its name and the name it was defined with (they differ after |
# frozen_string_literal: true
require "shelf"
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
catalog = Shelf::Catalog.new(store: Shelf::Store::Memory.new).add(book)
# respond_to? asks "would this call be accepted from outside?"
catalog.respond_to?(:add) # => true
catalog.respond_to?(:map) # => true
catalog.respond_to?(:remove) # => false
catalog.respond_to?(:puts) # => false
catalog.respond_to?(:puts, true) # => true
# Strings work as well as Symbols; anything else is a TypeError.
catalog.respond_to?("add") # => true
# Method objects capture a method together with its receiver.
adder = catalog.method(:add)
adder.owner # => Shelf::Catalog
adder.receiver.equal?(catalog) # => true
adder.arity # => 1
adder.parameters # => [[:req, :book]]
adder.name # => :add
adder.source_location&.last # => 13
adder.call(book).equal?(catalog) # => true
# Method#to_proc lets a method stand in for a block.
%w[978-0-306-40615-7 9780306406157].map(&Shelf::Isbn.method(:new))
# => [#<data Shelf::Isbn digits="9780306406157">, #<data Shelf::Isbn digits="9780306406157">]
# Unbound methods are detached from a receiver and can be rebound to compatible ones.
size = Array.instance_method(:size)
size.bind_call([1, 2, 3]) # => 3
size.owner # => Array
# public_method refuses non-public methods; method does not.
catalog.public_method(:add).call(book).class # => Shelf::Catalog
catalog.method(:puts).owner # => Kernel
# A dispatch table maps names to callables instead of calling send on user input.
shelf = Shelf::Catalog.new(store: Shelf::Store::Memory.new).add(book)
handlers = { count: shelf.method(:count), first: shelf.method(:first) }
handlers.fetch(:count).call # => 1
handlers.fetch(:first).call.title # => "Dune"
Source: Object#respond_to?, Object#method, Method and UnboundMethod#bind_call
catalog.respond_to?(:puts) is false because puts is a private Kernel method, and true once include_all is passed. catalog.method(:puts) succeeds regardless, because method ignores visibility; public_method would raise NameError
("method 'puts' for class 'Object' is private" for an Object). The Shelf::Isbn.method(:new) object passed with & turns a class method into a block, a compact alternative to
map { |digits| Shelf::Isbn.new(digits:) } when the arguments line up. The final dispatch table maps Symbols to bound methods: the caller can only trigger what the table contains, and a missing key raises KeyError
from fetch instead of calling something unintended.
Defining methods at run time
define_method
Module#define_method(name, body) defines an instance method on the receiver (a class or module) and returns the name as a Symbol. The body is either a block or a
Proc, Method or UnboundMethod passed as the second argument. It is public, so Shelf::Loan.define_method(:x) { } works from outside the class; older code that wrote send(:define_method, ...) can drop the send. The name may be a Symbol or a String.
# frozen_string_literal: true
require "shelf"
module Shelf
# A loan with one generated predicate per state and a few other generated methods.
class TrackedLoan < Loan
# One predicate per state: available?, lent?, overdue? and returned?.
STATES.each do |name|
define_method(:"#{name}?") { state == name }
end
# The block closes over a local variable that a plain def could never see.
renewals = 0
define_method(:renew) do |days = 7, reason: "none", &log|
renewals += 1
log&.call("renewed #{days} days (#{reason}), total #{renewals}")
days
end
# Generated methods can be made private like any other.
define_method(:audit_trail) { "#{book.title}: #{state}" }
private :audit_trail
# A Method or UnboundMethod can be reused under a second name.
define_method(:status, instance_method(:describe))
# A generated method can call super, but only with explicit arguments.
define_method(:describe) { "[tracked] #{super()}" }
end
end
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
loan = Shelf::TrackedLoan.new(book, state: :lent)
loan.lent? # => true
loan.available? # => false
Shelf::TrackedLoan.public_instance_methods(false).sort
# => [:available?, :describe, :lent?, :overdue?, :renew, :returned?, :status]
# Parameters behave like those of a lambda: arity is checked, defaults and keywords work.
loan.renew # => 7
loan.renew(14, reason: "holiday") { |entry| puts entry } # => 14
Shelf::TrackedLoan.instance_method(:renew).parameters
# => [[:opt, :days], [:key, :reason], [:block, :log]]
Shelf::TrackedLoan.instance_method(:renew).arity # => -1
# Visibility: audit_trail is private, so only an implicit receiver reaches it.
Shelf::TrackedLoan.private_instance_methods(false) # => [:audit_trail]
loan.send(:audit_trail) # => "Dune: lent"
# status is the original describe; describe was redefined to wrap it.
loan.status # => "Dune is lent"
loan.describe # => "[tracked] Dune is lent"
# define_method returns the name as a Symbol, and accepts a String name too.
Shelf::TrackedLoan.define_method("extra") { :extra } # => :extra
loan.extra # => :extra
def message_of
yield
rescue StandardError => e
"#{e.class}: #{e.message}"
end
message_of { loan.audit_trail } # => "NoMethodError: private method 'audit_trail' called for an instance of Shelf::TrackedLoan"
message_of { loan.lent?(1) } # => "ArgumentError: wrong number of arguments (given 1, expected 0)"
# Bare super is not allowed inside define_method.
class LazyLoan < Shelf::Loan
define_method(:describe) { super }
end
lazy = LazyLoan.new(book)
message_of { lazy.describe }[/implicit argument passing of super/] # => "implicit argument passing of super"
Output:
renewed 14 days (holiday), total 2
Source: Module#define_method, Module#instance_method and Module#private
What the example shows, and the rules behind it:
-
The block is a closure.
renewalsis a local variable of the class body, and every generatedrenewshares it. Adefstarts a new scope and could not see it. That makesdefine_methodthe tool for methods that need data captured at definition time (thenamein each predicate, a counter, a lookup table). -
selfis the receiver. Inside the blockselfis the instance the method was called on (the documentation says the block is evaluated withinstance_eval), sostate,bookand private helpers are reachable without a receiver. -
Parameters are those of a lambda. Even when the body is a
Proccreated withproc { |a, b| }, the generated method checks arity:loan.lent?(1)raisesArgumentError. Optional, keyword and block parameters (|days = 7, reason: "none", &log|) all work, andMethod#parametersandarityreport them like any other method. -
returnleaves the method. In an ordinary blockreturnwould return from the method that wrote the block; in adefine_methodbody it returns from the generated method. -
Visibility follows the scope. Called directly in a class body after
private,define_methodcreates a private method, and the same holds in a block run from the class body (such asSTATES.each). Called with an explicit receiver (Shelf::TrackedLoan.define_method(:extra)) or from inside a method, the new method is public. It also returns its name, soprivate define_method(:x) { }works, although RuboCop’sStyle/AccessModifierDeclarationsprefers the separateprivate :xline used above. -
Bare
superis rejected. Inside a generated methodsupermust be written with explicit arguments,super()orsuper(book). A baresuperraisesRuntimeError("implicit argument passing of super from method defined by define_method() is not supported"). The same applies to methods defined withdefine_singleton_method. -
Another method’s body can be reused.
define_method(:status, instance_method(:describe))copies the method under a second name, which is also how a method is wrapped: capture the old one withinstance_method, then define a new one that callsold.bind_call(self, ...). Method-lookup-based wrapping withprependis usually cleaner and is covered in Method Lookup and super. AnUnboundMethodcan only be reused on a class that is compatible with its owner. -
The error location is the block. The backtrace of an
ArgumentErrorraised for a wrong call showsblock in <class:TrackedLoan>, andsource_locationpoints at thedefine_methodcall, not at the line where the name was computed. Generated code is harder to debug, a point returned to in When to metaprogram.
The main (top-level) object also responds to define_method: on Ruby 3.3, 3.4 and 4.0 a top-level define_method(:name) { } defines a public method on Object (unlike a top-level def, which defines a private one), so every object
in the program, including nil, would respond to it. Define methods on a module of your own instead.
def and define_method differ in more than syntax:
| Aspect | def |
define_method |
|---|---|---|
Name |
Fixed when the source is written |
Computed at run time (a Symbol or a String) |
Scope of local variables |
New scope; no access to the surrounding locals |
Closure over the surrounding locals |
Arguments |
Any parameter list, |
Any parameter list a block can take, except |
|
Allowed (implicit arguments) |
A |
|
Returns from the method |
Returns from the generated method |
Visibility |
The current default visibility of the class body |
The default visibility of the class body, when called there; public otherwise |
Debugging |
A named frame ( |
A block frame; |
Speed |
The fastest way to call a method |
Close to |
define_singleton_method, extend and the singleton class
A method defined for one object only lives in that object’s singleton class. define_singleton_method(name, body) is the shorthand for object.singleton_class.define_method(name, body) (a public method). extend(Module) is
the way to add a whole module to a single object; it is the mechanism behind many small DSLs, where a plain object is extended with the methods the DSL block needs. The singleton class, class methods and class << self are explained in
Singleton Classes and Class-Level Behavior.
# frozen_string_literal: true
require "shelf"
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
loan = Shelf::Loan.new(book, state: :lent)
other = Shelf::Loan.new(book)
# define_singleton_method adds a method to one object only (its singleton class).
loan.define_singleton_method(:late_fee) { |days_late, rate: 10| days_late * rate } # => :late_fee
loan.late_fee(3) # => 30
loan.late_fee(3, rate: 25) # => 75
loan.singleton_methods # => [:late_fee]
other.respond_to?(:late_fee) # => false
loan.singleton_method(:late_fee).arity # => -2
# It is equivalent to define_method on the singleton class.
other.singleton_class.define_method(:note) { "see #{book.title}" } # => :note
other.note # => "see Dune"
other.singleton_methods # => [:note]
# extend adds a whole module to one object, which is how small DSLs are bolted onto plain objects.
module Reminders
def remind = "#{book.title} is #{state}"
end
other.extend(Reminders).equal?(other) # => true
other.remind # => "Dune is available"
other.singleton_class.include?(Reminders) # => true
other.is_a?(Reminders) # => true
loan.is_a?(Reminders) # => false
Shelf::Loan.include?(Reminders) # => false
Source: Object#define_singleton_method, Object#singleton_methods, Object#singleton_method and Object#extend
The fee method exists on loan and nowhere else, so other.respond_to?(:late_fee) is false. singleton_methods lists the methods defined directly on the object and, with its default all = true, those of the modules it was extended with (singleton_methods(false) lists only the first kind), and singleton_method(:late_fee) returns the Method
object. Extending is a one-way door per object: a module added with extend cannot be removed from that object, and is_a?(Reminders) stays true for it. Prefer extend on a fresh object created for the purpose over extending
long-lived shared ones. An object that has a singleton class is also a poorer citizen elsewhere: Ruby 4.0 reports undefined method 'x' for #<Shelf::Loan:0x...> with the full inspect for such an object, where an ordinary object gets the shorter for an instance of Shelf::Loan, and
an object that has singleton methods cannot be dumped with Marshal (TypeError, "singleton can’t be dumped").
Instance variables by name
instance_variable_get(name), instance_variable_set(name, value), instance_variable_defined?(name), instance_variables and remove_instance_variable(name) read and write state without going through accessors. The name must
look like an instance variable (@state, a Symbol or a String) or NameError is raised ("'state' is not allowed as an instance variable name"). Reading an undefined variable returns nil and, since Ruby 3.0, no warning. A frozen object refuses the write with FrozenError, and
Data instances are frozen from birth.
# frozen_string_literal: true
require "shelf"
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
loan = Shelf::Loan.new(book, state: :lent)
# Reading and writing instance variables by name breaks encapsulation on purpose.
loan.instance_variables # => [:@book, :@state]
loan.instance_variable_get(:@state) # => :lent
loan.instance_variable_set(:@state, :overdue) # => :overdue
loan.state # => :overdue
loan.instance_variable_defined?(:@state) # => true
loan.instance_variable_defined?(:@fine) # => false
loan.instance_variable_get(:@fine) # => nil
# The generic serialiser this makes possible: a hash of every instance variable.
loan.instance_variables.to_h { |name| [name.to_s.delete_prefix("@").to_sym, loan.instance_variable_get(name)] }
# => {book: #<data Shelf::Book isbn=#<data Shelf::Isbn digits="9780306406157">, title="Dune", author=#<data Shelf::Author name="Frank Herbert">>, state: :overdue}
# remove_instance_variable removes it and returns the old value.
loan.instance_variable_set(:@fine, 5) # => 5
loan.remove_instance_variable(:@fine) # => 5
loan.instance_variables # => [:@book, :@state]
def message_of
yield
rescue StandardError => e
"#{e.class}: #{e.message}"
end
# The name must look like an instance variable.
message_of { loan.instance_variable_get("state") } # => "NameError: 'state' is not allowed as an instance variable name"
message_of { loan.instance_variable_get(:"@a b") } # => "NameError: '@a b' is not allowed as an instance variable name"
message_of { loan.remove_instance_variable(:@nothing) } # => "NameError: instance variable @nothing not defined"
# Frozen objects refuse the write, and Data instances are frozen from birth.
message_of { book.instance_variable_set(:@title, "Emma") }[/\AFrozenError: can't modify frozen Shelf::Book/]
# => "FrozenError: can't modify frozen Shelf::Book"
Source: Object#instance_variable_get, Object#instance_variable_set, Object#instance_variables, Object#remove_instance_variable and FrozenError
These methods deliberately break encapsulation, so they belong in a small set of places: generic serialisers and to_h helpers (as in the example), test code that needs to arrange state, attribute macros that generate readers and writers
(below), and copy hooks such as initialize_copy. Everywhere else, use the object’s public API. Ruby 4.0 adds a hook for the opposite need, hiding sensitive variables from
inspect: a private instance_variables_to_inspect method that returns the list of names to show (see the 4.0 NEWS). The same primitives work on classes and modules, whose
"class-level instance variables" (Shelf::Loan.instance_variable_get(:@registry)) are not shared with subclasses, a frequent surprise when a macro keeps its state there.
Aliasing, removing and undefining
Methods can also be renamed or taken away at run time. These are the reflective counterparts of the alias and undef keywords, and unlike the keywords they accept computed names:
| Method | Effect |
|---|---|
|
Makes |
|
Deletes this class’s own definition, so lookup falls through to the superclass or to an included module. Raises |
|
Blocks the name entirely: even an inherited definition is hidden, and a call raises |
|
Like |
|
Whether the module has a method of that name, inherited ones included. |
# frozen_string_literal: true
require "shelf"
# Aliases and generated methods know both names.
module Shelf
# The same method under two names, with the old definition wrapped.
class Audited < Loan
alias original_describe describe
define_method(:describe) { "audit: #{original_describe}" }
%i[lent overdue].each do |state|
define_method(:"mark_#{state}") do
@state = state
[__method__, __callee__, state]
end
end
alias mark_late mark_overdue
end
end
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
loan = Shelf::Audited.new(book)
loan.describe # => "audit: Dune is on the shelf"
loan.mark_lent # => [:mark_lent, :mark_lent, :lent]
loan.mark_late # => [:mark_overdue, :mark_late, :overdue]
Shelf::Audited.instance_method(:mark_late).name # => :mark_late
Shelf::Audited.instance_method(:mark_late).original_name # => :mark_overdue
# remove_method deletes this class's entry, so lookup falls through to the superclass.
Shelf::Audited.send(:remove_method, :describe)
loan.describe # => "Dune is overdue"
# undef_method forbids the name, even though a superclass defines it.
Shelf::Audited.send(:undef_method, :describe)
Shelf::Audited.undefined_instance_methods # => [:describe]
Shelf::Audited.method_defined?(:describe) # => false
Shelf::Loan.method_defined?(:describe) # => true
begin
loan.describe
rescue NoMethodError => e
e.message.sub(/ for an instance.*/, "") # => "undefined method 'describe'"
end
Source: Module#alias_method, Module#remove_method, Module#undef_method, Module#undefined_instance_methods and Kernel#__method__
The first part wraps describe the pre-prepend way: keep the old implementation under another name, then define a new describe that calls it. It works, and __method__ and __callee__ tell a generated method which name it
was defined with and which name it was called by (they differ for mark_late, an alias of mark_overdue). The second part shows the difference between the two removals: after remove_method, loan.describe is the superclass version again, whereas after undef_method the
name is gone for Audited even though Shelf::Loan still defines it. Undefining is how an adapter hides most of a wrapped class’s interface; the hooks method_removed and method_undefined fire for these (see
Hooks, eval and DSLs). Visibility changes made at run time with public, private, protected, module_function, private_class_method and private_constant are covered in
Visibility and Encapsulation; since Ruby 3.0 private and its siblings accept an Array of names and since Ruby 3.1 they return their argument, which is what makes private attr_reader :x and private def x work.
Attribute macros and documenting generated methods
The standard family of method generators is attr_reader, attr_writer, attr_accessor (and attr). They are ordinary methods on Module that call the equivalent of define_method, and since Ruby 3.0 they return an Array of the Symbols they defined, so private attr_reader :book works (and
private attr_accessor :a, :b privatises all of the generated methods). A class-level macro of your own follows the same shape: a method on the class’s singleton that calls define_method for each name it is given. Putting the macro in a module that classes extend keeps
it reusable.
# frozen_string_literal: true
require "shelf"
module Shelf
# A class-level macro that generates a reader and a writer, with their documentation.
module Attributes
# Declares a readable and writable attribute.
#
# @!macro [attach] attribute
# @!method $1
# Returns the +$1+ attribute, or its default.
# @return [Object]
# @!method $1=(value)
# Sets the +$1+ attribute.
# @param value [Object]
# @return [Object]
# @param name [Symbol] the attribute name
# @param default [Object] returned while nothing has been set
# @return [Symbol] the attribute name
def attribute(name, default: nil)
variable = :"@#{name}"
define_method(name) do
instance_variable_defined?(variable) ? instance_variable_get(variable) : default
end
define_method(:"#{name}=") { |value| instance_variable_set(variable, value) }
(@attributes ||= []) << name
name
end
# @return [Array<Symbol>] the attribute names declared on this class
def attributes = (@attributes || []).dup
end
# A library member who can borrow books.
class Member
extend Attributes
attribute :name
attribute :max_loans, default: 3
def to_h = self.class.attributes.to_h { [it, public_send(it)] }
end
end
member = Shelf::Member.new
member.name = "Ico"
member.to_h # => {name: "Ico", max_loans: 3}
member.max_loans = 5 # => 5
member.to_h # => {name: "Ico", max_loans: 5}
Shelf::Member.attributes # => [:name, :max_loans]
Shelf::Member.instance_methods(false).sort # => [:max_loans, :max_loans=, :name, :name=, :to_h]
Shelf::Member.instance_method(:max_loans=).parameters # => [[:req, :value]]
Shelf::Member.instance_method(:name).source_location.last # => 23
Shelf::Member.attribute(:email) # => :email
attribute :max_loans, default: 3 returns :max_loans, defines the pair max_loans/max_loans=, and records the name. The reader checks instance_variable_defined? so that a default is returned until the writer runs, without creating the variable (which keeps instance_variables and
inspect limited to values that were really set). source_location for a generated method points at the define_method line inside the macro, not at the attribute :name line, which is why documentation for generated methods has to be written explicitly.
Tools that read source code cannot see methods created at run time, so documentation generators need hints:
-
YARD has the
@!methoddirective, which creates a documented method that has nodef, and@!macro [attach], which attaches a template to the method that follows (hereattribute) and expands it for each call, with$1standing for the first argument. The comment block aboveattributeis exactly that. Write the directives in the comment of the macro; YARD then documentsname,name=,max_loansandmax_loans=onShelf::Member. See Documentation with RDoc and YARD. -
RDoc has the
:method:directive for the same purpose, written in a comment block that sits next to the macro call. -
Always say what the generated method returns and accepts in the macro’s own comment, and keep a cheap check that the generated interface matches the documentation (below).
Constants by name
const_get, const_set, const_defined?, constants, const_missing, remove_const and const_source_location are the constant counterparts of send and define_method. Looking a class up by a name that arrives as a String (from a configuration file or a command-line
option) is the most common use, and the one that most needs care.
-
Module#const_get(name, inherit = true)accepts a Symbol or a String, and a String may be a path ("Shelf::Store::Memory", with an optional leading::); each segment is resolved in the result of the previous one. A name that is not a valid constant name raisesNameError("wrong constant name lower"), and a valid but missing one callsconst_missingand, by default, raisesNameError("uninitialized constant Shelf::Nope"). -
inheritmatters. By default the lookup also searches the ancestors of the receiver and, for a module,Object. SoShelf::Store.const_get(:String)returns::String. Passfalseto look only in the receiver itself, which is what a factory that maps names to its own adapters must do. -
Module#const_defined?(name, inherit = true)has the same arguments and answers without raising.Module#constants(inherit = true)lists the Symbols. -
Module#const_set(name, value)defines the constant (the name must start with a capital letter), warns "already initialized constant" if it exists, and, if the value is an anonymous class or module, gives it its name. -
Module#const_missing(name)is called for every unresolved constant; overriding it on a module (def self.const_missing) turns a typo into a domain error or loads code on demand. It is now rarely written by hand; on-demand loading is normally done withautoload(see Code Loading: require and autoload). -
Module#const_source_location(name)returns the file and line where the constant was assigned. -
private_constanthides a constant fromBox::NAME, but not fromBox.const_get(:NAME):const_getignores constant visibility, so it is a way around a private constant and should not be used on names you do not control. -
remove_constis private onModule, so removing a constant takessend(:remove_const, :Name), and a reloader that does so should know exactly what it is doing.
# frozen_string_literal: true
require "shelf"
module Shelf
module Store
# A store adapter that refuses to persist anything.
class Null
def all = []
def add(_book) = self
end
# Builds an adapter from a configuration name such as "memory" or "null".
def self.build(name)
class_name = name.to_s.split("_").map(&:capitalize).join
raise Error, "unknown store: #{name}" unless const_defined?(class_name, false)
const_get(class_name, false).new
end
# A friendlier failure for typos in Store::Something.
def self.const_missing(name)
raise Error, "no store adapter called #{name}"
end
end
# Policies are found by naming convention: Loan -> LoanPolicy.
module Policies
LoanPolicy = Data.define(:record) do
def renewable? = record.state == :lent
end
NullPolicy = Data.define(:record) do
def renewable? = false
end
def self.for(record)
name = "#{record.class.name.split('::').last}Policy"
policy = const_defined?(name, false) ? const_get(name, false) : NullPolicy
policy.new(record)
end
end
end
def message_of
yield
rescue StandardError => e
"#{e.class}: #{e.message}"
end
Shelf::Store.build("memory").class # => Shelf::Store::Memory
Shelf::Store.build(:null).class # => Shelf::Store::Null
message_of { Shelf::Store.build("file") } # => "Shelf::Error: unknown store: file"
message_of { Shelf::Store::Redis } # => "Shelf::Error: no store adapter called Redis"
# const_get walks up to Object by default, so a bare name can escape the namespace.
Shelf::Store.const_get(:String) # => String
message_of { Shelf::Store.const_get(:String, false) } # => "Shelf::Error: no store adapter called String"
Shelf::Store.const_defined?(:String) # => true
Shelf::Store.const_defined?(:String, false) # => false
# A path with :: resolves each segment in turn.
Object.const_get("Shelf::Store::Memory") # => Shelf::Store::Memory
Shelf.const_get("Store::Null") # => Shelf::Store::Null
Shelf::Store.constants.sort # => [:Memory, :Null]
# const_set defines a constant (and names an anonymous class); it warns if the constant exists.
adapter = Class.new(Shelf::Store::Null)
adapter.name # => nil
Shelf::Store.const_set(:Archive, adapter) # => Shelf::Store::Archive
adapter.name # => "Shelf::Store::Archive"
Shelf::Store.build("archive").class # => Shelf::Store::Archive
message_of { Shelf::Store.const_set(:archive, adapter) } # => "NameError: wrong constant name archive"
# Constant lookup by naming convention.
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
loan = Shelf::Loan.new(book, state: :lent)
Shelf::Policies.for(loan).renewable? # => true
Shelf::Policies.for(book).class # => Shelf::Policies::NullPolicy
# Where a constant was defined.
Shelf.const_source_location(:VERSION).last # => 4
Shelf::Store.const_source_location(:Memory).last # => 6
Source: Module#const_get, Module#const_set, Module#const_defined?, Module#const_missing and Module#const_source_location
Store.build is a factory that turns "memory" or :null into an adapter. It converts the configuration name to a class name, checks const_defined?(class_name, false) against the module’s own constants, and only then calls const_get(class_name, false): a name such as
"file" is an error of the program (Shelf::Error), not a surprise NameError. The const_missing override gives Store::Redis the same friendlier error. Without the inherit = false guard, Shelf::Store.build("string") would instantiate ::String.
Policies.for is the "constant inference from class names" pattern: Shelf::Loan maps to LoanPolicy and anything without a policy to NullPolicy. It is convention over configuration, the way policy-object libraries find a policy for a record, but written in a dozen lines of plain Ruby. Deriving the name
from record.class.name is the weak point: anonymous classes have no name (nil.split fails), and a subclass gets a different convention name unless the code walks ancestors. Passing user-controlled strings to const_get (the idiom Rails spells constantize, which is
ActiveSupport, not core Ruby) is a security smell for the reason shown above, and an allow-list or a registry Hash is the safe form.
Building classes and modules at run time
Class.new and Module.new
Class.new(superclass = Object) { |klass| ... } creates an anonymous class and Module.new { |mod| ... } an anonymous module. The block is evaluated with self set to the new class or module (as with class_eval), so def, define_method, attr_accessor, include and
private work inside it as they do in a class body, and the new class is also yielded to the block. Data.define and Struct.new (which return anonymous classes), Class.new(StandardError) for a throwaway error class and the Module.new mixins built by libraries all rely on it.
# frozen_string_literal: true
require "shelf"
# An anonymous class has no name until it is assigned to a constant.
adapter = Class.new(Shelf::Store::Memory) do
def all = super.sort_by(&:title)
end
adapter.name # => nil
adapter.superclass # => Shelf::Store::Memory
adapter.new.all # => []
Shelf::Store.const_set(:Sorted, adapter)
adapter.name # => "Shelf::Store::Sorted"
adapter.to_s # => "Shelf::Store::Sorted"
# The block runs with self set to the new class, so def defines instance methods
# and a local variable of the enclosing scope is visible (a closure).
prefix = "shelf"
tagged = Class.new do
define_method(:tag) { "#{prefix}-tag" }
def plain = "no access to locals here"
end
tagged.new.tag # => "shelf-tag"
tagged.instance_methods(false).sort # => [:plain, :tag]
# The inherited hook runs before the block.
class Registry
def self.inherited(subclass)
super
(@subclasses ||= []) << subclass.instance_methods(false)
end
def self.seen = @subclasses
end
Class.new(Registry) { def hello = :hi }
Registry.seen # => [[]]
Registry.subclasses.size # => 1
Registry.subclasses.first.instance_methods(false) # => [:hello]
# set_temporary_name (Ruby 3.3) gives an anonymous module a readable name for inspect.
mixin = Module.new
mixin.set_temporary_name("fake<Namespace>") # => fake<Namespace>
mixin.name # => "fake<Namespace>"
def message_of
yield
rescue StandardError => e
"#{e.class}: #{e.message}"
end
message_of { mixin.new } # => "NoMethodError: undefined method 'new' for module fake<Namespace>"
message_of { mixin.set_temporary_name("Real::Name") }[/must not be a constant path/] # => "must not be a constant path"
message_of { Class.new(Class) } # => "TypeError: can't make subclass of Class"
message_of { Class.new(adapter.new) }[/superclass must be an instance/] # => "superclass must be an instance"
# Module.new builds a mixin whose methods are generated from data.
statuses = Module.new do
Shelf::Loan::STATES.each do |state|
define_method(:"#{state}?") { self.state == state }
end
end
loan_class = Class.new(Shelf::Loan) { include statuses }
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
loan = loan_class.new(book, state: :overdue)
loan.overdue? # => true
loan.lent? # => false
loan_class.include?(statuses) # => true
loan_class.ancestors.first(3).map(&:class) # => [Class, Module, Class]
# Data.define and Struct.new also return anonymous classes.
Data.define(:title).name # => nil
Struct.new(:title).superclass # => Struct
# Subclass bookkeeping that needs no hook.
Registry.subclasses.size # => 1
Shelf::Loan.subclasses.size # => 1
loan.singleton_class.attached_object.equal?(loan) # => true
Source: Class.new, Module.new, Module#set_temporary_name, Class#subclasses and Class#attached_object
-
A class gets a name the first time it is assigned to a constant, either by
Const = Class.newor byconst_set, and keeps it. Until thennameisnilandinspectshows#<Class:0x...>. Assigning an anonymous class to a second constant does not rename it. Theinheritedhook runs with the anonymous class, before the block, which is whyRegistry.seenrecords an empty list of methods and why hooks must not assumesubclass.nameis set. -
set_temporary_name(Ruby 3.3) gives an anonymous class or module a readable, fake name forinspectand error messages. The name must not look like a constant path ("Real::Name"raisesArgumentError, because it would be mistaken for a real class),set_temporary_name(nil)removes it, and a class that already has a permanent name refuses a new one (RuntimeError, "can’t change permanent name"). It is meant for libraries that build classes on the fly and want diagnostics such asfake<Namespace>instead of hex addresses. -
Class.new(Class)is an error ("can’t make subclass of Class"), and the superclass argument must be a class: passing an instance, as inClass.new(adapter.new), raisesTypeError. -
Class#subclasses(Ruby 3.1) returns the direct subclasses without a hook; a descendants method for whole hierarchies is part of Ruby’s development branch and not in 4.0.Class#attached_object(Ruby 3.2) goes from a singleton class back to its object. -
The block is a closure, so it can use local variables, which
classcannot (seeprefixabove). The price is thatdefinside the block still opens a new method scope, so a local defined outside is not visible in adefbody, only indefine_methodbodies. -
Constants assigned inside the block do not belong to the new class. Constant lookup is lexical, and a block is not a new lexical scope:
# frozen_string_literal: true
# A constant assigned inside a block belongs to the scope where the block was written,
# not to the class being built.
adapter = Class.new do
LIMIT = 3 # rubocop:disable Lint/ConstantDefinitionInBlock
def limit = LIMIT
end
adapter.new.limit # => 3
adapter.constants # => []
Object.const_defined?(:LIMIT, false) # => true
# const_set puts the constant on the class itself.
bounded = Class.new do
const_set(:MAX, 5)
def max = self.class::MAX
end
bounded.constants # => [:MAX]
bounded.new.max # => 5
Object.const_defined?(:MAX, false) # => false
LIMIT = 3 inside Class.new do ... end defines ::LIMIT (here at the top level), leaving adapter.constants empty. RuboCop reports this as Lint/ConstantDefinitionInBlock; the one disabling comment in the file is the deliberate illustration. Use const_set inside the block, or assign constants in a real class body.
Parameterized classes, abstract bases and class-level delegation
Class methods are ordinary methods on the singleton class, so a class can expose any operator-like method, including []. That gives parameterized classes: Repository[Book] returns a new subclass configured for Book. The pattern uses Class.new(self) to create the subclass and
define_singleton_method to give it the parameter, and memoises the result so that Repository[Book] is always the same class. The same ingredients make abstract base classes, class-to-instance delegation (Renewals.renew(loan) calling Renewals.new.renew(loan)) and a callable alias such as call.
# frozen_string_literal: true
require "forwardable"
require "shelf"
module Shelf
# An abstract base whose concrete classes are produced by Repository[Model].
class Repository
# Repository[Book] returns a subclass bound to Book. The result is memoised per model.
def self.[](model)
@bound ||= {}
@bound[model] ||= Class.new(self) do
define_singleton_method(:model) { model }
end
end
# The abstract part: an unbound repository has no model.
def self.model = raise(NotImplementedError, "bind a model first: #{name}[Model]")
def initialize(store: Shelf.config.store)
@store = store
end
def all = @store.all.grep(self.class.model)
end
BookRepository = Repository[Book]
AuthorRepository = Repository[Author]
# Class-to-instance delegation: Renewals.renew(...) is Renewals.new.renew(...).
class Renewals
def self.delegate_to_instance(*names)
names.each do |name|
define_singleton_method(name) { |*args, **kwargs, &block| new.public_send(name, *args, **kwargs, &block) }
end
end
delegate_to_instance :renew
def renew(loan, days: 14) = "#{loan.book.title} renewed for #{days} days"
alias call renew
end
# Forwardable generates the delegating methods for you.
class Desk
extend Forwardable
def_delegators :@catalog, :count, :first
def_delegator :@catalog, :add, :shelve
def initialize(catalog) = @catalog = catalog
end
end
def message_of
yield
rescue StandardError, ScriptError => e
"#{e.class}: #{e.message}"
end
Shelf::BookRepository.model # => Shelf::Book
Shelf::BookRepository.name # => "Shelf::BookRepository"
Shelf::Repository[Shelf::Book].equal?(Shelf::BookRepository) # => true
Shelf::BookRepository.superclass.equal?(Shelf::Repository) # => true
message_of { Shelf::Repository.model } # => "NotImplementedError: bind a model first: Shelf::Repository[Model]"
# NotImplementedError is a ScriptError, so a plain rescue does not catch it.
NotImplementedError.ancestors.take(3) # => [NotImplementedError, ScriptError, Exception]
NotImplementedError.ancestors.include?(StandardError) # => false
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
store = Shelf::Store::Memory.new.add(book).add(book.author)
Shelf::BookRepository.new(store:).all.map(&:title) # => ["Dune"]
Shelf::AuthorRepository.new(store:).all.map(&:name) # => ["Frank Herbert"]
loan = Shelf::Loan.new(book, state: :lent)
Shelf::Renewals.renew(loan) # => "Dune renewed for 14 days"
Shelf::Renewals.renew(loan, days: 7) # => "Dune renewed for 7 days"
Shelf::Renewals.new.call(loan, days: 21) # => "Dune renewed for 21 days"
Shelf::Renewals.instance_method(:call).eql?(Shelf::Renewals.instance_method(:renew)) # => true
Shelf::Renewals.instance_method(:call).name # => :call
Shelf::Renewals.instance_method(:call).original_name # => :renew
# Method objects of delegating methods know where they were defined.
desk = Shelf::Desk.new(Shelf::Catalog.new(store: Shelf::Store::Memory.new))
desk.shelve(book).class # => Shelf::Catalog
desk.count # => 1
desk.first.title # => "Dune"
Shelf::Desk.instance_methods(false).sort # => [:count, :first, :shelve]
# then yields the receiver; with `it` the block needs no parameter list (Ruby 3.4).
Shelf::BookRepository.new(store:).all.then { it.map(&:title).join(", ") } # => "Dune"
-
BookRepository = Repository[Book]names the generated subclass, andRepository[Book].equal?(BookRepository)shows the memoisation. Writingclass BookRepository < Repository[Book]also works but leaves a nameless intermediate class betweenBookRepositoryandRepositoryin the ancestors; assigning the result to a constant avoids it. Giving each parameterized class its own class-level state is the reason to prefer this over a constructor argument: validation, naming and class methods can then depend on the parameter. -
The abstract part is a class method (
Repository.model) that raises until a subclass overrides it. The convention to avoid is raisingNotImplementedErrorfor "subclass must implement": it inherits fromScriptError, notStandardError, so a plainrescueorrescue => edoes not catch it and it can escape a request loop or test runner. RaiseNoMethodError(the honest description), a customShelf::Errorsubclass, orArgumentError/TypeErrorfor bad configuration, and reserveNotImplementedErrorfor features a platform does not support, which is what its documentation describes. -
Class-to-instance delegation turns an object that is naturally built per call into one with a convenient class-level entry point.
delegate_to_instance :renewgeneratesRenewals.renewwithdefine_singleton_method, forwarding positional, keyword and block arguments explicitly (adefine_methodblock cannot use...). The shorter hand-written form for one entry point isdef self.call(...) = new.call(...). Both keepnewout of the call sites and allow the instance to carry state during the call. -
alias call renewmakes the object usable wherever a callable is expected (.(),Method#to_proc,then(&callable)), andoriginal_namestill reports:renew. In a class body RuboCop’sStyle/Aliasprefers thealiaskeyword for a literal pair, whilealias_methodis the form for computed names and for use outside the class body. -
Forwardable(a default gem in the standard library,require "forwardable") is the stdlib class-level macro for delegation:def_delegators :@catalog, :count, :firstdefines real methods namedcountandfirst, anddef_delegator :@catalog, :add, :shelverenames on the way. The methods appear ininstance_methods(false)and need nomethod_missing.SimpleDelegatorandDelegateClassfromdelegateare the object-level versions (see Modules and Mixins). -
thenwithit(Ruby 3.4,itas the implicit single block parameter) keeps a small transformation of a returned value in one expression:repository.all.then { it.map(&:title).join(", ") }. On Ruby 3.3 and earlieritin a block without parameters is an ordinary method call and the line fails withNameError; use_1or a named parameter there.
Ghost methods with method_missing
When the lookup in the diagram at the top of the page finds nothing, Ruby calls method_missing(name, *args, &block) on the receiver, passing the Symbol and the original arguments. The default implementation, BasicObject#method_missing, raises NoMethodError (NameError for a bare
identifier). Overriding it creates ghost methods: calls that have no def behind them. Proxies, builders (xml.title("Dune")), dynamic finders and test doubles all work this way. The behaviour of the default method, the error classes and the
BasicObject proxy are described in Method Lookup and super; this section is about writing the override correctly.
The protocol and the full signature
A correct override has four parts:
-
Handle only the names you own and send everything else to
super, so that real errors (typos, calls to missing methods) still produceNoMethodErrorwith the right name, receiver and message. RuboCop’sLint/MissingSuperandStyle/MissingRespondToMissingcheck this. -
Forward the complete call: positional arguments, keyword arguments and the block. The signature
(name, *args, **kwargs, &block)does it explicitly. Anonymous forms do it without names:(name, *, **, &)(anonymous*and**arguments can be passed on since Ruby 3.2; the anonymous block&since Ruby 3.1), and(name, ...)(...accepts a leading argument since Ruby 3.0). -
Define
respond_to_missing?(name, include_private = false)with the same condition, ending in|| super, so thatrespond_to?,methodandpublic_methodagree with the ghost methods. -
Prefer calling the real target with
public_sendwhen forwarding, so the ghost does not become a way to call private methods.
The easiest part to get wrong is the keyword arguments. Since Ruby 3.0 positional and keyword arguments are separate. A method_missing(name, *args, &block) collects the keywords into a Hash that is the last element of args, and forwarding it with *args hands the target an ordinary positional Hash,
which is an ArgumentError for a method that expects keywords. There are three fixes: declare **kwargs (or the anonymous **), use ..., or keep the old signature and mark it with ruby2_keywords, which flags the final Hash so that splatting it passes keywords again.
# frozen_string_literal: true
require "shelf"
# The named parameters below are the point of the comparison, so RuboCop's advice to use
# anonymous forwarding is switched off for the three classes that show them.
module Shelf
# The object being wrapped: positional, keyword and block arguments.
class Receipt
def line(book, prefix: "", &decorate)
text = "#{prefix}#{book.title}"
decorate ? decorate.call(text) : text
end
def total = 0
end
# rubocop:disable Style/ArgumentsForwarding, Naming/BlockForwarding
# Forwards only *args: keyword arguments arrive as a positional Hash.
class NaiveForwarder
def initialize(target) = @target = target
def method_missing(name, *args, &block)
return super unless @target.respond_to?(name)
@target.public_send(name, *args, &block)
end
def respond_to_missing?(name, include_private = false) = @target.respond_to?(name, include_private) || super
end
# The full signature: positional, keyword and block arguments all travel.
class Forwarder
def initialize(target) = @target = target
def method_missing(name, *args, **kwargs, &block)
return super unless @target.respond_to?(name)
@target.public_send(name, *args, **kwargs, &block)
end
def respond_to_missing?(name, include_private = false) = @target.respond_to?(name, include_private) || super
end
# rubocop:enable Style/ArgumentsForwarding, Naming/BlockForwarding
# The ... parameter (with a leading argument since Ruby 3.0) says the same without names.
class EllipsisForwarder
def initialize(target) = @target = target
def method_missing(name, ...)
return super unless @target.respond_to?(name)
@target.public_send(name, ...)
end
def respond_to_missing?(name, include_private = false) = @target.respond_to?(name, include_private) || super
end
# rubocop:disable Style/ArgumentsForwarding, Naming/BlockForwarding
# ruby2_keywords flags the trailing Hash so that splatting it passes keywords again.
class FlaggedForwarder
def initialize(target) = @target = target
ruby2_keywords def method_missing(name, *args, &block)
return super unless @target.respond_to?(name)
@target.public_send(name, *args, &block)
end
def respond_to_missing?(name, include_private = false) = @target.respond_to?(name, include_private) || super
end
# rubocop:enable Style/ArgumentsForwarding, Naming/BlockForwarding
end
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
receipt = Shelf::Receipt.new
forwarders = [Shelf::NaiveForwarder, Shelf::Forwarder, Shelf::EllipsisForwarder, Shelf::FlaggedForwarder]
forwarders.each do |klass|
result = begin
klass.new(receipt).line(book, prefix: "> ", &:upcase)
rescue ArgumentError => e
"ArgumentError: #{e.message}"
end
puts "#{klass.name.split('::').last}: #{result}"
end
forwarder = Shelf::Forwarder.new(receipt)
forwarder.total # => 0
forwarder.respond_to?(:line) # => true
forwarder.respond_to?(:refund) # => false
# respond_to_missing? also makes method() work; the Method object reports the ghost.
forwarder.method(:line).arity # => -1
forwarder.method(:line).call(book) # => "Dune"
forwarder.method(:line).inspect # => "#<Method: Shelf::Forwarder#line(*)>"
def message_of
yield
rescue NameError => e
"#{e.class}: #{e.message}"
end
# super turns an unknown name back into the usual error, with name, receiver and arguments intact.
message_of { forwarder.refund(1) } # => "NoMethodError: undefined method 'refund' for an instance of Shelf::Forwarder"
message_of { forwarder.instance_eval { refund } }.sub(/ for an instance.*/, "")
# => "NameError: undefined local variable or method 'refund'"
Output:
NaiveForwarder: ArgumentError: wrong number of arguments (given 2, expected 1)
Forwarder: > DUNE
EllipsisForwarder: > DUNE
FlaggedForwarder: > DUNE
Source: BasicObject#method_missing, Object#respond_to_missing?, Module#ruby2_keywords and Ruby 3.2.0 NEWS
The first forwarder is the classic bug: line(book, prefix: "> ", &:upcase) arrives as args = [book, {prefix: "> "}], and the target receives two positional arguments instead of one positional and one keyword. The other three forwarders pass the keyword and the block through unchanged. The file
disables Style/ArgumentsForwarding and Naming/BlockForwarding around the classes that show named parameters, because the named form is what the comparison is about; in code of your own prefer the anonymous or ... form.
Since Ruby 3.0 a *args forwarder that is not marked with ruby2_keywords stops passing keywords. Ruby 3.2 then tightened the rule: a method that takes a rest parameter and wishes to delegate keywords through it must be marked itself, "with no exception"
(see the 3.2.0 NEWS), because the flag is no longer kept when a flagged Hash passes through an unmarked *args method, so every hop in a chain of forwarders needs the mark. The **kwargs, anonymous and ... forms need no marking and are the better choice for new code.
The last two lines of the file show super doing its job: an unknown name still raises NoMethodError, with the right method name, and a bare identifier (refund evaluated with instance_eval, so with no receiver and no arguments) raises
NameError, the same error Ruby raises for a misspelled local variable.
respond_to_missing? and Method objects
respond_to? does not know about ghost methods unless respond_to_missing? says so, and the failure mode is subtle: code that checks respond_to?(:line) before calling concludes the object cannot do it, and forwarder.method(:line) raises NameError for a method that works when called.
With respond_to_missing? in place, method(:line) returns a Method whose call goes through method_missing, whose arity is -1 and whose inspect shows #<Method: Shelf::Forwarder#line(*)>. The same ghost can then be passed as a block with &forwarder.method(:line) or stored in a dispatch table.
respond_to_missing? is also what libraries call on your behalf. Kernel#Array, Array#flatten, puts, multiple assignment, String#+, Array#join, File.join and the other implicit conversions (see
Conversions and Coercion) probe their arguments for to_ary, to_str, to_path, to_proc, to_hash and similar names, which is where over-eager ghosts go wrong.
Pitfalls of ghost methods
# frozen_string_literal: true
require "shelf"
module Shelf
# A bag of settings with ghost readers and writers that become real methods after first use.
class Options
def initialize(**values) = @values = values
def method_missing(name, *)
key = name.to_s.delete_suffix("=").to_sym
return super unless @values.key?(key) || name.end_with?("=")
define_accessors(key)
public_send(name, *)
end
def respond_to_missing?(name, include_private = false)
@values.key?(name.to_s.delete_suffix("=").to_sym) || super
end
private
def define_accessors(key)
define_singleton_method(key) { @values[key] }
define_singleton_method(:"#{key}=") { |value| @values[key] = value }
end
end
# Answers every message, which is exactly the problem.
class Greedy
def method_missing(name, *) = "ghost #{name}"
def respond_to_missing?(*) = true
end
# Handles only the names it owns and leaves the rest to super.
class Polite
def method_missing(name, *)
return super unless name.start_with?("get_")
"ghost #{name}"
end
def respond_to_missing?(name, include_private = false) = name.start_with?("get_") || super
end
end
options = Shelf::Options.new(loan_days: 14)
options.singleton_methods # => []
options.loan_days # => 14
options.singleton_methods # => [:loan_days=, :loan_days]
options.loan_days = 21 # => 21
options.loan_days # => 21
options.respond_to?(:loan_days) # => true
options.respond_to?(:fine) # => false
def message_of
yield
rescue StandardError, SystemStackError => e
"#{e.class}: #{e.message}"
end
message_of { Shelf::Options.new.fine } # => "NoMethodError: undefined method 'fine' for an instance of Shelf::Options"
# Implicit conversions probe the object with respond_to? and to_ary, to_str, to_proc, ...
greedy = Shelf::Greedy.new
message_of { puts greedy }
# => "TypeError: can't convert Shelf::Greedy to Array (Shelf::Greedy#to_ary gives String)"
message_of { [greedy].flatten }
# => "TypeError: can't convert Shelf::Greedy to Array (Shelf::Greedy#to_ary gives String)"
message_of { Array(greedy) }
# => "TypeError: can't convert Shelf::Greedy to Array (Shelf::Greedy#to_ary gives String)"
polite = Shelf::Polite.new
polite.get_title # => "ghost get_title"
[polite].flatten.size # => 1
polite.respond_to?(:to_ary) # => false
# A method_missing that calls another missing method calls itself until the stack runs out.
class Runaway
def method_missing(name, *) = helper_for(name)
def respond_to_missing?(*) = false
end
message_of { Runaway.new.anything } # => "SystemStackError: stack level too deep"
-
A catch-all ghost breaks implicit conversions.
Greedyanswers every message, includingto_ary, with a String.puts greedy,[greedy].flattenandArray(greedy)all ask forto_ary, get a String back and raiseTypeError("can’t convert Shelf::Greedy to Array (Shelf::Greedy#to_ary gives String)"). Where the conversion isto_strthe damage is quieter:"x" + greedyreturns"xghost to_str"and[greedy].join(",")returns"ghost to_str"instead of failing, andFile.join("a", greedy)returns"a/ghost to_path".Politeanswers onlyget_names and sends the rest tosuper, soflattenandputssee an ordinary object. The rule: match narrowly (a prefix, a pattern, a lookup in a table) and never return a value for a name you did not check. -
A ghost that raises for everything else must raise the right error. Return
super, notnil, for unknown names. Amethod_missingthat silently returnsnilhides typos and makesrespond_to_missing?untruthful. -
Visibility errors reach
method_missingtoo. A private or protected method called with an explicit receiver is routed tomethod_missing. A catch-all ghost therefore returns a value forobject.private_method, where the default raises "private method called". -
Recursion. A
method_missingthat calls a method the class does not define (or reads an attribute that is not set up yet) calls itself untilSystemStackError("stack level too deep"). Initialise the state the ghost reads, ininitialize, before the object can receive messages, and reach it with@ivarrather than a method. -
Bare identifiers. A name with no receiver and no arguments (
ghost) reachesmethod_missingtoo, so a handled ghost works in all ofghost,ghost()andself.ghost. What differs is the error when the ghost ends insuper: a bare identifier raisesNameError("undefined local variable or method"), the explicit forms raiseNoMethodError. -
Class-level ghosts need class-level
respond_to_missing?. Define both on the singleton (def self.method_missing,def self.respond_to_missing?), as dynamic finders do. -
Debugging. Backtraces for ghost methods pass through
method_missing, and the method cannot be found withgreporinstance_methods. A ghost whose names are fixed in advance is almost always better written withdefine_method. -
Messages. On Ruby 4.0 the
NoMethodErrormessage for an ordinary object isundefined method 'fine' for an instance of Shelf::Options; once an object has singleton methods (whichOptionsgets after its first access), it prints the object’sinspectinstead, including its memory address.
The cost of ghost methods
Every ghost call first misses in the method lookup, then calls method_missing, which usually does string and Symbol work to decide whether the name is theirs. A generated method pays the lookup once, when it is defined. The benchmark compares three ways of writing loan.lent? with identical results: a
def, a define_method loop and a method_missing ghost that parses the name on every call. Benchmark.bmbm runs each block twice (a rehearsal first, to warm caches) and reports the second pass.
# frozen_string_literal: true
require "benchmark"
require "shelf"
# Three ways to answer loan.lent?, all with the same result.
class DefLoan < Shelf::Loan
def lent? = state == :lent
end
class DefineLoan < Shelf::Loan
Shelf::Loan::STATES.each { |name| define_method(:"#{name}?") { state == name } }
end
class GhostLoan < Shelf::Loan
def method_missing(name, *args, &)
status = name.to_s.delete_suffix("?").to_sym
return super unless name.end_with?("?") && Shelf::Loan::STATES.include?(status)
state == status
end
def respond_to_missing?(name, include_private = false)
(name.end_with?("?") && Shelf::Loan::STATES.include?(name.to_s.delete_suffix("?").to_sym)) || super
end
end
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
loans = [DefLoan, DefineLoan, GhostLoan].map { |klass| klass.new(book, state: :lent) }
loans.map(&:lent?) # => [true, true, true]
runs = 1_000_000
Benchmark.bmbm do |bench|
loans.each { |loan| bench.report(loan.class.name) { runs.times { loan.lent? } } }
end
Output (an Apple silicon laptop, Ruby 4.0.7; your numbers will differ, the ratios are what matters):
Rehearsal ----------------------------------------------
DefLoan 0.028839 0.000007 0.028846 ( 0.028853)
DefineLoan 0.031347 0.000030 0.031377 ( 0.031397)
GhostLoan 0.179977 0.001163 0.181140 ( 0.181933)
------------------------------------- total: 0.241363sec
Source: https://github.com/ruby/benchmark[Benchmark (bundled gem)], https://docs.ruby-lang.org/en/4.0/Module.html#method-i-define_method[Module#define_method] and https://docs.ruby-lang.org/en/4.0/BasicObject.html#method-i-method_missing[BasicObject#method_missing]
user system total real
DefLoan 0.029703 0.000457 0.030160 ( 0.030977)
DefineLoan 0.032377 0.000343 0.032720 ( 0.033476)
GhostLoan 0.210576 0.004055 0.214631 ( 0.221432)
One million calls take about 0.03 s with def, about 0.03 s with define_method and about 0.2 s with the ghost, a factor of six to seven. Run on Ruby 3.4.x the same file gave the same ordering and a similar ratio (about 0.17 s for the ghost). The difference is about a fifth of a microsecond per call: irrelevant for a method called a handful of times, and noticeable for one in a hot loop that runs millions of times. Measure
your own case with Benchmark.bmbm (or benchmark-ips, see
Performance, Benchmarking and Profiling). Remember that benchmark was promoted from a default gem to a bundled gem in Ruby 4.0 (see the 4.0 NEWS): it is installed with Ruby, but under Bundler it needs a Gemfile entry.
The best of both is the hybrid: let the ghost answer the first call, then define a real method so later calls never reach method_missing. Shelf::Options in the previous example does that with define_singleton_method, using the first access to create a reader and a writer.
singleton_methods is empty before the first access and holds [:loan_days=, :loan_days] afterwards. A hybrid is more code than either pure form, so it is worth it only for ghosts that sit on a hot path and whose name set is not known up front.
The reflective surface at a glance
The primitives on this page come from three places. BasicObject is the minimum an object needs; Object and Kernel add the reflective helpers; Module and Class add the definition tools.
# frozen_string_literal: true
# What a BasicObject really has: the bare minimum for dispatch and identity.
BasicObject.instance_methods.sort
# => [:!, :!=, :==, :__id__, :__send__, :equal?, :instance_eval, :instance_exec]
BasicObject.private_instance_methods.sort
# => [:initialize, :method_missing, :singleton_method_added, :singleton_method_removed, :singleton_method_undefined]
Object.method_defined?(:send) # => true
BasicObject.method_defined?(:send) # => false
Source: BasicObject and Object
| Where | Dispatch and definition members |
|---|---|
|
|
|
|
|
|
|
|
Object.method_defined?(:send) is true and BasicObject.method_defined?(:send) is false: a BasicObject subclass has to use __send__ and cannot call respond_to?, class or inspect, which is exactly why it is used for blank-slate proxies. Object#instance_variable_get and its siblings, method,
and the definition tools are public, so metaprogramming is one method call away from any object. The hooks and the eval family are covered in Hooks, eval and DSLs, and binding, ObjectSpace and tracing in
Reflection, Introspection and Tracing.
When to metaprogram
Metaprogramming moves work from the person reading the code to the interpreter at run time, and makes the code harder to search, to debug and to document. It earns that cost when it removes real duplication or when the set of names is genuinely data. A practical order of preference when a class needs behaviour that varies:
| Need | Prefer | Reach for metaprogramming when |
|---|---|---|
Two or three similar methods |
Write them out with |
There are many, or they come from a list that already exists (a loop that calls |
Calling a method chosen at run time |
A |
The name space is large and regular ( |
Methods known only at run time |
|
A proxy or builder must accept names it cannot enumerate. |
Changing an existing class |
A subclass, a decorator, or a module added with |
There is no other way into the object, and then through a refinement (Refinements and Monkey Patching) rather than a global patch. |
Behaviour on one object |
|
The object comes from code you do not control. |
A configuration language |
A DSL built from blocks, |
The configuration is read by many people and the declarative form removes boilerplate. |
Questions that help decide:
-
Could a Hash or a
casedo the same job? If the generated methods are only a different way of spelling a lookup, they usually can. -
Will a reader know where the method came from? Name the generator clearly (
attribute,has_state), keep it in one file, and document the result (YARD or RDoc directives). -
Is the input trusted? Names from a constant list are fine. Names from a file the user edits or from the network need an allow-list.
-
Is it on a hot path? If so, generate real methods, not ghosts.
-
Can the generated interface be tested? If you cannot assert what was defined, the macro does too much.
-
Does it honour the Ruby object model? Generated methods should respect visibility, call
superwhere subclasses might override them, definerespond_to_missing?for ghosts, and return the same types that equivalent hand-written methods would.
String-based eval (class_eval "def #{name}; end") is the oldest way to generate methods and the least safe: the string is parsed as code, so a name or a default value that came from outside becomes a code injection, and syntax errors appear at run time far from the cause.
Prefer the block forms on this page. If a string eval is unavoidable, pass __FILE__ and __LINE__ so backtraces point at the definition (RuboCop’s Style/EvalWithLocation checks this), and add a documenting comment with an example of the generated code (Style/DocumentDynamicEvalDefinition).
Ruby’s own Forwardable is such a case, and a good model for how it is done carefully. Details are in Hooks, eval and DSLs.
Testing generated code
Test the interface that the generator produces, not the generator’s implementation. Assert that each name exists (assert_respond_to, public_method_defined?), that it behaves for every input the generator accepts (loop over the same list), that it is absent for names it should not define (refute_respond_to), and that
its signature is what the documentation promises (arity, parameters). For ghost methods, add the negative case: an unknown name must still raise NoMethodError. Property-style loops are cheap, since the generator’s input is a list.
# frozen_string_literal: true
require "minitest/autorun"
require "shelf"
module Shelf
# The code under test: one generated predicate per state.
class StatefulLoan < Loan
STATES.each do |name|
define_method(:"#{name}?") { state == name }
end
end
end
class StatefulLoanTest < Minitest::Test
def setup
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
@book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new(name: "Frank Herbert"))
end
# Test the generated interface, not the generating code.
def test_a_predicate_exists_for_every_state
Shelf::Loan::STATES.each do |state|
assert_respond_to Shelf::StatefulLoan.new(@book), :"#{state}?"
end
end
def test_exactly_one_predicate_is_true_for_each_state
Shelf::Loan::STATES.each do |state|
loan = Shelf::StatefulLoan.new(@book, state:)
truthy = Shelf::Loan::STATES.select { |name| loan.public_send(:"#{name}?") }
assert_equal [state], truthy
end
end
def test_predicates_take_no_arguments
method = Shelf::StatefulLoan.instance_method(:lent?)
assert_equal 0, method.arity
assert_equal [], method.parameters
end
def test_unknown_states_are_not_generated
loan = Shelf::StatefulLoan.new(@book)
refute_respond_to loan, :lost?
assert_raises(NoMethodError) { loan.lost? }
end
def test_generated_methods_are_public_instance_methods
assert Shelf::StatefulLoan.public_method_defined?(:lent?)
end
end
Output (ruby -I lib ex12.rb --seed 42; the timing line varies):
Run options: --seed 42
# Running:
.....
Finished in 0.000281s, 17793.5915 runs/s, 46263.3379 assertions/s.
5 runs, 13 assertions, 0 failures, 0 errors, 0 skips
Source: Minitest::Assertions (Minitest is a bundled gem; under Bundler add it to the Gemfile) and Module#public_instance_method
The first two tests check every state, so adding a new entry to Loan::STATES extends the tests without edits. The arity test fails if someone changes the macro so that the predicates take a parameter. Testing frameworks are in
Testing with Minitest and RSpec and Test Doubles; RSpec’s respond_to and have_attributes matchers cover the same ground.
Pitfalls
-
sendfor private methods in production code. It couples callers to implementation details. Preferpublic_send, or make the method public, or move the behaviour into a collaborator. -
Unfiltered names.
public_send(params[:action])orconst_get(params[:type])is remote control of the program. Filter first. -
define_methodin a loop that captures the loop variable by reference. Block parameters are fresh on each iteration, sonames.each { |name| define_method(name) { name } }is correct; awhileloop with an outer variable shares one variable between all generated methods. Useeachormap. -
Forgetting
respond_to_missing?(or the|| super), and therefore breakingrespond_to?,methodand duck typing. -
Ghosts that swallow errors. Return
superfor names you do not own. -
Positional Hash instead of keywords in a forwarder written as
(name, *args, &block). Use**kwargs,..., orruby2_keywords. -
Constants defined inside
Class.new { }land in the enclosing scope. Useconst_set. -
Redefinition warnings.
-wreportsmethod redefined; discarding old namewhendefine_methodordefreplaces a method in the same class. Generate each name once, and useremove_methodor a subclass if replacement is intended. -
Class-level instance variables are not inherited. A macro that stores its list in
@attributeson the class sees nothing from a superclass; subclasses need their own copy, usually made in theinheritedhook (see Hooks, eval and DSLs). -
const_getand private constants.private_constantis not enforced forconst_get. -
Shallow freezing.
freezedoes not freeze the objects an object points at, so a frozen catalogue still accepts books through its store. -
Top-level
define_method. It defines a public method on every object. -
Thread safety. Defining methods at run time while other threads call them is allowed but racy for the code that checks
respond_to?and then calls. Define generated methods at load time, before the program starts serving requests.
Where older material is outdated
Tutorials and books on metaprogramming were mostly written for Ruby 2.x and 3.0 to 3.2. Items to correct when reading them:
| Older material says | Current Ruby 4.0 behaviour |
|---|---|
Error text such as |
Ruby 3.3 changed the receiver to "for an instance of ClassName" (and |
|
Ruby 3.4 prints |
|
It is a |
|
They are ActiveSupport, not core Ruby, and |
|
Ruby 3.4 adds |
|
|
|
Wrap with |
|
From Ruby 3.0 keywords are separate from positional arguments; use |
|
It returns an Array of Symbols since Ruby 3.0, and |
|
|
|
|
|
Added in Ruby 3.1 and 3.2. |
|
|
String |
Block-based |
|
It returns its arguments since Ruby 3.1, which is why |
A top-level |
On the Rubies checked (3.3, 3.4 and 4.0) it works and defines a public method on |
RuboCop cops for dynamic dispatch and definition
The following cops, run with RuboCop 1.92, encode rules from this page.
| Cop | What it flags |
|---|---|
|
|
|
|
|
A class that defines |
|
Hooks and constructors that do not call |
|
Named |
|
A constant assigned inside |
|
|
|
|
|
String |
|
A |
Source: RuboCop Style cops and RuboCop Lint cops
References
-
Object, BasicObject, Kernel, Module and Class (
send,public_send,define_method,method_missing,const_get) -
Method and UnboundMethod
-
Ruby 4.0 documentation: Methods, Calling Methods and Modules and Classes