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.

flowchart TD A["receiver.name(args, block)"] --> B{"Method named name in the singleton class, class or ancestors?"} B -- yes --> C{"Visibility allows this call site?"} C -- yes --> D["Run the method"] C -- "no: private or protected" --> F["method_missing(name, ...)"] B -- no --> F F --> G{"method_missing overridden?"} G -- "yes, and it handles name" --> H["Ghost method result"] G -- "yes, but it calls super" --> I["BasicObject#method_missing"] G -- no --> I I --> J["raise NoMethodError, or NameError for a bare identifier"]

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

object.send(name, *args, &block)

Ignores it: private and protected methods run

Defined on Object (through Kernel). A class may define its own send (a socket or a mail object does), which then hides this one.

object.__send__(name, *args, &block)

Ignores it

Defined on BasicObject, so it works on blank-slate proxies, and by convention is never overridden. Use it when the receiver might define send.

object.public_send(name, *args, &block)

Respects it: private and protected methods raise NoMethodError

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

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’s Style/SendWithLiteralMethodName flags it (for public_send; Style/Send, which asks for __send__ instead of send, is disabled by default).

  • Use public_send when the name is data, and send only when bypassing visibility is the point: tests of private methods, framework code that calls hooks such as initialize_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

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

object.method(:name)

The Method for a public, protected or private method (or a ghost method vouched for by respond_to_missing?). Raises NameError if there is none.

object.public_method(:name)

The same, but only for public methods.

object.singleton_method(:name)

Only methods defined directly on the object’s singleton class.

Method#call, method.(args), Method#[], Method#===

Invoke it. === makes a Method usable as a case branch.

Method#to_proc

A lambda that calls the method. This is what &object.method(:name) passes.

Method#unbind, Module#instance_method

An UnboundMethod. It cannot be called until bound.

UnboundMethod#bind(object), UnboundMethod#bind_call(object, *args)

Attach it to an object of the right class (bind_call binds and calls in one step). Binding to an object of another class raises TypeError.

Method#owner, Method#receiver, Method#name, Method#original_name

Where the method was found, what it is bound to, and both its name and the name it was defined with (they differ after alias).

# 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"

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

What the example shows, and the rules behind it:

  • The block is a closure. renewals is a local variable of the class body, and every generated renew shares it. A def starts a new scope and could not see it. That makes define_method the tool for methods that need data captured at definition time (the name in each predicate, a counter, a lookup table).

  • self is the receiver. Inside the block self is the instance the method was called on (the documentation says the block is evaluated with instance_eval), so state, book and private helpers are reachable without a receiver.

  • Parameters are those of a lambda. Even when the body is a Proc created with proc { |a, b| }, the generated method checks arity: loan.lent?(1) raises ArgumentError. Optional, keyword and block parameters (|days = 7, reason: "none", &log|) all work, and Method#parameters and arity report them like any other method.

  • return leaves the method. In an ordinary block return would return from the method that wrote the block; in a define_method body it returns from the generated method.

  • Visibility follows the scope. Called directly in a class body after private, define_method creates a private method, and the same holds in a block run from the class body (such as STATES.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, so private define_method(:x) { } works, although RuboCop’s Style/AccessModifierDeclarations prefers the separate private :x line used above.

  • Bare super is rejected. Inside a generated method super must be written with explicit arguments, super() or super(book). A bare super raises RuntimeError ("implicit argument passing of super from method defined by define_method() is not supported"). The same applies to methods defined with define_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 with instance_method, then define a new one that calls old.bind_call(self, ...). Method-lookup-based wrapping with prepend is usually cleaner and is covered in Method Lookup and super. An UnboundMethod can only be reused on a class that is compatible with its owner.

  • The error location is the block. The backtrace of an ArgumentError raised for a wrong call shows block in <class:TrackedLoan>, and source_location points at the define_method call, 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, ... forwarding

Any parameter list a block can take, except ...

super without arguments

Allowed (implicit arguments)

A RuntimeError; write the arguments

return

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 (Shelf::Loan#describe)

A block frame; source_location is the define_method call

Speed

The fastest way to call a method

Close to def in the measurements shown in The cost of ghost methods

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

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"

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

Module#alias_method(new, old)

Makes new another name for old and returns new as a Symbol (Ruby 3.0; it returned the class before). Method#original_name still reports old. Inside a class body RuboCop’s Style/Alias prefers the alias new old keyword for literal names.

Module#remove_method(name)

Deletes this class’s own definition, so lookup falls through to the superclass or to an included module. Raises NameError if the class does not define it itself.

Module#undef_method(name)

Blocks the name entirely: even an inherited definition is hidden, and a call raises NoMethodError. Module#undefined_instance_methods (Ruby 3.2) lists what was blocked this way in a class.

Module#public_instance_method(name)

Like instance_method but only for public methods.

Module#method_defined?(name), public_method_defined?, private_method_defined?, protected_method_defined?

Whether the module has a method of that name, inherited ones included. method_defined? answers for public and protected methods and ignores private ones; the other three answer for exactly their own visibility.

# 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

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 @!method directive, which creates a documented method that has no def, and @!macro [attach], which attaches a template to the method that follows (here attribute) and expands it for each call, with $1 standing for the first argument. The comment block above attribute is exactly that. Write the directives in the comment of the macro; YARD then documents name, name=, max_loans and max_loans= on Shelf::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 raises NameError ("wrong constant name lower"), and a valid but missing one calls const_missing and, by default, raises NameError ("uninitialized constant Shelf::Nope").

  • inherit matters. By default the lookup also searches the ancestors of the receiver and, for a module, Object. So Shelf::Store.const_get(:String) returns ::String. Pass false to 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 with autoload (see Code Loading: require and autoload).

  • Module#const_source_location(name) returns the file and line where the constant was assigned.

  • private_constant hides a constant from Box::NAME, but not from Box.const_get(:NAME): const_get ignores constant visibility, so it is a way around a private constant and should not be used on names you do not control.

  • remove_const is private on Module, so removing a constant takes send(: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

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
  • A class gets a name the first time it is assigned to a constant, either by Const = Class.new or by const_set, and keeps it. Until then name is nil and inspect shows #<Class:0x...>. Assigning an anonymous class to a second constant does not rename it. The inherited hook runs with the anonymous class, before the block, which is why Registry.seen records an empty list of methods and why hooks must not assume subclass.name is set.

  • set_temporary_name (Ruby 3.3) gives an anonymous class or module a readable, fake name for inspect and error messages. The name must not look like a constant path ("Real::Name" raises ArgumentError, 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 as fake<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 in Class.new(adapter.new), raises TypeError.

  • 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 class cannot (see prefix above). The price is that def inside the block still opens a new method scope, so a local defined outside is not visible in a def body, only in define_method bodies.

  • 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, and Repository[Book].equal?(BookRepository) shows the memoisation. Writing class BookRepository < Repository[Book] also works but leaves a nameless intermediate class between BookRepository and Repository in 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 raising NotImplementedError for "subclass must implement": it inherits from ScriptError, not StandardError, so a plain rescue or rescue => e does not catch it and it can escape a request loop or test runner. Raise NoMethodError (the honest description), a custom Shelf::Error subclass, or ArgumentError/TypeError for bad configuration, and reserve NotImplementedError for 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 :renew generates Renewals.renew with define_singleton_method, forwarding positional, keyword and block arguments explicitly (a define_method block cannot use ...). The shorter hand-written form for one entry point is def self.call(...) = new.call(...). Both keep new out of the call sites and allow the instance to carry state during the call.

  • alias call renew makes the object usable wherever a callable is expected (.(), Method#to_proc, then(&callable)), and original_name still reports :renew. In a class body RuboCop’s Style/Alias prefers the alias keyword for a literal pair, while alias_method is 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, :first defines real methods named count and first, and def_delegator :@catalog, :add, :shelve renames on the way. The methods appear in instance_methods(false) and need no method_missing. SimpleDelegator and DelegateClass from delegate are the object-level versions (see Modules and Mixins).

  • then with it (Ruby 3.4, it as 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 earlier it in a block without parameters is an ordinary method call and the line fails with NameError; use _1 or 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:

  1. Handle only the names you own and send everything else to super, so that real errors (typos, calls to missing methods) still produce NoMethodError with the right name, receiver and message. RuboCop’s Lint/MissingSuper and Style/MissingRespondToMissing check this.

  2. 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).

  3. Define respond_to_missing?(name, include_private = false) with the same condition, ending in || super, so that respond_to?, method and public_method agree with the ghost methods.

  4. Prefer calling the real target with public_send when 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

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. Greedy answers every message, including to_ary, with a String. puts greedy, [greedy].flatten and Array(greedy) all ask for to_ary, get a String back and raise TypeError ("can’t convert Shelf::Greedy to Array (Shelf::Greedy#to_ary gives String)"). Where the conversion is to_str the damage is quieter: "x" + greedy returns "xghost to_str" and [greedy].join(",") returns "ghost to_str" instead of failing, and File.join("a", greedy) returns "a/ghost to_path". Polite answers only get_ names and sends the rest to super, so flatten and puts see 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, not nil, for unknown names. A method_missing that silently returns nil hides typos and makes respond_to_missing? untruthful.

  • Visibility errors reach method_missing too. A private or protected method called with an explicit receiver is routed to method_missing. A catch-all ghost therefore returns a value for object.private_method, where the default raises "private method called".

  • Recursion. A method_missing that calls a method the class does not define (or reads an attribute that is not set up yet) calls itself until SystemStackError ("stack level too deep"). Initialise the state the ghost reads, in initialize, before the object can receive messages, and reach it with @ivar rather than a method.

  • Bare identifiers. A name with no receiver and no arguments (ghost) reaches method_missing too, so a handled ghost works in all of ghost, ghost() and self.ghost. What differs is the error when the ghost ends in super: a bare identifier raises NameError ("undefined local variable or method"), the explicit forms raise NoMethodError.

  • 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 with grep or instance_methods. A ghost whose names are fixed in advance is almost always better written with define_method.

  • Messages. On Ruby 4.0 the NoMethodError message for an ordinary object is undefined method 'fine' for an instance of Shelf::Options; once an object has singleton methods (which Options gets after its first access), it prints the object’s inspect instead, 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

BasicObject

__send__, instance_eval, instance_exec, equal?, !, !=, ==, __id__, and the private initialize, method_missing, singleton_method_added, singleton_method_removed, singleton_method_undefined.

Object and Kernel

send, public_send, respond_to?, respond_to_missing?, method, public_method, singleton_method, methods, public_methods, private_methods, protected_methods, singleton_methods, define_singleton_method, extend, singleton_class, instance_variable_get, instance_variable_set, instance_variable_defined?, instance_variables, remove_instance_variable, then, tap, itself, is_a?, kind_of?, instance_of?, __method__, __callee__, binding.

Module

define_method, alias_method, remove_method, undef_method, instance_method, public_instance_method, instance_methods, private_instance_methods, method_defined?, const_get, const_set, const_defined?, constants, const_missing, const_source_location, private_constant, attr_reader, attr_writer, attr_accessor, module_eval/class_eval, module_exec/class_exec, set_temporary_name, ruby2_keywords, include, prepend, refine, and the hooks included, extended, prepended, method_added, method_removed, method_undefined, const_added.

Class

new, allocate, superclass, subclasses, attached_object, inherited.

Source: Module, Class and Kernel

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 def. Plain code is greppable and shows up in Method#source_location.

There are many, or they come from a list that already exists (a loop that calls define_method once per state).

Calling a method chosen at run time

A case over a Symbol, or a Hash of Symbols to lambdas or Method objects.

The name space is large and regular ("#{field}=" setters), and an allow-list guards the input.

Methods known only at run time

method_missing with a narrow pattern and respond_to_missing?, preferably with the hybrid that defines a real method on first use.

A proxy or builder must accept names it cannot enumerate.

Changing an existing class

A subclass, a decorator, or a module added with prepend. See Method Lookup and super.

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

extend with a module, or a small wrapper object.

The object comes from code you do not control.

A configuration language

A DSL built from blocks, instance_eval and a handful of explicit methods (Hooks, eval and DSLs).

The configuration is read by many people and the declarative form removes boilerplate.

Questions that help decide:

  • Could a Hash or a case do 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 super where subclasses might override them, define respond_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

  • send for private methods in production code. It couples callers to implementation details. Prefer public_send, or make the method public, or move the behaviour into a collaborator.

  • Unfiltered names. public_send(params[:action]) or const_get(params[:type]) is remote control of the program. Filter first.

  • define_method in a loop that captures the loop variable by reference. Block parameters are fresh on each iteration, so names.each { |name| define_method(name) { name } } is correct; a while loop with an outer variable shares one variable between all generated methods. Use each or map.

  • Forgetting respond_to_missing? (or the || super), and therefore breaking respond_to?, method and duck typing.

  • Ghosts that swallow errors. Return super for names you do not own.

  • Positional Hash instead of keywords in a forwarder written as (name, *args, &block). Use **kwargs, ..., or ruby2_keywords.

  • Constants defined inside Class.new { } land in the enclosing scope. Use const_set.

  • Redefinition warnings. -w reports method redefined; discarding old name when define_method or def replaces a method in the same class. Generate each name once, and use remove_method or a subclass if replacement is intended.

  • Class-level instance variables are not inherited. A macro that stores its list in @attributes on the class sees nothing from a superclass; subclasses need their own copy, usually made in the inherited hook (see Hooks, eval and DSLs).

  • const_get and private constants. private_constant is not enforced for const_get.

  • Shallow freezing. freeze does 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 undefined method 'x' for nil:NilClass, or with a backtick as the opening quote

Ruby 3.3 changed the receiver to "for an instance of ClassName" (and for nil), and Ruby 3.4 changed the quotes to plain apostrophes: undefined method 'x' for nil. Messages for objects with singleton classes still print the object’s inspect.

{:name=>"Ico"} in inspect output

Ruby 3.4 prints {name: "Ico"}; Data prints as #<data Shelf::Book ...>.

NotImplementedError as the way to mark abstract methods

It is a ScriptError, not a StandardError, so rescue => e misses it. Raise NoMethodError or a custom error; keep NotImplementedError for unsupported platform features.

safe_constantize or constantize as part of "the language"

They are ActiveSupport, not core Ruby, and safe_constantize returns nil for unknown names. In plain Ruby use const_get with inherit = false and an allow-list.

_1 as the only implicit block parameter

Ruby 3.4 adds it for a single parameter; the two cannot be mixed in one block.

send(:define_method, ...), singleton_class.send(:define_method, ...)

define_method and define_singleton_method are public and need no send.

alias_method_chain and hand-rolled alias wrapping

Wrap with prepend and super, or with instance_method plus bind_call when the wrapper must live elsewhere.

method_missing examples with (name, *args, &block) that forward keywords

From Ruby 3.0 keywords are separate from positional arguments; use **kwargs, ... or ruby2_keywords, which became mandatory for *args delegation in 3.2.

attr_accessor returning nil

It returns an Array of Symbols since Ruby 3.0, and private attr_accessor :x works; alias_method returns the new name.

OpenStruct as the easy "dynamic object"

ostruct is a bundled gem since Ruby 4.0 and builds methods on demand with the costs described above. Prefer Struct, Data, a Hash or a small class like Shelf::Options (Struct, Data and Value Objects).

Benchmark and Logger listed as part of the standard library

benchmark is a bundled gem in Ruby 4.0 and needs a Gemfile entry under Bundler; logger and pstore moved the same way.

Class#subclasses and Class#attached_object missing

Added in Ruby 3.1 and 3.2. Module#undefined_instance_methods and Module#const_added are 3.2, Module#set_temporary_name 3.3.

inherited as the only way to find subclasses

Class#subclasses lists direct subclasses without a hook. A method returning all descendants is in Ruby’s development branch and not in 4.0.

String eval for method generation, as in older Rails and metaprogramming books

Block-based define_method avoids the injection and debugging problems; Forwardable remains the example of careful string generation.

private returning nil

It returns its arguments since Ruby 3.1, which is why private def name and private define_method(...) work.

A top-level define_method described as invalid

On the Rubies checked (3.3, 3.4 and 4.0) it works and defines a public method on Object. Treat it as a pitfall, because every object then responds to the new method, not as an error.

RuboCop cops for dynamic dispatch and definition

The following cops, run with RuboCop 1.92, encode rules from this page.

Cop What it flags

Style/SendWithLiteralMethodName

public_send(:name) with a literal name: call the method directly.

Style/Send

send when __send__ is preferred (disabled by default).

Style/MissingRespondToMissing

A class that defines method_missing without respond_to_missing?.

Lint/MissingSuper

Hooks and constructors that do not call super.

Style/ArgumentsForwarding, Naming/BlockForwarding

Named *args, **kwargs and &block that could be anonymous or ....

Lint/ConstantDefinitionInBlock

A constant assigned inside Class.new { } or another block.

Style/AccessModifierDeclarations

private define_method(...) in the inline form.

Style/Alias

alias_method for a literal pair in a class body, where the alias keyword is preferred.

Style/EvalWithLocation, Style/DocumentDynamicEvalDefinition

String eval without __FILE__ and __LINE__, and string-defined methods without a comment showing the code.

Lint/ShadowedException

A rescue list that names a class together with its superclass, as in NoMethodError, NameError.