Overview
Polymorphism describes the capability of objects from different classes to respond to the same method call with behavior specific to their type. The term derives from Greek roots meaning "many forms," reflecting how a single interface can represent multiple underlying implementations.
In object-oriented systems, polymorphism manifests when different classes implement the same method signature but with distinct behaviors. A method call operates on objects without requiring knowledge of their specific class, relying instead on the object's ability to respond appropriately. This decoupling between interface and implementation forms the foundation for flexible, maintainable code architectures.
Ruby implements polymorphism through its dynamic type system and duck typing philosophy. Unlike statically typed languages that enforce polymorphism through explicit interfaces or inheritance hierarchies, Ruby determines an object's fitness for a particular operation at runtime based on its available methods. An object's class matters less than its ability to respond to requested messages.
Consider a simple example demonstrating polymorphic behavior:
class FileLogger
def log(message)
File.open('app.log', 'a') { |f| f.puts(message) }
end
end
class ConsoleLogger
def log(message)
puts message
end
end
class NetworkLogger
def log(message)
# Send message to remote logging service
HTTP.post('https://logs.example.com', body: { message: message })
end
end
def process_data(logger)
logger.log("Starting data processing")
# Process data...
logger.log("Processing complete")
end
# All three logger types work with the same method
process_data(FileLogger.new)
process_data(ConsoleLogger.new)
process_data(NetworkLogger.new)
The process_data method operates on any logger object that responds to log, regardless of class membership. Each logger implements distinct behavior behind a uniform interface, enabling the calling code to remain ignorant of implementation details.
Polymorphism provides several key benefits in software design. It reduces coupling between components, making systems easier to modify and extend. New classes can participate in existing code without modifying that code, adhering to the open/closed principle. It enables abstraction, allowing developers to work at higher levels without concerning themselves with concrete implementations. Testing becomes simpler through the use of test doubles that respond to the same methods as production objects.
Key Principles
Polymorphism rests on several fundamental concepts that govern how objects interact through shared interfaces while maintaining distinct behaviors.
Message Passing and Method Dispatch
Objects communicate through messages in object-oriented systems. When code sends a message to an object, the runtime determines which method implementation to execute based on the receiver's type. This process, called method dispatch or dynamic dispatch, occurs at runtime rather than compile time in dynamically typed languages like Ruby.
Ruby's method lookup follows a specific path: the object's singleton class, the object's class, included modules in reverse order of inclusion, parent classes, and their included modules. This lookup chain determines which implementation executes for any given method call.
class Animal
def speak
"Some sound"
end
end
class Dog < Animal
def speak
"Woof"
end
end
class Cat < Animal
def speak
"Meow"
end
end
animals = [Dog.new, Cat.new, Animal.new]
animals.each { |animal| puts animal.speak }
# Output:
# Woof
# Meow
# Some sound
Each object responds to the speak message with its class-specific implementation. The calling code remains unaware of which class it interacts with, operating only through the shared method name.
Duck Typing
Ruby embraces duck typing: if an object walks like a duck and quacks like a duck, treat it as a duck. The object's class hierarchy becomes irrelevant; only its ability to respond to specific messages matters. This approach provides maximum flexibility but shifts type safety from compile-time to runtime.
class TextFile
def read
"File contents"
end
end
class HttpResponse
def read
"Response body"
end
end
class StringIO
def read
"String buffer"
end
end
def display_content(readable)
content = readable.read
puts content
end
# All work despite no shared parent class
display_content(TextFile.new)
display_content(HttpResponse.new)
display_content(StringIO.new)
The display_content method accepts any object implementing read, regardless of inheritance relationships. This flexibility comes with a tradeoff: methods must implement defensive checks or let runtime errors surface when objects lack expected methods.
Substitutability and the Liskov Substitution Principle
Effective polymorphism requires that derived classes remain substitutable for their parent classes without altering program correctness. Subclasses should strengthen postconditions and weaken preconditions, never the reverse. Violating this principle creates subtle bugs when polymorphic code makes assumptions about behavior that subclasses fail to honor.
class Rectangle
attr_accessor :width, :height
def initialize(width, height)
@width = width
@height = height
end
def area
width * height
end
end
class Square < Rectangle
def initialize(side)
super(side, side)
end
def width=(value)
@width = @height = value
end
def height=(value)
@width = @height = value
end
end
def test_rectangle(rect)
rect.width = 5
rect.height = 10
rect.area == 50 # Expects 50
end
test_rectangle(Rectangle.new(0, 0)) # => true
test_rectangle(Square.new(0)) # => false (area is 100)
The Square class violates substitutability. Code written for Rectangle makes assumptions that fail for Square, demonstrating how inheritance-based polymorphism can introduce unexpected behavior when substitutability breaks down.
Interface Segregation
Polymorphic interfaces should remain focused and cohesive. Objects implementing an interface should require all methods in that interface, not just a subset. Large, unfocused interfaces force implementing classes to provide stub implementations for unused methods, creating maintenance burdens and confusion.
Ruby lacks formal interface declarations, but the principle applies through implicit contracts. When designing for polymorphism, consider whether objects should truly share all methods or whether multiple smaller interfaces better serve the design.
Parametric Polymorphism
While often associated with generic programming in statically typed languages, Ruby's dynamic nature makes all methods inherently parametric. Methods operate on parameters without caring about their concrete types, only their ability to respond to required messages.
def find_maximum(collection)
collection.max
end
find_maximum([1, 5, 3, 9, 2]) # => 9
find_maximum(%w[apple banana cherry]) # => "cherry"
find_maximum([Date.today, Date.yesterday]) # => today's date
The find_maximum method works with any collection implementing max, demonstrating parametric behavior without explicit type parameters.
Ruby Implementation
Ruby's approach to polymorphism differs significantly from statically typed languages, relying on dynamic typing, open classes, and flexible method dispatch to enable polymorphic behavior.
Method Definition and Override
Classes define methods that can be overridden by subclasses. Ruby resolves method calls at runtime by searching the inheritance chain, selecting the first matching method.
class Payment
def process
"Processing generic payment"
end
def refund
"Refunding generic payment"
end
end
class CreditCardPayment < Payment
def process
validate_card
charge_card
"Processing credit card payment"
end
private
def validate_card
# Card validation logic
end
def charge_card
# Charging logic
end
end
class PayPalPayment < Payment
def process
authenticate_paypal
transfer_funds
"Processing PayPal payment"
end
private
def authenticate_paypal
# PayPal authentication
end
def transfer_funds
# Transfer logic
end
end
def execute_payment(payment)
result = payment.process
puts result
end
execute_payment(CreditCardPayment.new)
execute_payment(PayPalPayment.new)
Each payment type provides specific implementation details while maintaining the same external interface. The execute_payment method operates uniformly across all payment types.
Modules and Mixins
Ruby modules provide an alternative to inheritance-based polymorphism. Multiple modules can be included in a class, providing implementations that become part of the method lookup chain. This composition approach avoids the limitations of single inheritance while maintaining polymorphic behavior.
module Searchable
def search(query)
records.select { |record| matches?(record, query) }
end
private
def matches?(record, query)
record.to_s.include?(query)
end
end
module Sortable
def sort_by_field(field)
records.sort_by { |record| record.send(field) }
end
end
class UserRepository
include Searchable
include Sortable
def records
@users ||= load_users
end
private
def load_users
# Load users from database
[]
end
end
class ProductRepository
include Searchable
include Sortable
def records
@products ||= load_products
end
private
def load_products
# Load products from database
[]
end
end
def find_records(repository, query)
repository.search(query)
end
user_repo = UserRepository.new
product_repo = ProductRepository.new
find_records(user_repo, "john")
find_records(product_repo, "laptop")
Both repositories respond to search and sort_by_field through module inclusion, achieving polymorphic behavior without inheritance. Each repository provides its own records implementation while sharing search and sort capabilities.
Method Missing and Dynamic Dispatch
Ruby's method_missing hook enables objects to respond to method calls dynamically, even for methods not explicitly defined. This technique creates highly flexible polymorphic interfaces but should be used judiciously due to performance and debugging implications.
class ConfigurationProxy
def initialize(config_hash)
@config = config_hash
end
def method_missing(method_name, *args)
key = method_name.to_s
if key.end_with?('=')
@config[key.chomp('=')] = args.first
else
@config.fetch(key) { super }
end
end
def respond_to_missing?(method_name, include_private = false)
key = method_name.to_s.chomp('=')
@config.key?(key) || super
end
end
class StaticConfiguration
attr_accessor :host, :port, :timeout
def initialize(config_hash)
@host = config_hash['host']
@port = config_hash['port']
@timeout = config_hash['timeout']
end
end
def configure_service(config)
config.host = 'example.com'
config.port = 8080
config.timeout = 30
end
dynamic_config = ConfigurationProxy.new({})
static_config = StaticConfiguration.new({})
configure_service(dynamic_config)
configure_service(static_config)
Both configuration objects respond to the same setter methods, though ConfigurationProxy generates methods dynamically. Properly implementing respond_to_missing? maintains consistency with Ruby's introspection mechanisms.
Proc and Lambda Polymorphism
Ruby's callable objects—procs, lambdas, and methods—exhibit polymorphic behavior through the call method. This enables passing different executable code as arguments while maintaining a consistent interface.
class Calculator
def compute(operation, a, b)
operation.call(a, b)
end
end
add = ->(x, y) { x + y }
subtract = ->(x, y) { x - y }
multiply = ->(x, y) { x * y }
class Division
def call(x, y)
return nil if y.zero?
x.to_f / y
end
end
calc = Calculator.new
puts calc.compute(add, 10, 5) # => 15
puts calc.compute(subtract, 10, 5) # => 5
puts calc.compute(multiply, 10, 5) # => 50
puts calc.compute(Division.new, 10, 5) # => 2.0
The compute method accepts any callable object, whether lambda, proc, or object implementing call. This pattern appears frequently in Ruby codebases for callbacks, strategy implementations, and functional programming techniques.
Singleton Methods and Eigenclasses
Ruby allows defining methods on individual objects through singleton methods, creating per-object polymorphic behavior. Each object has an eigenclass (singleton class) where these methods live, providing instance-specific implementations.
class Logger
def log(message)
"Default: #{message}"
end
end
standard_logger = Logger.new
verbose_logger = Logger.new
def verbose_logger.log(message)
"VERBOSE: #{Time.now} - #{message}"
end
def process_with_logger(logger, data)
logger.log("Processing #{data}")
end
puts process_with_logger(standard_logger, "user data")
# => "Default: Processing user data"
puts process_with_logger(verbose_logger, "user data")
# => "VERBOSE: 2025-10-11 14:30:00 -0500 - Processing user data"
Both loggers belong to the same class but exhibit different behaviors. Singleton methods provide object-level polymorphism without creating new classes.
Common Patterns
Several established patterns leverage polymorphism to solve recurring design problems in Ruby applications.
Strategy Pattern
The strategy pattern encapsulates algorithms in interchangeable objects, allowing runtime selection of behavior. Each strategy implements the same interface, enabling polymorphic substitution.
class CompressionStrategy
def compress(data)
raise NotImplementedError
end
end
class GzipCompression < CompressionStrategy
def compress(data)
# Gzip compression logic
"gzip:#{data}"
end
end
class ZipCompression < CompressionStrategy
def compress(data)
# Zip compression logic
"zip:#{data}"
end
end
class NoCompression < CompressionStrategy
def compress(data)
data
end
end
class FileUploader
attr_writer :compression_strategy
def initialize
@compression_strategy = NoCompression.new
end
def upload(filename, data)
compressed = @compression_strategy.compress(data)
send_to_server(filename, compressed)
end
private
def send_to_server(filename, data)
# Upload logic
"Uploaded #{filename}: #{data[0..20]}..."
end
end
uploader = FileUploader.new
puts uploader.upload('doc.txt', 'file contents here')
uploader.compression_strategy = GzipCompression.new
puts uploader.upload('doc.txt', 'file contents here')
uploader.compression_strategy = ZipCompression.new
puts uploader.upload('doc.txt', 'file contents here')
The uploader switches compression strategies without modifying its core logic. Each strategy implements the compress method, maintaining polymorphic compatibility.
Template Method Pattern
Template methods define an algorithm's skeleton in a base class, deferring specific steps to subclasses. The base class controls the overall flow while subclasses provide specialized behavior through polymorphic method overrides.
class ReportGenerator
def generate
gather_data
format_header
format_body
format_footer
output
end
def gather_data
@data = fetch_data
end
def fetch_data
raise NotImplementedError, "Subclasses must implement fetch_data"
end
def format_header
raise NotImplementedError, "Subclasses must implement format_header"
end
def format_body
raise NotImplementedError, "Subclasses must implement format_body"
end
def format_footer
raise NotImplementedError, "Subclasses must implement format_footer"
end
def output
raise NotImplementedError, "Subclasses must implement output"
end
end
class PDFReport < ReportGenerator
def fetch_data
# Fetch from database
{ sales: 1000, expenses: 500 }
end
def format_header
"PDF Header: Monthly Report"
end
def format_body
"Sales: #{@data[:sales]}, Expenses: #{@data[:expenses]}"
end
def format_footer
"Page 1"
end
def output
"#{format_header}\n#{format_body}\n#{format_footer}"
end
end
class HTMLReport < ReportGenerator
def fetch_data
{ sales: 1000, expenses: 500 }
end
def format_header
"<h1>Monthly Report</h1>"
end
def format_body
"<p>Sales: #{@data[:sales]}, Expenses: #{@data[:expenses]}</p>"
end
def format_footer
"<footer>Generated on #{Time.now}</footer>"
end
def output
"<html>#{format_header}#{format_body}#{format_footer}</html>"
end
end
def create_report(generator)
generator.generate
end
puts create_report(PDFReport.new)
puts create_report(HTMLReport.new)
Both report types follow the same generation sequence while providing format-specific implementations. The base class enforces structure through the template method while subclasses supply polymorphic specializations.
Null Object Pattern
The null object pattern provides a do-nothing implementation of an interface, eliminating nil checks and conditional logic. The null object responds to the same methods as real objects but with benign behavior.
class User
attr_reader :name, :email
def initialize(name, email)
@name = name
@email = email
end
def admin?
false
end
end
class AdminUser < User
def admin?
true
end
end
class GuestUser
def name
"Guest"
end
def email
"guest@example.com"
end
def admin?
false
end
end
class AuthenticationService
def current_user
# Return actual user if authenticated, guest user otherwise
authenticated? ? fetch_user : GuestUser.new
end
private
def authenticated?
# Check authentication
false
end
def fetch_user
User.new("John Doe", "john@example.com")
end
end
def display_user_info(user)
puts "Name: #{user.name}"
puts "Email: #{user.email}"
puts "Admin: #{user.admin?}"
end
auth = AuthenticationService.new
display_user_info(auth.current_user)
The GuestUser object responds to the same methods as authenticated users, enabling code to operate uniformly without checking for nil. This pattern reduces conditional branches and simplifies client code.
Adapter Pattern
Adapters convert one interface into another, enabling objects with incompatible interfaces to work together polymorphically. The adapter wraps an adaptee object and translates method calls.
class LegacyEmailService
def send_email(recipient_address, subject_line, message_body)
"Sending legacy email to #{recipient_address}"
end
end
class ModernEmailService
def deliver(to:, subject:, body:)
"Delivering modern email to #{to}"
end
end
class LegacyEmailAdapter
def initialize(legacy_service)
@service = legacy_service
end
def deliver(to:, subject:, body:)
@service.send_email(to, subject, body)
end
end
def notify_user(email_service, user_email)
email_service.deliver(
to: user_email,
subject: "Notification",
body: "You have a new message"
)
end
modern = ModernEmailService.new
legacy = LegacyEmailAdapter.new(LegacyEmailService.new)
puts notify_user(modern, "user@example.com")
puts notify_user(legacy, "user@example.com")
The adapter enables the legacy service to participate in code expecting the modern interface. Both services become interchangeable from the client's perspective.
Practical Examples
Real-world scenarios demonstrate how polymorphism solves concrete problems in Ruby applications.
HTTP Client Abstraction
Applications often need to swap HTTP client libraries without rewriting dependent code. Polymorphic wrappers around different HTTP libraries create a consistent interface.
class NetHttpClient
def get(url)
uri = URI(url)
response = Net::HTTP.get_response(uri)
{ status: response.code.to_i, body: response.body }
end
def post(url, data)
uri = URI(url)
response = Net::HTTP.post_form(uri, data)
{ status: response.code.to_i, body: response.body }
end
end
class FaradayClient
def initialize
@conn = Faraday.new
end
def get(url)
response = @conn.get(url)
{ status: response.status, body: response.body }
end
def post(url, data)
response = @conn.post(url, data)
{ status: response.status, body: response.body }
end
end
class ApiClient
def initialize(http_client)
@http = http_client
end
def fetch_user(user_id)
response = @http.get("https://api.example.com/users/#{user_id}")
if response[:status] == 200
JSON.parse(response[:body])
else
nil
end
end
def create_user(user_data)
response = @http.post("https://api.example.com/users", user_data)
response[:status] == 201
end
end
# Swap HTTP implementations without changing ApiClient
client_with_net_http = ApiClient.new(NetHttpClient.new)
client_with_faraday = ApiClient.new(FaradayClient.new)
user = client_with_net_http.fetch_user(123)
result = client_with_faraday.create_user(name: "Jane", email: "jane@example.com")
The ApiClient remains unaware of which HTTP library performs requests. Testing becomes simpler through mock HTTP clients implementing the same interface.
Data Serialization Pipeline
Applications serialize data into various formats for different consumers. Polymorphic serializers enable format selection without modifying serialization logic.
class JsonSerializer
def serialize(data)
require 'json'
JSON.generate(data)
end
def deserialize(string)
JSON.parse(string)
end
end
class YamlSerializer
def serialize(data)
require 'yaml'
YAML.dump(data)
end
def deserialize(string)
YAML.load(string)
end
end
class MessagePackSerializer
def serialize(data)
require 'msgpack'
data.to_msgpack
end
def deserialize(string)
MessagePack.unpack(string)
end
end
class DataExporter
def initialize(serializer)
@serializer = serializer
end
def export(records, filename)
serialized = @serializer.serialize(records)
File.write(filename, serialized)
"Exported #{records.length} records to #{filename}"
end
def import(filename)
data = File.read(filename)
@serializer.deserialize(data)
end
end
records = [
{ id: 1, name: "Alice", age: 30 },
{ id: 2, name: "Bob", age: 25 }
]
json_exporter = DataExporter.new(JsonSerializer.new)
yaml_exporter = DataExporter.new(YamlSerializer.new)
msgpack_exporter = DataExporter.new(MessagePackSerializer.new)
json_exporter.export(records, "data.json")
yaml_exporter.export(records, "data.yaml")
msgpack_exporter.export(records, "data.msgpack")
imported_json = json_exporter.import("data.json")
imported_yaml = yaml_exporter.import("data.yaml")
Each serializer implements the same interface while handling format-specific details internally. The exporter operates uniformly across all formats.
Payment Processing System
E-commerce applications process payments through multiple gateways. Polymorphic payment processors abstract gateway differences behind a common interface.
class StripeProcessor
def initialize(api_key)
@api_key = api_key
end
def charge(amount_cents, currency, token)
# Stripe API call
{
success: true,
transaction_id: "stripe_#{SecureRandom.hex(8)}",
amount: amount_cents,
currency: currency
}
end
def refund(transaction_id, amount_cents)
# Stripe refund API call
{ success: true, refund_id: "stripe_ref_#{SecureRandom.hex(8)}" }
end
end
class BraintreeProcessor
def initialize(merchant_id, public_key, private_key)
@merchant_id = merchant_id
@public_key = public_key
@private_key = private_key
end
def charge(amount_cents, currency, token)
# Braintree API call
{
success: true,
transaction_id: "braintree_#{SecureRandom.hex(8)}",
amount: amount_cents,
currency: currency
}
end
def refund(transaction_id, amount_cents)
# Braintree refund API call
{ success: true, refund_id: "braintree_ref_#{SecureRandom.hex(8)}" }
end
end
class Order
attr_reader :total_cents, :currency, :payment_token
def initialize(total_cents, currency, payment_token)
@total_cents = total_cents
@currency = currency
@payment_token = payment_token
end
end
class OrderProcessor
def initialize(payment_processor)
@payment_processor = payment_processor
end
def process_order(order)
result = @payment_processor.charge(
order.total_cents,
order.currency,
order.payment_token
)
if result[:success]
{
status: :completed,
transaction_id: result[:transaction_id],
amount: result[:amount]
}
else
{ status: :failed }
end
end
def refund_order(transaction_id, amount_cents)
@payment_processor.refund(transaction_id, amount_cents)
end
end
order = Order.new(5000, 'USD', 'tok_abc123')
stripe = OrderProcessor.new(StripeProcessor.new('sk_test_key'))
braintree = OrderProcessor.new(
BraintreeProcessor.new('merchant_123', 'pub_key', 'priv_key')
)
stripe_result = stripe.process_order(order)
braintree_result = braintree.process_order(order)
The OrderProcessor switches between payment gateways without code changes. Each processor handles gateway-specific authentication and API details while maintaining interface consistency.
Logging Strategy
Applications log to different destinations based on environment or configuration. Polymorphic loggers enable consistent logging calls regardless of destination.
class FileLogger
def initialize(filename)
@filename = filename
@file = File.open(filename, 'a')
end
def log(level, message)
@file.puts("[#{Time.now}] #{level.upcase}: #{message}")
@file.flush
end
def close
@file.close
end
end
class SyslogLogger
def initialize(program_name)
@syslog = Syslog.open(program_name)
end
def log(level, message)
priority = case level
when :debug then Syslog::LOG_DEBUG
when :info then Syslog::LOG_INFO
when :warn then Syslog::LOG_WARNING
when :error then Syslog::LOG_ERR
end
@syslog.log(priority, message)
end
def close
@syslog.close
end
end
class MultiLogger
def initialize(loggers)
@loggers = loggers
end
def log(level, message)
@loggers.each { |logger| logger.log(level, message) }
end
def close
@loggers.each(&:close)
end
end
class Application
def initialize(logger)
@logger = logger
end
def run
@logger.log(:info, "Application starting")
perform_work
@logger.log(:info, "Application finished")
rescue StandardError => e
@logger.log(:error, "Application error: #{e.message}")
ensure
@logger.close
end
private
def perform_work
@logger.log(:debug, "Performing work...")
# Application logic
end
end
# Single destination
app_with_file = Application.new(FileLogger.new('app.log'))
app_with_syslog = Application.new(SyslogLogger.new('myapp'))
# Multiple destinations
multi_logger = MultiLogger.new([
FileLogger.new('app.log'),
SyslogLogger.new('myapp')
])
app_with_multi = Application.new(multi_logger)
app_with_file.run
app_with_multi.run
Applications switch logging strategies without modifying logging calls. The MultiLogger demonstrates how polymorphism enables combining multiple implementations transparently.
Design Considerations
Selecting polymorphic designs requires weighing tradeoffs between flexibility, complexity, and maintainability.
When to Use Polymorphism
Polymorphism provides value when multiple implementations of an operation exist or will exist. If variation points remain static and few, conditional logic may suffice with less overhead. Polymorphism shines when new variations emerge frequently, as adding new classes beats modifying conditional statements spread throughout a codebase.
Consider polymorphism when different objects require fundamentally different algorithms for the same conceptual operation. A sort method operating on different data structures benefits from polymorphic implementations optimized for each structure. Conversely, trivial variations better suit simple conditional branches than full polymorphic hierarchies.
Testing requirements influence design decisions. Polymorphic designs simplify testing by enabling test doubles that implement the same interface as production objects. Complex dependencies become mockable through polymorphic substitution, isolating units under test.
Inheritance vs Composition
Ruby supports both inheritance-based and composition-based polymorphism. Inheritance creates "is-a" relationships, sharing implementation through class hierarchies. Composition builds "has-a" relationships, delegating to contained objects.
Inheritance suits cases where subclasses represent true specializations of parent classes. A Dog genuinely represents a specialized Animal. However, deep hierarchies create fragility and coupling. Changes to parent classes ripple through all descendants, potentially breaking distant subclasses.
Composition provides flexibility through object aggregation. A User containing an AuthenticationStrategy decouples authentication implementation from user representation. Strategies change without modifying User. The composition approach scales better as systems grow complex, avoiding inheritance's coupling problems.
Ruby modules offer middle ground through mixins. A class includes modules to gain behavior without establishing "is-a" relationships. This approach combines composition's flexibility with inheritance's convenience, though method resolution order becomes complex with many modules.
# Inheritance approach
class Vehicle
def start_engine
"Starting engine"
end
end
class Car < Vehicle
def drive
"Driving on road"
end
end
# Composition approach
class Engine
def start
"Starting engine"
end
end
class Wheels
def rotate
"Wheels rotating"
end
end
class Automobile
def initialize
@engine = Engine.new
@wheels = Wheels.new
end
def start_engine
@engine.start
end
def drive
@wheels.rotate
"Driving on road"
end
end
The inheritance version couples Car to Vehicle. Changes to Vehicle affect all subclasses. The composition version isolates components, enabling independent evolution.
Interface Design
Effective polymorphism requires well-designed interfaces. Interfaces should remain focused on specific capabilities rather than bundling unrelated operations. A Readable interface contains only reading operations, not writing or validation.
Interface stability matters greatly in polymorphic systems. Frequently changing interfaces force updates across all implementing classes. Careful upfront design and gradual interface evolution prevent cascading changes. Ruby's lack of explicit interfaces increases the burden of documentation and testing to ensure consistency.
Consider the granularity of interfaces. Fine-grained interfaces with single methods offer maximum flexibility but proliferate interface definitions. Coarse-grained interfaces with many methods simplify implementation but reduce flexibility. Balance depends on expected variation and implementation burden.
Performance Implications
Polymorphism introduces runtime overhead through dynamic dispatch. Each method call requires traversing the method lookup chain to find the appropriate implementation. In tight loops processing millions of elements, this overhead accumulates.
Ruby's dynamic typing amplifies performance concerns. Static languages can optimize polymorphic calls through techniques like inline caching and devirtualization. Ruby performs less aggressive optimization, making polymorphic calls relatively expensive.
Profile before optimizing. Most applications spend time in I/O, database queries, or business logic rather than method dispatch. Polymorphic designs rarely create bottlenecks in typical Ruby applications. When profiling reveals hot paths, consider targeted optimizations like caching method lookups or using more direct call mechanisms.
Maintainability Tradeoffs
Polymorphic designs trade immediate simplicity for long-term flexibility. A simple conditional statement requires less initial code than polymorphic classes. However, as variations grow, conditional logic becomes unwieldy while polymorphic designs scale gracefully.
Documentation and discoverability suffer in polymorphic systems. Finding all implementations of an interface requires searching the codebase or relying on tools. Explicit conditional logic makes variation points obvious. Good naming, documentation, and consistent conventions mitigate this discoverability problem.
Debugging polymorphic code can challenge developers unfamiliar with dynamic dispatch. Stack traces show which method executed but not why that particular implementation was selected. Careful logging and debugging tools that expose method resolution help developers understand polymorphic behavior.
Reference
Key Concepts
| Concept | Description | Example Use Case |
|---|---|---|
| Polymorphism | Objects of different types respond to the same message with type-specific behavior | Multiple logger types responding to log method |
| Duck Typing | Object suitability determined by available methods rather than class membership | Any object implementing read can be used as readable |
| Method Dispatch | Runtime process of determining which method implementation to execute | Finding the correct speak method for Dog, Cat, or Animal |
| Substitutability | Derived objects can replace base objects without altering correctness | Any Payment subclass working in place of Payment |
| Dynamic Dispatch | Method resolution occurring at runtime based on receiver type | Array, Hash, and Set each having different each implementation |
| Interface | Set of methods that objects must implement to participate in polymorphic behavior | All serializers implementing serialize and deserialize |
| Strategy | Encapsulating algorithms in interchangeable objects | Different compression algorithms for file uploader |
| Template Method | Base class defining algorithm structure with subclasses providing steps | Report generators with format-specific implementations |
Common Polymorphic Patterns
| Pattern | Purpose | Key Characteristic |
|---|---|---|
| Strategy | Algorithm selection at runtime | Encapsulates variations behind common interface |
| Template Method | Fixed algorithm structure with variable steps | Base class controls flow, subclasses provide details |
| Null Object | Eliminate nil checks | Provides do-nothing implementation of interface |
| Adapter | Interface compatibility | Wraps incompatible object with expected interface |
| State | Behavior changes with internal state | Object delegates to state objects implementing same interface |
| Decorator | Add responsibilities dynamically | Wrappers implement same interface as wrapped object |
| Factory Method | Object creation deferred to subclasses | Factory method returns objects implementing common interface |
| Observer | Notification distribution | Observers implement update method called by subject |
Ruby Implementation Techniques
| Technique | Mechanism | Best For |
|---|---|---|
| Class Inheritance | Subclass overrides parent methods | True specialization relationships |
| Module Mixins | Include modules to gain methods | Shared behavior across unrelated classes |
| Duck Typing | Rely on method presence not class | Maximum flexibility without inheritance |
| Callable Objects | Objects implementing call method | Passing behavior as parameters |
| method_missing | Respond to undefined methods dynamically | Proxies and dynamic interfaces |
| Singleton Methods | Define methods on individual objects | Per-instance behavior customization |
| Composition | Delegate to contained objects | Decoupling implementation from interface |
| Refinements | Locally modify class behavior | Scoped modifications without global impact |
Method Lookup Order
| Step | Location | Description |
|---|---|---|
| 1 | Singleton class | Methods defined on the specific object |
| 2 | Prepended modules | Modules prepended to the class |
| 3 | Object's class | Methods defined in the class itself |
| 4 | Included modules | Modules included in the class, reverse order |
| 5 | Parent class | Methods in superclass |
| 6 | Parent's modules | Modules included in superclass |
| 7 | Object class | Methods in base Object class |
| 8 | Kernel module | Methods in Kernel module |
| 9 | BasicObject | Most basic Ruby object methods |
Design Decision Framework
| Consideration | Prefer Inheritance | Prefer Composition |
|---|---|---|
| Relationship | True is-a relationship exists | Has-a or uses-a relationship |
| Coupling | Tight coupling acceptable | Loose coupling required |
| Reuse | Inherit implementation and interface | Reuse through delegation |
| Flexibility | Hierarchy relatively stable | Frequent strategy changes |
| Testing | Subclass testing straightforward | Need to mock dependencies |
| Complexity | Simple hierarchy | Deep or complex hierarchy |
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Violation of substitutability | Subclass breaks parent assumptions | Ensure subclasses strengthen postconditions |
| Deep inheritance hierarchies | Coupling and fragility increase with depth | Favor composition or modules over deep hierarchies |
| Interface bloat | Objects forced to implement unused methods | Split large interfaces into focused smaller ones |
| Missing respond_to_missing? | Introspection breaks with method_missing | Always implement respond_to_missing? with method_missing |
| Overuse of polymorphism | Simple cases gain unnecessary complexity | Use conditionals for simple, stable variations |
| Unclear interfaces | Implicit contracts lead to confusion | Document expected methods and behaviors clearly |
| Performance blind spots | Excessive dynamic dispatch in hot paths | Profile before optimizing, consider direct calls |
| Inheritance for code reuse | Using inheritance only to share code | Use modules or composition for code reuse |