CrackedRuby logo

CrackedRuby

Duck Typing

Overview

Duck typing operates on the principle that an object's suitability for a particular operation depends on the presence of specific methods rather than its class. Ruby implements duck typing through dynamic method dispatch, allowing any object that responds to the required methods to fulfill a contract.

The core mechanism relies on Ruby's respond_to? method and dynamic method calling through send or public_send. Objects can implement identical interfaces without sharing inheritance hierarchies, creating flexible and decoupled designs.

class Duck
  def quack
    "Quack!"
  end
  
  def swim
    "Swimming like a duck"
  end
end

class Person
  def quack
    "I'm pretending to be a duck!"
  end
  
  def swim
    "Swimming like a person"
  end
end

def make_it_quack_and_swim(duck_like_thing)
  puts duck_like_thing.quack
  puts duck_like_thing.swim
end

make_it_quack_and_swim(Duck.new)
# => Quack!
# => Swimming like a duck

make_it_quack_and_swim(Person.new)
# => I'm pretending to be a duck!
# => Swimming like a person

Ruby's standard library extensively uses duck typing. The IO class and its subclasses demonstrate this through consistent method interfaces. File objects, socket objects, and string IO objects all respond to methods like read, write, and close, allowing them to be used interchangeably in contexts expecting IO-like behavior.

require 'stringio'

def process_input(io_like)
  content = io_like.read
  io_like.rewind if io_like.respond_to?(:rewind)
  content.upcase
end

# Works with File
file = File.open('example.txt', 'r')
process_input(file)

# Works with StringIO
string_io = StringIO.new("hello world")
process_input(string_io)

# Works with any object implementing read
class CustomReader
  def initialize(data)
    @data = data
  end
  
  def read
    @data
  end
end

process_input(CustomReader.new("custom data"))

Duck typing eliminates the need for explicit interface declarations or complex inheritance hierarchies. Methods can accept any object that implements the required behavior, promoting composition over inheritance and reducing coupling between components.

Basic Usage

Duck typing implementations typically start with method calls that assume certain methods exist on the passed objects. Ruby raises NoMethodError when an object doesn't respond to a called method, making missing implementations immediately apparent.

class EmailNotifier
  def notify(message)
    # Send email
    puts "Email sent: #{message}"
  end
end

class SlackNotifier
  def notify(message)
    # Send to Slack
    puts "Slack message: #{message}"
  end
end

class NotificationService
  def initialize(notifier)
    @notifier = notifier
  end
  
  def alert(message)
    @notifier.notify(message)
  end
end

email_service = NotificationService.new(EmailNotifier.new)
slack_service = NotificationService.new(SlackNotifier.new)

email_service.alert("System error occurred")
# => Email sent: System error occurred

slack_service.alert("Deployment complete")
# => Slack message: Deployment complete

The respond_to? method provides runtime introspection to check method availability before calling. This approach prevents exceptions while maintaining duck typing flexibility.

class DataProcessor
  def process(source)
    if source.respond_to?(:each)
      process_enumerable(source)
    elsif source.respond_to?(:read)
      process_io(source)
    elsif source.respond_to?(:to_s)
      process_string(source.to_s)
    else
      raise ArgumentError, "Source must respond to :each, :read, or :to_s"
    end
  end
  
  private
  
  def process_enumerable(source)
    source.each { |item| puts "Processing: #{item}" }
  end
  
  def process_io(source)
    puts "Reading: #{source.read}"
  end
  
  def process_string(source)
    puts "String: #{source}"
  end
end

processor = DataProcessor.new

# Works with arrays
processor.process([1, 2, 3])
# => Processing: 1
# => Processing: 2
# => Processing: 3

# Works with IO objects
processor.process(StringIO.new("test data"))
# => Reading: test data

# Works with any object implementing to_s
processor.process(42)
# => String: 42

Duck typing works particularly well with Ruby's blocks and iterators. Methods can yield to blocks while accepting any enumerable-like object, creating highly reusable components.

class BatchProcessor
  def process_in_batches(collection, batch_size = 10)
    return enum_for(__method__, collection, batch_size) unless block_given?
    
    batch = []
    collection.each do |item|
      batch << item
      if batch.size >= batch_size
        yield batch
        batch = []
      end
    end
    yield batch unless batch.empty?
  end
end

processor = BatchProcessor.new

# Works with arrays
processor.process_in_batches([1,2,3,4,5,6,7,8,9,10,11]) do |batch|
  puts "Batch: #{batch}"
end

# Works with ranges
processor.process_in_batches(1..15, 4) do |batch|
  puts "Range batch: #{batch}"
end

# Works with custom enumerables
class CountDown
  def initialize(start)
    @start = start
  end
  
  def each
    @start.downto(1) { |n| yield n }
  end
end

processor.process_in_batches(CountDown.new(7), 3) do |batch|
  puts "Countdown batch: #{batch}"
end

Advanced Usage

Advanced duck typing involves creating objects that implement multiple interfaces and respond dynamically to method calls. Ruby's metaprogramming capabilities enable sophisticated duck typing patterns through method delegation, forwarding, and dynamic method definition.

Method delegation allows objects to forward specific method calls to composed objects, creating transparent proxies that maintain duck typing contracts while adding functionality.

require 'forwardable'

class LoggingProxy
  extend Forwardable
  
  def_delegators :@target, :read, :write, :close, :rewind
  
  def initialize(target, logger)
    @target = target
    @logger = logger
  end
  
  def method_missing(method_name, *args, &block)
    if @target.respond_to?(method_name)
      @logger.info("Calling #{method_name} on #{@target.class}")
      result = @target.public_send(method_name, *args, &block)
      @logger.info("#{method_name} returned #{result.class}")
      result
    else
      super
    end
  end
  
  def respond_to_missing?(method_name, include_private = false)
    @target.respond_to?(method_name, include_private) || super
  end
end

class SimpleLogger
  def info(message)
    puts "[LOG] #{message}"
  end
end

# Create a logging proxy around StringIO
io = StringIO.new("initial content")
logged_io = LoggingProxy.new(io, SimpleLogger.new)

logged_io.write(" additional content")
# => [LOG] Calling write on StringIO
# => [LOG] write returned StringIO

logged_io.rewind
# => [LOG] Calling rewind on StringIO
# => [LOG] rewind returned Integer

puts logged_io.read
# => [LOG] Calling read on StringIO
# => [LOG] read returned String
# => initial content additional content

Dynamic method definition creates objects that implement interfaces at runtime, enabling adaptive behavior based on configuration or external data sources.

class AdaptiveProcessor
  def initialize(processors = {})
    @processors = processors
    define_processor_methods
  end
  
  def add_processor(name, processor)
    @processors[name] = processor
    define_method(name) do |data|
      processor.call(data)
    end
  end
  
  def supports?(operation)
    respond_to?(operation)
  end
  
  private
  
  def define_processor_methods
    @processors.each do |name, processor|
      define_singleton_method(name) do |data|
        processor.call(data)
      end
    end
  end
end

# Configure processors
text_processor = AdaptiveProcessor.new({
  upcase: ->(text) { text.upcase },
  reverse: ->(text) { text.reverse },
  length: ->(text) { text.length }
})

puts text_processor.upcase("hello")  # => HELLO
puts text_processor.reverse("hello") # => olleh
puts text_processor.length("hello")  # => 5

# Add new processor at runtime
text_processor.add_processor(:vowel_count, ->(text) { 
  text.count('aeiouAEIOU') 
})

puts text_processor.vowel_count("hello world") # => 3
puts text_processor.supports?(:vowel_count)    # => true
puts text_processor.supports?(:unknown)        # => false

Complex duck typing scenarios involve objects that adapt their behavior based on the methods available on their collaborators, creating self-configuring systems.

class FlexibleSerializer
  def initialize(adapters: {})
    @adapters = adapters
    @adapters[:default] ||= method(:to_string)
  end
  
  def serialize(object)
    adapter = find_adapter(object)
    adapter.call(object)
  end
  
  def register_adapter(adapter, &condition)
    @adapters[adapter] = condition
  end
  
  private
  
  def find_adapter(object)
    @adapters.each do |adapter, condition|
      next if adapter == :default
      return method(adapter) if condition.call(object)
    end
    @adapters[:default]
  end
  
  def to_json(object)
    if object.respond_to?(:to_json)
      object.to_json
    elsif object.respond_to?(:to_h)
      object.to_h.to_json
    else
      { value: object.to_s }.to_json
    end
  end
  
  def to_xml(object)
    if object.respond_to?(:to_xml)
      object.to_xml
    elsif object.respond_to?(:to_h)
      hash_to_xml(object.to_h)
    else
      "<value>#{object}</value>"
    end
  end
  
  def to_string(object)
    object.respond_to?(:to_s) ? object.to_s : object.inspect
  end
  
  def hash_to_xml(hash, root = 'object')
    "<#{root}>" + 
    hash.map { |k,v| "<#{k}>#{v}</#{k}>" }.join +
    "</#{root}>"
  end
end

serializer = FlexibleSerializer.new

# Register type-specific adapters
serializer.register_adapter(:to_json) { |obj| obj.is_a?(Hash) || obj.is_a?(Array) }
serializer.register_adapter(:to_xml) { |obj| obj.respond_to?(:to_xml) }

# Test with different objects
puts serializer.serialize({ name: "John", age: 30 })
# => {"name":"John","age":30}

puts serializer.serialize([1, 2, 3])
# => [1,2,3]

puts serializer.serialize("simple string")
# => simple string

# Custom object with to_xml method
class Person
  def initialize(name, age)
    @name, @age = name, age
  end
  
  def to_xml
    "<person><name>#{@name}</name><age>#{@age}</age></person>"
  end
end

puts serializer.serialize(Person.new("Jane", 25))
# => <person><name>Jane</name><age>25</age></person>

Common Pitfalls

Duck typing introduces subtle issues related to method signature mismatches, implicit expectations, and error handling. Objects may implement methods with the same name but different parameter expectations, leading to runtime failures that are difficult to debug.

class FileWriter
  def write(content)
    File.write("output.txt", content)
    puts "Written to file: #{content}"
  end
end

class LogWriter  
  def write(level, message)
    puts "[#{level.upcase}] #{message}"
  end
end

class DatabaseWriter
  def write(table, data)
    puts "INSERT INTO #{table}: #{data}"
  end
end

def save_content(writer, content)
  writer.write(content)  # This will fail for LogWriter and DatabaseWriter
end

# This works
save_content(FileWriter.new, "Hello World")

# These fail with wrong number of arguments
begin
  save_content(LogWriter.new, "Hello World")
rescue ArgumentError => e
  puts "Error: #{e.message}"
end
# => Error: wrong number of arguments (given 1, expected 2)

To handle signature mismatches, examine method arity or use parameter validation before making calls:

class SafeWriter
  def initialize(writers)
    @writers = writers.select { |w| valid_writer?(w) }
  end
  
  def write_to_all(content)
    @writers.each do |writer|
      case writer.method(:write).arity
      when 1
        writer.write(content)
      when 2
        writer.write("info", content) if writer.is_a?(LogWriter)
        writer.write("logs", content) if writer.is_a?(DatabaseWriter)
      else
        puts "Unsupported writer: #{writer.class}"
      end
    end
  end
  
  private
  
  def valid_writer?(writer)
    writer.respond_to?(:write) && writer.method(:write).arity.between?(1, 2)
  end
end

Implicit method expectations create another common pitfall. Methods may expect duck-typed objects to behave in specific ways beyond just responding to certain method names.

class BrokenCounter
  def initialize
    @count = 0
  end
  
  def each
    # This breaks the Enumerable contract by not yielding values
    @count += 1
    puts "Each called #{@count} times"
  end
end

class ProperCounter
  def initialize(limit)
    @limit = limit
  end
  
  def each
    return enum_for(__method__) unless block_given?
    (1..@limit).each { |n| yield n }
  end
end

def sum_items(enumerable)
  total = 0
  enumerable.each { |item| total += item }
  total
end

# This fails because BrokenCounter doesn't yield values
begin
  puts sum_items(BrokenCounter.new)
rescue LocalJumpError => e
  puts "Error: #{e.message}"
end
# => Error: no block given (yield)

# This works correctly
puts sum_items(ProperCounter.new(5))
# => 15

Method return value assumptions cause failures when duck-typed objects return unexpected types. Always validate return values when the behavior depends on specific return types.

class StringReturner
  def process
    "processed data"
  end
end

class ArrayReturner  
  def process
    ["processed", "data"]
  end
end

class HashReturner
  def process
    { result: "processed data" }
  end
end

def handle_result(processor)
  result = processor.process
  # Assuming result is always a string
  result.upcase  # This will fail for ArrayReturner and HashReturner
end

# Safe version with type checking
def safe_handle_result(processor)
  result = processor.process
  case result
  when String
    result.upcase
  when Array
    result.map(&:to_s).join(" ").upcase
  when Hash
    result.values.first.to_s.upcase
  else
    result.to_s.upcase
  end
end

puts safe_handle_result(StringReturner.new)  # => PROCESSED DATA
puts safe_handle_result(ArrayReturner.new)   # => PROCESSED DATA  
puts safe_handle_result(HashReturner.new)    # => PROCESSED DATA

State mutation issues arise when duck-typed objects share method names but have different side effects or state management approaches.

class MutableBuffer
  def initialize
    @buffer = []
  end
  
  def add(item)
    @buffer << item
    self  # Returns self for chaining
  end
  
  def to_s
    @buffer.join(", ")
  end
end

class ImmutableBuffer
  def initialize(items = [])
    @items = items.freeze
  end
  
  def add(item)
    ImmutableBuffer.new(@items + [item])  # Returns new instance
  end
  
  def to_s
    @items.join(", ")
  end
end

def build_list(buffer_class)
  buffer = buffer_class.new
  buffer = buffer.add("first")
  buffer = buffer.add("second")  
  buffer = buffer.add("third")
  buffer
end

mutable_result = build_list(MutableBuffer)
puts mutable_result.to_s  # => first, second, third

immutable_result = build_list(ImmutableBuffer)  
puts immutable_result.to_s  # => first, second, third

# Problems arise when assuming mutability
buffer = MutableBuffer.new
buffer.add("item1")
buffer.add("item2") 
puts buffer.to_s  # => item1, item2

buffer = ImmutableBuffer.new
buffer.add("item1")  # This creates a new object, but we don't capture it
buffer.add("item2")  # This also creates a new object from the original empty buffer
puts buffer.to_s     # => (empty string - original buffer unchanged)

Production Patterns

Duck typing in production environments requires robust error handling, performance considerations, and integration with frameworks. Web applications commonly use duck typing for request processing, data formatting, and service integration.

Rails controllers demonstrate production duck typing through action filtering and parameter handling:

class ApiController < ApplicationController
  before_action :authenticate_user!
  before_action :set_format
  
  def process_request(handler)
    # Handler can be any object responding to :call with request context
    if handler.respond_to?(:validate_params)
      return render_error(handler.validate_params(params)) unless handler.validate_params(params).empty?
    end
    
    result = handler.call(request_context)
    
    if result.respond_to?(:success?) && result.success?
      render_success(result)
    elsif result.respond_to?(:errors)
      render_error(result.errors)
    else
      render json: result
    end
  end
  
  private
  
  def request_context
    {
      user: current_user,
      params: params,
      headers: request.headers
    }
  end
  
  def render_success(result)
    data = result.respond_to?(:to_hash) ? result.to_hash : result
    render json: { success: true, data: data }
  end
  
  def render_error(errors)
    render json: { success: false, errors: errors }, status: :unprocessable_entity
  end
end

# Different handler implementations
class UserCreationHandler
  def validate_params(params)
    errors = []
    errors << "Name required" unless params[:name].present?
    errors << "Email required" unless params[:email].present?
    errors
  end
  
  def call(context)
    user = User.create(context[:params].permit(:name, :email))
    OpenStruct.new(success?: user.persisted?, to_hash: user.attributes)
  end
end

class DataExportHandler
  def call(context)
    data = User.where(created_by: context[:user]).map(&:attributes)
    { export: data, count: data.length }
  end
end

Service objects in production use duck typing for flexible dependency injection and testing:

class OrderProcessingService
  def initialize(payment_gateway:, inventory_service:, notification_service:)
    @payment_gateway = payment_gateway
    @inventory_service = inventory_service  
    @notification_service = notification_service
  end
  
  def process_order(order)
    return failure("Invalid order") unless valid_order?(order)
    
    # Check inventory using duck typing
    availability = @inventory_service.check_availability(order.items)
    return failure("Items not available") unless availability.respond_to?(:available?) && availability.available?
    
    # Process payment - different gateways have different interfaces
    payment_result = process_payment(order)
    return failure("Payment failed: #{payment_result.error}") unless payment_result.success?
    
    # Reserve inventory
    reservation = @inventory_service.reserve_items(order.items)
    
    # Send notifications - flexible notification backends
    send_notifications(order, payment_result)
    
    success(order_id: order.id, payment_id: payment_result.id)
  end
  
  private
  
  def process_payment(order)
    if @payment_gateway.respond_to?(:charge_with_token)
      @payment_gateway.charge_with_token(order.payment_token, order.total_amount)
    elsif @payment_gateway.respond_to?(:process_payment)
      @payment_gateway.process_payment(
        amount: order.total_amount,
        source: order.payment_source
      )
    else
      @payment_gateway.charge(order.total_amount)
    end
  end
  
  def send_notifications(order, payment_result)
    message = "Order #{order.id} processed successfully"
    
    if @notification_service.respond_to?(:send_multiple)
      @notification_service.send_multiple([
        { type: :email, recipient: order.user.email, message: message },
        { type: :sms, recipient: order.user.phone, message: message }
      ])
    else
      @notification_service.notify(order.user, message)
    end
  end
  
  def valid_order?(order)
    order.respond_to?(:items) && 
    order.respond_to?(:total_amount) && 
    order.respond_to?(:user) &&
    order.items.any?
  end
  
  def success(data)
    OpenStruct.new(success?: true, data: data)
  end
  
  def failure(message)
    OpenStruct.new(success?: false, error: message)
  end
end

# Production usage with different service implementations
class StripePaymentGateway
  def charge_with_token(token, amount)
    # Stripe-specific implementation
    result = Stripe::Charge.create(amount: amount, source: token)
    OpenStruct.new(success?: true, id: result.id)
  rescue Stripe::CardError => e
    OpenStruct.new(success?: false, error: e.message)
  end
end

class PaypalPaymentGateway
  def process_payment(amount:, source:)
    # PayPal-specific implementation  
    response = paypal_client.payment.create({
      amount: { currency: 'USD', total: amount },
      payment_method: source
    })
    OpenStruct.new(success?: response.state == 'approved', id: response.id)
  end
end

class RedisInventoryService
  def check_availability(items)
    available = items.all? { |item| redis.get("inventory:#{item.id}").to_i >= item.quantity }
    OpenStruct.new(available?: available)
  end
  
  def reserve_items(items)
    items.each { |item| redis.decrby("inventory:#{item.id}", item.quantity) }
    true
  end
  
  private
  
  def redis
    @redis ||= Redis.current
  end
end

class CompositeNotificationService
  def initialize(services)
    @services = services
  end
  
  def send_multiple(notifications)
    notifications.each do |notification|
      service = @services[notification[:type]]
      next unless service
      
      if service.respond_to?(:send_notification)
        service.send_notification(notification[:recipient], notification[:message])
      else
        service.notify(notification[:recipient], notification[:message])
      end
    end
  end
end

Background job processing uses duck typing to support multiple queue backends and job formats:

class FlexibleJobProcessor
  def initialize(queue_adapter:, job_serializer: JsonJobSerializer.new)
    @queue_adapter = queue_adapter
    @job_serializer = job_serializer
  end
  
  def enqueue_job(job_class, *args, **options)
    job_data = @job_serializer.serialize(job_class, args, options)
    
    if @queue_adapter.respond_to?(:enqueue_with_delay)
      delay = options.delete(:delay) || 0
      @queue_adapter.enqueue_with_delay(job_data, delay)
    elsif @queue_adapter.respond_to?(:schedule)
      run_at = options.delete(:run_at) || Time.current
      @queue_adapter.schedule(job_data, run_at)
    else
      @queue_adapter.enqueue(job_data)
    end
  end
  
  def process_jobs(limit = 10)
    jobs = fetch_jobs(limit)
    jobs.each { |job| process_single_job(job) }
  end
  
  private
  
  def fetch_jobs(limit)
    if @queue_adapter.respond_to?(:dequeue_batch)
      @queue_adapter.dequeue_batch(limit)
    else
      limit.times.map { @queue_adapter.dequeue }.compact
    end
  end
  
  def process_single_job(job_data)
    job_class, args, options = @job_serializer.deserialize(job_data)
    
    # Different job classes may have different interfaces
    if job_class.respond_to?(:perform_with_context)
      job_class.perform_with_context(create_job_context, *args)
    elsif job_class.respond_to?(:perform_async)
      job_class.perform_async(*args)
    else
      job_class.new.perform(*args)
    end
  rescue StandardError => e
    handle_job_failure(job_data, e)
  end
  
  def create_job_context
    {
      queue: @queue_adapter.class.name,
      processed_at: Time.current,
      worker_id: Process.pid
    }
  end
  
  def handle_job_failure(job_data, error)
    if @queue_adapter.respond_to?(:handle_failure)
      @queue_adapter.handle_failure(job_data, error)
    else
      puts "Job failed: #{error.message}"
    end
  end
end

class JsonJobSerializer
  def serialize(job_class, args, options)
    {
      class: job_class.name,
      args: args,
      options: options
    }.to_json
  end
  
  def deserialize(job_data)
    data = JSON.parse(job_data)
    [
      Object.const_get(data['class']),
      data['args'],
      data['options']
    ]
  end
end

class RedisQueueAdapter
  def initialize(redis = Redis.current)
    @redis = redis
  end
  
  def enqueue(job_data)
    @redis.lpush('job_queue', job_data)
  end
  
  def dequeue
    @redis.brpop('job_queue', timeout: 1)&.last
  end
  
  def dequeue_batch(limit)
    jobs = []
    limit.times do
      job = @redis.rpop('job_queue')
      break unless job
      jobs << job
    end
    jobs
  end
  
  def handle_failure(job_data, error)
    @redis.lpush('failed_jobs', {
      job: job_data,
      error: error.message,
      failed_at: Time.current.to_f
    }.to_json)
  end
end

Testing Strategies

Testing duck typing requires strategies for verifying interface contracts, mocking collaborating objects, and ensuring consistent behavior across different implementations. Test doubles must accurately implement the expected duck type interfaces.

Contract testing ensures that all implementations of a duck type maintain consistent behavior:

RSpec.shared_examples "a notifier" do
  it "responds to notify method" do
    expect(subject).to respond_to(:notify)
  end
  
  it "accepts message parameter" do
    expect { subject.notify("test message") }.not_to raise_error
  end
  
  it "returns truthy value on success" do
    result = subject.notify("test message")
    expect(result).to be_truthy
  end
  
  it "handles empty messages" do
    expect { subject.notify("") }.not_to raise_error
  end
  
  it "handles long messages" do
    long_message = "x" * 1000
    expect { subject.notify(long_message) }.not_to raise_error
  end
end

# Apply shared examples to different implementations
RSpec.describe EmailNotifier do
  subject { EmailNotifier.new(smtp_settings: test_smtp_config) }
  it_behaves_like "a notifier"
  
  # Implementation-specific tests
  it "sends emails to configured recipients" do
    expect(Mail::TestMailer.deliveries).to be_empty
    subject.notify("test message")
    expect(Mail::TestMailer.deliveries.size).to eq(1)
    expect(Mail::TestMailer.deliveries.last.subject).to include("test message")
  end
end

RSpec.describe SlackNotifier do
  subject { SlackNotifier.new(webhook_url: "https://hooks.slack.com/test") }
  it_behaves_like "a notifier"
  
  # Implementation-specific tests
  it "posts to Slack webhook" do
    stub_request(:post, "https://hooks.slack.com/test")
      .with(body: hash_including(text: "test message"))
      .to_return(status: 200, body: "ok")
    
    result = subject.notify("test message")
    expect(result).to be_truthy
  end
end

RSpec.describe PushNotifier do
  subject { PushNotifier.new(api_key: "test_key") }
  it_behaves_like "a notifier"
end

Mock objects for duck typing must implement the full interface contract expected by the code under test:

class DocumentProcessorTest < Minitest::Test
  def setup
    @mock_storage = MockStorage.new
    @mock_formatter = MockFormatter.new
    @processor = DocumentProcessor.new(
      storage: @mock_storage,
      formatter: @mock_formatter
    )
  end
  
  def test_processes_document_with_valid_input
    @mock_formatter.expect(:format, "formatted content", ["raw content"])
    @mock_storage.expect(:store, "stored_id", ["formatted content", "document.txt"])
    
    result = @processor.process("raw content", "document.txt")
    
    assert_equal "stored_id", result
    @mock_formatter.verify
    @mock_storage.verify
  end
  
  def test_handles_formatter_that_returns_array
    array_formatter = MockArrayFormatter.new
    array_formatter.expect(:format, ["part1", "part2"], ["content"])
    
    @processor = DocumentProcessor.new(
      storage: @mock_storage,
      formatter: array_formatter
    )
    
    # Storage should receive concatenated content
    @mock_storage.expect(:store, "stored_id", ["part1part2", "document.txt"])
    
    result = @processor.process("content", "document.txt")
    assert_equal "stored_id", result
  end
  
  def test_handles_storage_with_metadata_support
    metadata_storage = MockMetadataStorage.new
    @mock_formatter.expect(:format, "formatted", ["content"])
    
    metadata_storage.expect(:store_with_metadata, "stored_id", [
      "formatted",
      "doc.txt", 
      { processed_at: Time, original_size: 7 }
    ])
    
    @processor = DocumentProcessor.new(
      storage: metadata_storage,
      formatter: @mock_formatter
    )
    
    result = @processor.process("content", "doc.txt")
    assert_equal "stored_id", result
  end
end

class MockStorage
  def initialize
    @expectations = []
  end
  
  def expect(method_name, return_value, args)
    @expectations << { method: method_name, return_value: return_value, args: args }
  end
  
  def store(content, filename)
    expectation = @expectations.shift
    unless expectation && expectation[:method] == :store
      raise "Unexpected call to store"
    end
    
    unless expectation[:args] == [content, filename]
      raise "Wrong arguments to store: expected #{expectation[:args]}, got #{[content, filename]}"
    end
    
    expectation[:return_value]
  end
  
  def verify
    raise "Unfulfilled expectations: #{@expectations}" unless @expectations.empty?
  end
end

class MockMetadataStorage < MockStorage
  def store_with_metadata(content, filename, metadata)
    expectation = @expectations.shift
    unless expectation && expectation[:method] == :store_with_metadata
      raise "Unexpected call to store_with_metadata"
    end
    
    expected_content, expected_filename, expected_metadata = expectation[:args]
    unless content == expected_content && filename == expected_filename
      raise "Wrong arguments to store_with_metadata"
    end
    
    # Verify metadata structure but allow time flexibility
    expected_metadata.each do |key, expected_type|
      unless metadata.key?(key) && metadata[key].is_a?(expected_type)
        raise "Missing or wrong type for metadata key #{key}"
      end
    end
    
    expectation[:return_value]
  end
end

Integration testing with duck typing involves testing the complete interaction between different implementations:

RSpec.describe "End-to-end data pipeline" do
  let(:csv_source) { build_csv_source }
  let(:json_source) { build_json_source }
  let(:xml_source) { build_xml_source }
  
  let(:memory_sink) { MemoryDataSink.new }
  let(:file_sink) { FileDataSink.new(temp_dir) }
  
  let(:pipeline) { DataPipeline.new }
  
  context "with different source and sink combinations" do
    it "processes CSV to memory" do
      pipeline.connect(source: csv_source, sink: memory_sink)
      pipeline.run
      
      expect(memory_sink.data).to have(3).items
      expect(memory_sink.data.first).to include(name: "John", age: "30")
    end
    
    it "processes JSON to file" do
      pipeline.connect(source: json_source, sink: file_sink)
      pipeline.run
      
      output_file = File.join(temp_dir, "output.txt")
      expect(File.exist?(output_file)).to be true
      
      content = File.read(output_file)
      expect(content).to include("Jane")
      expect(content).to include("25")
    end
    
    it "handles mixed data types consistently" do
      sources = [csv_source, json_source, xml_source]
      
      sources.each do |source|
        memory_sink.reset
        pipeline.connect(source: source, sink: memory_sink)
        pipeline.run
        
        # All sources should produce comparable output structure
        expect(memory_sink.data).to be_an(Array)
        expect(memory_sink.data.first).to respond_to(:[])
        expect(memory_sink.data.first[:name]).to be_a(String)
      end
    end
  end
  
  context "error handling across implementations" do
    it "handles source read failures gracefully" do
      error_source = ErrorSource.new("Read failed")
      pipeline.connect(source: error_source, sink: memory_sink)
      
      expect { pipeline.run }.to raise_error(DataPipeline::SourceError)
      expect(memory_sink.data).to be_empty
    end
    
    it "handles sink write failures gracefully" do
      error_sink = ErrorSink.new("Write failed")
      pipeline.connect(source: csv_source, sink: error_sink)
      
      expect { pipeline.run }.to raise_error(DataPipeline::SinkError)
    end
  end
  
  private
  
  def build_csv_source
    csv_data = [
      "name,age,city",
      "John,30,New York", 
      "Jane,25,San Francisco",
      "Bob,35,Chicago"
    ].join("\n")
    
    StringSource.new(csv_data, format: :csv)
  end
  
  def build_json_source
    json_data = [
      { name: "John", age: 30, city: "New York" },
      { name: "Jane", age: 25, city: "San Francisco" },
      { name: "Bob", age: 35, city: "Chicago" }
    ].to_json
    
    StringSource.new(json_data, format: :json)
  end
  
  def build_xml_source
    xml_data = <<~XML
      <people>
        <person><name>John</name><age>30</age><city>New York</city></person>
        <person><name>Jane</name><age>25</age><city>San Francisco</city></person>
        <person><name>Bob</name><age>35</age><city>Chicago</city></person>
      </people>
    XML
    
    StringSource.new(xml_data, format: :xml)
  end
  
  def temp_dir
    @temp_dir ||= Dir.mktmpdir("duck_typing_test")
  end
end

Reference

Core Methods

Method Parameters Returns Description
#respond_to?(method_name, include_all = false) method_name (Symbol/String), include_all (Boolean) Boolean Checks if object responds to method
#method(method_name) method_name (Symbol/String) Method Returns Method object for given method name
#public_send(method_name, *args) method_name (Symbol/String), *args Object Calls public method with arguments
#send(method_name, *args) method_name (Symbol/String), *args Object Calls method with arguments (including private)
#method_missing(method_name, *args, &block) method_name (Symbol), *args, &block Object Called when method doesn't exist
#respond_to_missing?(method_name, include_all) method_name (Symbol), include_all (Boolean) Boolean Should be defined when method_missing is defined

Method Introspection

Method Parameters Returns Description
Method#arity None Integer Number of required parameters (-n for variable args)
Method#parameters None Array Parameter names and types as [type, name] pairs
Method#source_location None Array File and line number where method defined
Method#owner None Class/Module Module/class that defines the method
Method#name None Symbol Method name
Method#call(*args) *args Object Calls the method with arguments

Dynamic Method Definition

Method Parameters Returns Description
#define_method(name, &block) name (Symbol), &block Symbol Defines instance method
#define_singleton_method(name, &block) name (Symbol), &block Symbol Defines singleton method on object
#alias_method(new_name, old_name) new_name (Symbol), old_name (Symbol) self Creates method alias
#remove_method(name) name (Symbol) self Removes method definition
#undef_method(name) name (Symbol) self Prevents method calls including inherited

Delegation and Forwarding

Library/Method Usage Description
Forwardable#def_delegator def_delegator :@target, :method_name Delegates single method to object
Forwardable#def_delegators def_delegators :@target, :method1, :method2 Delegates multiple methods to object
SimpleDelegator SimpleDelegator.new(target) Delegates all methods to target object
DelegateClass() DelegateClass(Array) Creates class delegating to specific class

Parameter Types for Method#parameters

Parameter Type Description Example
:req Required positional parameter def method(a)
:opt Optional positional parameter def method(a = nil)
:rest Splat parameter def method(*args)
:keyreq Required keyword parameter def method(a:)
:key Optional keyword parameter def method(a: nil)
:keyrest Double splat parameter def method(**kwargs)
:block Block parameter def method(&block)

Method Arity Values

Arity Value Meaning Example Method
n (positive) Exactly n required parameters def method(a, b) → 2
0 No parameters def method() → 0
-n (negative) At least (n-1) required, plus optional def method(a, *rest) → -2
-1 Variable arguments accepted def method(*args) → -1

Common Duck Type Interfaces

Interface Required Methods Common Usage
Enumerable-like #each Iteration, collection processing
IO-like #read, #write, #close File operations, streaming
String-like #to_s, #+, #[] Text processing, concatenation
Hash-like #[], #[]=, #keys Key-value storage, lookup
Callable #call Function objects, callbacks
Comparable-like #<=> Sorting, ordering operations

Error Types in Duck Typing

Exception Class Raised When Prevention Strategy
NoMethodError Method doesn't exist on object Use respond_to? checks
ArgumentError Wrong number of arguments Check Method#arity or Method#parameters
LocalJumpError Block required but not given Use block_given?
TypeError Object doesn't support expected operation Validate object types or contracts
NameError Constant or variable doesn't exist Use defined? or const_defined?

Best Practices Decision Matrix

Scenario Use respond_to? Use method_missing Use explicit interface
Known method set
Dynamic method generation
Performance critical
Proxy/delegation patterns
API integration
Development/debugging