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 | ✓ | ✗ | ✓ |