CrackedRuby CrackedRuby

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