Overview
Method delegation transfers responsibility for method execution from one object to another. Ruby provides several mechanisms for implementing delegation: the Forwardable
module for selective method forwarding, the Delegator
class for comprehensive delegation patterns, and SimpleDelegator
for straightforward object wrapping.
The Forwardable
module extends classes with delegation capabilities through def_delegator
and def_delegators
methods. These methods create delegation methods at the class level, binding them to specific target objects or instance variables.
require 'forwardable'
class TaskManager
extend Forwardable
def initialize
@tasks = []
end
def_delegator :@tasks, :<<, :add_task
def_delegators :@tasks, :empty?, :size, :first
end
manager = TaskManager.new
manager.add_task("Complete documentation")
puts manager.size # => 1
puts manager.empty? # => false
The Delegator
class serves as a base class for creating delegation objects. Subclasses must implement the __getobj__
method to specify the target object for delegation. This approach provides complete delegation where all undefined methods forward to the target.
require 'delegate'
class LoggedArray < Delegator
def initialize(array)
@array = array
super
end
def __getobj__
@array
end
def <<(value)
puts "Adding: #{value}"
@array << value
end
end
logged = LoggedArray.new([1, 2, 3])
logged << 4 # Adding: 4
puts logged.size # => 4
SimpleDelegator
extends Delegator
with automatic target object management. The constructor accepts the delegation target, eliminating the need to implement __getobj__
.
require 'delegate'
class TimestampedHash < SimpleDelegator
def initialize(hash = {})
@created_at = Time.now
super(hash)
end
def age
Time.now - @created_at
end
end
data = TimestampedHash.new({'name' => 'John'})
data['city'] = 'Portland'
puts data['name'] # => "John"
puts data.keys # => ["name", "city"]
Basic Usage
The Forwardable
module handles selective delegation through explicit method declarations. Each def_delegator
call creates a single delegation method, while def_delegators
creates multiple methods targeting the same object.
require 'forwardable'
class OrderProcessor
extend Forwardable
def initialize
@items = []
@total = 0.0
end
# Single method delegation with renaming
def_delegator :@items, :empty?, :no_items?
def_delegator :@items, :size, :item_count
# Multiple method delegation to same target
def_delegators :@items, :first, :last, :each
def add_item(price)
@items << price
@total += price
end
attr_reader :total
end
processor = OrderProcessor.new
processor.add_item(29.99)
processor.add_item(15.50)
puts processor.item_count # => 2
puts processor.no_items? # => false
puts processor.first # => 29.99
processor.each { |price| puts "Item: $#{price}" }
The delegation target can be any object accessible through instance variables, method calls, or constants. Method renaming occurs when the third parameter differs from the original method name.
require 'forwardable'
class DatabaseConnection
extend Forwardable
def initialize(host, port)
@config = {host: host, port: port, timeout: 30}
@logger = Logger.new(STDOUT)
end
# Delegate to hash methods
def_delegators :@config, :[], :[]=
# Delegate to logger with method renaming
def_delegator :@logger, :info, :log_info
def_delegator :@logger, :error, :log_error
def connect
log_info("Connecting to #{@config[:host]}:#{@config[:port]}")
# Connection logic here
end
end
db = DatabaseConnection.new('localhost', 5432)
db[:timeout] = 60
puts db[:host] # => "localhost"
SimpleDelegator
provides comprehensive delegation where undefined methods automatically forward to the wrapped object. The __getobj__
method returns the current delegation target, while __setobj__
changes it.
require 'delegate'
class ValidatedArray < SimpleDelegator
def initialize(array = [])
@validation_rules = []
super(array)
end
def add_validation(&block)
@validation_rules << block
end
def <<(value)
@validation_rules.each do |rule|
unless rule.call(value)
raise ArgumentError, "Value #{value} failed validation"
end
end
super
end
def valid_items
select { |item| @validation_rules.all? { |rule| rule.call(item) } }
end
end
validated = ValidatedArray.new([1, 2, 3])
validated.add_validation { |x| x > 0 }
validated.add_validation { |x| x < 100 }
validated << 50 # Valid
puts validated.size # => 4
puts validated.last # => 50
# This would raise: validated << -1
Delegation preserves the original object's interface while adding new functionality. The delegated methods maintain their original signatures and return values.
require 'delegate'
class CachedFile < SimpleDelegator
def initialize(filename)
@file = File.open(filename, 'r')
@cache = {}
super(@file)
end
def read_with_cache
@cache[:content] ||= read
end
def stats
{
size: size,
mtime: mtime,
cached: !@cache.empty?
}
end
def close
super
@cache.clear
end
end
# Assuming 'data.txt' exists
cached = CachedFile.new('data.txt')
content = cached.read_with_cache # Reads and caches
same_content = cached.read_with_cache # Returns cached version
puts cached.stats[:cached] # => true
cached.close
Advanced Usage
Multiple delegation levels create chains where objects delegate to other delegating objects. Each level in the chain can modify behavior before forwarding calls.
require 'delegate'
class TimestampedArray < SimpleDelegator
def initialize(array = [])
@timestamps = {}
super(array)
end
def <<(value)
result = super
@timestamps[size - 1] = Time.now
result
end
def timestamp_for(index)
@timestamps[index]
end
end
class LoggedArray < SimpleDelegator
def initialize(delegated_array)
@log = []
super(delegated_array)
end
def <<(value)
@log << "Adding #{value} at #{Time.now}"
super
end
def operations_log
@log
end
end
# Create delegation chain
base = [1, 2, 3]
timestamped = TimestampedArray.new(base)
logged = LoggedArray.new(timestamped)
logged << 4
logged << 5
puts logged.size # => 5 (delegates through chain)
puts logged.operations_log.last # Shows last operation
puts logged.timestamp_for(4) # Shows timestamp for index 4
The Forwardable
module supports dynamic delegation where the target object changes based on runtime conditions. Instance methods can return different objects for delegation.
require 'forwardable'
class MultiStorage
extend Forwardable
def initialize
@memory_store = {}
@file_store = File.open('temp.dat', 'w+')
@use_memory = true
end
def_delegators :current_store, :[], :[]=, :keys, :size
def toggle_storage
@use_memory = !@use_memory
sync_stores if @use_memory
end
private
def current_store
@use_memory ? @memory_store : @file_store
end
def sync_stores
# Synchronization logic between stores
@memory_store.each { |k, v| @file_store.write("#{k}=#{v}\n") }
end
end
storage = MultiStorage.new
storage[:key] = 'value'
puts storage.size # Uses memory store
storage.toggle_storage
storage[:key2] = 'value2' # Now uses file store
Custom delegation classes can override method_missing
to implement sophisticated routing logic. This approach handles method calls that don't exist on the delegation target.
require 'delegate'
class RouterDelegator < SimpleDelegator
def initialize(primary, fallback)
@fallback = fallback
super(primary)
end
def method_missing(method, *args, **kwargs, &block)
if __getobj__.respond_to?(method)
__getobj__.__send__(method, *args, **kwargs, &block)
elsif @fallback.respond_to?(method)
@fallback.__send__(method, *args, **kwargs, &block)
else
super
end
end
def respond_to_missing?(method, include_private = false)
__getobj__.respond_to?(method, include_private) ||
@fallback.respond_to?(method, include_private) ||
super
end
end
class Calculator
def add(a, b); a + b; end
def multiply(a, b); a * b; end
end
class StringUtils
def upcase(str); str.upcase; end
def reverse(str); str.reverse; end
end
router = RouterDelegator.new(Calculator.new, StringUtils.new)
puts router.add(5, 3) # => 8 (delegates to Calculator)
puts router.upcase('hello') # => "HELLO" (delegates to StringUtils)
puts router.multiply(4, 7) # => 28 (delegates to Calculator)
Delegation objects can be composed to create complex behavior trees. Each delegator in the composition can add its own functionality while preserving the delegation chain.
require 'delegate'
class ValidatingDelegator < SimpleDelegator
def initialize(object, validators = {})
@validators = validators
super(object)
end
def method_missing(method, *args, **kwargs, &block)
if @validators.key?(method)
validator = @validators[method]
raise ArgumentError, "Validation failed for #{method}" unless validator.call(*args)
end
super
end
end
class LoggingDelegator < SimpleDelegator
def initialize(object, logger = nil)
@logger = logger || ->(msg) { puts "[LOG] #{msg}" }
super(object)
end
def method_missing(method, *args, **kwargs, &block)
@logger.call("Calling #{method} with #{args}")
result = super
@logger.call("#{method} returned #{result}")
result
end
end
# Compose multiple delegators
array = [1, 2, 3]
validators = {
:push => ->(val) { val.is_a?(Integer) },
:<< => ->(val) { val > 0 }
}
validated = ValidatingDelegator.new(array, validators)
logged = LoggingDelegator.new(validated)
logged.push(4) # Validates then logs
logged << 5 # Validates then logs
# logged << -1 # Would raise validation error
Common Pitfalls
Object identity confusion occurs when delegated objects appear identical to their targets but maintain separate identities. The is_a?
method returns true for the delegator's class, not the target's class.
require 'delegate'
class ArrayWrapper < SimpleDelegator
def initialize(array)
super(array)
end
end
original = [1, 2, 3]
wrapped = ArrayWrapper.new(original)
puts wrapped.size # => 3 (delegated)
puts wrapped == original # => true (delegates to ==)
puts wrapped.equal?(original) # => false (different objects)
puts wrapped.is_a?(Array) # => false (not an Array)
puts wrapped.is_a?(ArrayWrapper) # => true
puts wrapped.__getobj__.is_a?(Array) # => true
Method delegation does not automatically inherit class methods or constants from the target object. Only instance methods receive delegation through method_missing
.
require 'delegate'
class EnhancedString < SimpleDelegator
def initialize(string)
super(string)
end
def word_count
split.size
end
end
enhanced = EnhancedString.new("Hello world")
puts enhanced.upcase # => "HELLO WORLD" (delegated)
puts enhanced.word_count # => 2 (custom method)
# These don't work as expected:
# puts enhanced.class.name # => "EnhancedString" (not "String")
# String::CONSTANTS are not accessible through enhanced
Delegation breaks when methods modify the receiver object directly. Operations that change the object's identity or type may not behave as expected through delegation.
require 'delegate'
class NumberWrapper < SimpleDelegator
def initialize(number)
@original_class = number.class
super(number)
end
def type_changed?
__getobj__.class != @original_class
end
end
wrapped = NumberWrapper.new(42)
puts wrapped + 8 # => 50 (works fine)
# Operations that might change type
result = wrapped / 2.0 # Returns Float, but wrapper still thinks it's Integer
puts result.class # => Float
puts wrapped.type_changed? # => false (wrapper doesn't track this)
# Mutation methods can cause issues
string_wrapped = NumberWrapper.new("hello")
string_wrapped.gsub!(/l/, 'x') # Modifies the target string
puts string_wrapped # => "hexxo" (changed)
The Forwardable
module creates methods at class definition time, not instance creation time. This means delegation targets must be consistent across all instances or specified through methods rather than instance variables.
require 'forwardable'
class ProblematicDelegator
extend Forwardable
def initialize(target)
@target = target
end
# This won't work as expected - @target isn't set when class loads
# def_delegator :@target, :size
# This works because it's evaluated at runtime
def_delegator :target_object, :size
private
def target_object
@target
end
end
# Correct approach for dynamic targets
class FlexibleDelegator
extend Forwardable
def initialize(primary, secondary)
@primary = primary
@secondary = secondary
@use_primary = true
end
def_delegator :current_target, :size, :target_size
def_delegator :current_target, :empty?, :target_empty?
def switch_target
@use_primary = !@use_primary
end
private
def current_target
@use_primary ? @primary : @secondary
end
end
flexible = FlexibleDelegator.new([1, 2, 3], {a: 1, b: 2})
puts flexible.target_size # => 3
flexible.switch_target
puts flexible.target_size # => 2
Circular delegation creates infinite loops when objects delegate to each other or to themselves. Ruby does not automatically detect or prevent these situations.
require 'delegate'
class CircularDelegatorA < SimpleDelegator
def special_method
"From A: #{__getobj__.special_method}"
end
end
class CircularDelegatorB < SimpleDelegator
def special_method
"From B: #{__getobj__.special_method}"
end
end
# This creates a circular reference
# a = CircularDelegatorA.new(b)
# b = CircularDelegatorB.new(a)
# a.special_method # Would cause infinite recursion
# Safe approach - check for circular references
class SafeDelegator < SimpleDelegator
def initialize(target)
raise ArgumentError, "Circular delegation detected" if circular?(target)
super(target)
end
private
def circular?(target)
current = target
while current.is_a?(SimpleDelegator)
return true if current.equal?(self)
current = current.__getobj__
end
false
end
end
Exception handling through delegation can mask the true source of errors. Exceptions raised in delegated methods show the delegation object in stack traces, not the original target.
require 'delegate'
class ErrorTrackingDelegator < SimpleDelegator
def method_missing(method, *args, **kwargs, &block)
super
rescue => e
# Re-raise with additional context
raise e.class, "Error in delegated #{method}: #{e.message}", e.backtrace
end
def respond_to_missing?(method, include_private = false)
super
end
end
problematic_array = [1, 2, 3]
tracking = ErrorTrackingDelegator.new(problematic_array)
begin
tracking.fetch(10) # Array#fetch raises IndexError
rescue => e
puts "Caught: #{e.class}: #{e.message}"
# Shows delegator context rather than just Array error
end
Testing Strategies
Testing delegated objects requires verification of both delegation behavior and added functionality. Mock objects can stub delegation targets while preserving the delegation pattern.
require 'delegate'
require 'rspec'
class CachingDelegator < SimpleDelegator
def initialize(target)
@cache = {}
super(target)
end
def cached_call(method, *args)
key = [method, args].hash
@cache[key] ||= __getobj__.__send__(method, *args)
end
def cache_size
@cache.size
end
end
RSpec.describe CachingDelegator do
let(:target) { double('target') }
let(:delegator) { CachingDelegator.new(target) }
describe '#cached_call' do
it 'caches method results' do
expect(target).to receive(:expensive_operation).once.and_return('result')
result1 = delegator.cached_call(:expensive_operation)
result2 = delegator.cached_call(:expensive_operation)
expect(result1).to eq('result')
expect(result2).to eq('result')
expect(delegator.cache_size).to eq(1)
end
it 'handles different arguments separately' do
expect(target).to receive(:method_with_args).with(1).and_return('one')
expect(target).to receive(:method_with_args).with(2).and_return('two')
delegator.cached_call(:method_with_args, 1)
delegator.cached_call(:method_with_args, 2)
expect(delegator.cache_size).to eq(2)
end
end
describe 'delegation' do
it 'forwards unknown methods to target' do
expect(target).to receive(:some_method).with('arg').and_return('delegated')
result = delegator.some_method('arg')
expect(result).to eq('delegated')
end
it 'preserves method signatures' do
expect(target).to receive(:complex_method).with(1, 2, key: 'value').and_return('complex')
result = delegator.complex_method(1, 2, key: 'value')
expect(result).to eq('complex')
end
end
end
Integration tests verify that delegation works correctly with real objects rather than mocks. These tests catch issues that unit tests with doubles might miss.
require 'forwardable'
class FileLogger
extend Forwardable
def initialize(filename)
@file = File.open(filename, 'a')
end
def_delegators :@file, :write, :flush, :close
def log(level, message)
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
write("[#{timestamp}] #{level.upcase}: #{message}\n")
flush
end
end
RSpec.describe FileLogger do
let(:temp_file) { 'test_log.txt' }
let(:logger) { FileLogger.new(temp_file) }
after do
logger.close if logger
File.delete(temp_file) if File.exist?(temp_file)
end
describe '#log' do
it 'writes formatted messages to file' do
logger.log('info', 'Test message')
logger.close
content = File.read(temp_file)
expect(content).to match(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] INFO: Test message/)
end
it 'handles multiple log levels' do
logger.log('debug', 'Debug message')
logger.log('error', 'Error message')
logger.close
lines = File.readlines(temp_file)
expect(lines).to have(2).items
expect(lines[0]).to include('DEBUG: Debug message')
expect(lines[1]).to include('ERROR: Error message')
end
end
describe 'file delegation' do
it 'allows direct file operations' do
logger.write("Direct write\n")
logger.flush
logger.close
content = File.read(temp_file)
expect(content).to include('Direct write')
end
end
end
Testing edge cases requires scenarios that stress the delegation mechanism, including error conditions and boundary cases.
require 'delegate'
class ValidatedDelegator < SimpleDelegator
def initialize(target, validator)
@validator = validator
super(target)
end
def method_missing(method, *args, **kwargs, &block)
if modifying_method?(method)
raise ArgumentError, "Invalid arguments" unless @validator.call(method, *args)
end
super
end
private
def modifying_method?(method)
method.to_s.end_with?('!', '=') || [:<<, :push, :pop, :shift, :unshift].include?(method)
end
end
RSpec.describe ValidatedDelegator do
let(:array) { [] }
let(:validator) { ->(method, *args) { args.all? { |arg| arg.is_a?(String) } } }
let(:delegator) { ValidatedDelegator.new(array, validator) }
describe 'validation on modifying methods' do
it 'allows valid modifications' do
expect { delegator << 'valid' }.not_to raise_error
expect { delegator.push('also valid') }.not_to raise_error
expect(delegator.size).to eq(2)
end
it 'rejects invalid modifications' do
expect { delegator << 42 }.to raise_error(ArgumentError)
expect { delegator.push('valid', 42) }.to raise_error(ArgumentError)
expect(delegator.size).to eq(0)
end
it 'allows non-modifying methods without validation' do
delegator << 'test'
expect { delegator.first }.not_to raise_error
expect { delegator.include?('test') }.not_to raise_error
end
end
describe 'error propagation' do
it 'preserves original errors from target' do
expect { delegator.fetch(999) }.to raise_error(IndexError)
end
it 'handles method_missing chain correctly' do
expect { delegator.nonexistent_method }.to raise_error(NoMethodError)
end
end
end
Reference
Forwardable Module Methods
Method | Parameters | Returns | Description |
---|---|---|---|
def_delegator(accessor, method, alias_name=method) |
accessor (Symbol/String), method (Symbol/String), alias_name (Symbol/String) |
Symbol |
Creates delegation method for single target method |
def_delegators(accessor, *methods) |
accessor (Symbol/String), methods (Array of Symbol/String) |
Array |
Creates delegation methods for multiple target methods |
delegate(hash) |
hash (Hash) |
Array |
Alternative syntax for method delegation |
def_instance_delegator(accessor, method, alias_name=method) |
Same as def_delegator |
Symbol |
Alias for def_delegator |
def_instance_delegators(accessor, *methods) |
Same as def_delegators |
Array |
Alias for def_delegators |
Delegator Class Methods
Method | Parameters | Returns | Description |
---|---|---|---|
new(obj) |
obj (Object) |
Delegator |
Creates new delegator instance |
__getobj__ |
None | Object |
Returns current delegation target (abstract) |
__setobj__(obj) |
obj (Object) |
Object |
Sets new delegation target |
marshal_dump |
None | Object |
Returns object for marshaling |
marshal_load(obj) |
obj (Object) |
Object |
Restores object from marshaling |
SimpleDelegator Class Methods
Method | Parameters | Returns | Description |
---|---|---|---|
new(obj) |
obj (Object) |
SimpleDelegator |
Creates delegator wrapping target object |
__getobj__ |
None | Object |
Returns wrapped object |
__setobj__(obj) |
obj (Object) |
Object |
Changes wrapped object |
DelegateClass Function
Method | Parameters | Returns | Description |
---|---|---|---|
DelegateClass(superclass) |
superclass (Class) |
Class |
Creates delegator class inheriting from superclass |
Common Delegation Patterns
Pattern | Implementation | Use Case |
---|---|---|
Selective Delegation | Forwardable with def_delegators |
Forward specific methods only |
Complete Delegation | SimpleDelegator |
Forward all undefined methods |
Conditional Delegation | Custom method_missing |
Route methods based on conditions |
Chain Delegation | Nested delegators | Multiple layers of delegation |
Validated Delegation | Override in delegator subclass | Add validation to delegated methods |
Error Classes
Class | Parent | Description |
---|---|---|
ArgumentError |
StandardError |
Raised for invalid delegation arguments |
NoMethodError |
NameError |
Raised when delegated method doesn't exist |
SystemStackError |
Exception |
Raised for circular delegation |
Module Requirements
Module/Class | Library | Purpose |
---|---|---|
Forwardable |
require 'forwardable' |
Selective method delegation |
Delegator |
require 'delegate' |
Base delegation class |
SimpleDelegator |
require 'delegate' |
Simple object wrapping |
DelegateClass |
require 'delegate' |
Dynamic delegation class creation |
Performance Characteristics
Operation | Delegator Type | Overhead | Notes |
---|---|---|---|
Method Call | Forwardable |
Low | Direct method dispatch |
Method Call | SimpleDelegator |
Medium | method_missing dispatch |
Object Creation | Forwardable |
Low | No wrapper object |
Object Creation | SimpleDelegator |
Medium | Creates wrapper instance |
Memory Usage | Forwardable |
Low | No additional object storage |
Memory Usage | SimpleDelegator |
Medium | Stores reference to target |