CrackedRuby logo

CrackedRuby

Method Delegation

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