Skip to main content
โšก Calmops

Dynamic Dispatch in Ruby: send, public_send, and Method Objects

Introduction

Dynamic dispatch is the ability to call a method by name at runtime, rather than hardcoding the method call at compile time. In Ruby, send and public_send are the primary tools for this. They’re fundamental to metaprogramming, DSLs, and building flexible, data-driven code.

The Problem: Calling Private Methods

By default, private methods can’t be called from outside a class:

class Student
  ENERGY_LOW = 50
  attr_accessor :name, :energy

  def initialize(name, energy = 100)
    @name   = name
    @energy = energy
  end

  def walk
    @energy -= 30
    puts "#{@name} walked. Energy: #{@energy}"
  end

  def hungry?
    if @energy <= ENERGY_LOW
      puts "#{@name} is hungry! Eating..."
      eating
    else
      puts "#{@name} is full."
    end
  end

  private

  def eating
    @energy += 20
    puts "#{@name} ate. Energy: #{@energy}"
  end
end

s1 = Student.new("Lily")
s1.walk
s1.walk
s1.hungry?

# Direct call fails:
s1.eating
# => NoMethodError: private method 'eating' called for #<Student ...>

send: Bypass Visibility

send calls any method by name โ€” including private ones:

s1 = Student.new("Lily")
s1.send(:eating)   # calls private method โ€” works!
puts s1.energy     # => 120

# send with arguments
s1.send(:walk)
s1.send(:energy=, 50)  # calls the setter

send accepts a symbol or string as the method name, followed by any arguments:

obj.send(:method_name)
obj.send(:method_name, arg1, arg2)
obj.send("method_name", arg1)

public_send: Respect Visibility

public_send works like send but only calls public methods โ€” it raises NoMethodError for private/protected methods:

s1 = Student.new("Lily")

s1.public_send(:walk)    # => works (public method)
s1.public_send(:eating)  # => NoMethodError: private method 'eating'

Best practice: Use public_send when calling methods based on user input or external data. Use send only when you intentionally need to bypass visibility (e.g., in tests or internal metaprogramming).

Dynamic Method Dispatch

The real power of send is calling methods whose names are determined at runtime:

class Report
  def summary
    "Summary report"
  end

  def detailed
    "Detailed report"
  end

  def csv
    "CSV export"
  end
end

report = Report.new
format = "detailed"  # could come from user input, config, etc.

# Dynamic dispatch โ€” no if/case needed
puts report.send(format)  # => "Detailed report"

Building a Command Dispatcher

class CLI
  def run(command, *args)
    if respond_to?(command, true)
      send(command, *args)
    else
      puts "Unknown command: #{command}"
    end
  end

  private

  def start(service)
    puts "Starting #{service}..."
  end

  def stop(service)
    puts "Stopping #{service}..."
  end

  def status(service)
    puts "#{service} is running"
  end
end

cli = CLI.new
cli.run("start",  "nginx")   # => Starting nginx...
cli.run("status", "nginx")   # => nginx is running
cli.run("stop",   "nginx")   # => Stopping nginx...
cli.run("delete", "nginx")   # => Unknown command: delete

Attribute Access by Name

class Config
  attr_accessor :host, :port, :timeout, :debug

  def initialize
    @host    = "localhost"
    @port    = 3000
    @timeout = 30
    @debug   = false
  end

  def set(key, value)
    setter = "#{key}="
    if respond_to?(setter)
      send(setter, value)
    else
      raise ArgumentError, "Unknown config key: #{key}"
    end
  end

  def get(key)
    if respond_to?(key)
      send(key)
    else
      raise ArgumentError, "Unknown config key: #{key}"
    end
  end
end

config = Config.new
config.set("host", "api.example.com")
config.set("port", 443)
puts config.get("host")  # => "api.example.com"
puts config.get("port")  # => 443

Method Objects

method(:name) returns a Method object โ€” a first-class reference to a method that can be stored, passed, and called later:

class Calculator
  def double(n)
    n * 2
  end

  def square(n)
    n ** 2
  end
end

calc = Calculator.new

# Get method objects
double_method = calc.method(:double)
square_method = calc.method(:square)

# Call them
puts double_method.call(5)  # => 10
puts square_method.call(4)  # => 16

# Pass as blocks
[1, 2, 3, 4, 5].map(&double_method)  # => [2, 4, 6, 8, 10]
[1, 2, 3, 4, 5].map(&square_method)  # => [1, 4, 9, 16, 25]

Unbound Methods

instance_method returns an UnboundMethod that can be bound to any object of the right class:

class Greeter
  def hello
    "Hello from #{self.class}"
  end
end

unbound = Greeter.instance_method(:hello)
bound   = unbound.bind(Greeter.new)
puts bound.call  # => "Hello from Greeter"

respond_to? and respond_to_missing?

Before calling a method dynamically, check if the object responds to it:

obj = "hello"

if obj.respond_to?(:upcase)
  puts obj.send(:upcase)  # => "HELLO"
end

# Include private methods in the check
obj.respond_to?(:eating)         # => false (private)
obj.respond_to?(:eating, true)   # => true  (include private)

Practical: Dynamic Attribute Mapping

class DataMapper
  def initialize(source, mapping)
    @source  = source
    @mapping = mapping
  end

  def map
    @mapping.each_with_object({}) do |(source_key, target_key), result|
      value = @source.respond_to?(source_key) ?
                @source.send(source_key) :
                @source[source_key]
      result[target_key] = value
    end
  end
end

User = Struct.new(:first_name, :last_name, :email_address)
user = User.new("Alice", "Smith", "[email protected]")

mapper = DataMapper.new(user, {
  first_name:    :name,
  email_address: :email
})

puts mapper.map.inspect
# => {:name=>"Alice", :email=>"[email protected]"}

Practical: Test Helpers

send is commonly used in tests to access private methods:

# RSpec
describe Student do
  let(:student) { Student.new("Lily", 40) }

  it "eating increases energy" do
    expect { student.send(:eating) }
      .to change { student.energy }.by(20)
  end

  it "hungry? triggers eating when energy is low" do
    expect(student).to receive(:eating)
    student.hungry?
  end
end

Security Warning

Never use send with user-supplied method names without validation:

# DANGEROUS: arbitrary method execution
method_name = params[:action]
object.send(method_name)  # attacker could call :destroy, :delete, etc.

# SAFE: allowlist permitted methods
ALLOWED_ACTIONS = %i[show index search].freeze

method_name = params[:action].to_sym
if ALLOWED_ACTIONS.include?(method_name)
  object.public_send(method_name)
else
  raise "Action not permitted"
end

Summary

Method Calls private? Use case
obj.method_name No Normal method call
obj.send(:name) Yes Dynamic dispatch, testing private methods
obj.public_send(:name) No Dynamic dispatch with safety
obj.method(:name) Yes Get method as object, pass as block
respond_to?(:name) No Check if public method exists
respond_to?(:name, true) Yes Check including private methods

Resources

Comments