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
- Ruby Docs: Object#send
- Ruby Docs: Object#public_send
- Ruby Docs: Method
- Metaprogramming Ruby โ Paolo Perrotta
Comments