CrackedRuby logo

CrackedRuby

Method Chaining

Method chaining enables consecutive method calls on objects by returning values that support further method invocation.

Patterns and Best Practices Ruby Idioms
11.2.3

Overview

Method chaining connects multiple method calls in a single expression, where each method returns an object that supports the next method in the chain. Ruby's object model makes this pattern natural since every expression returns a value, and methods can return self or other chainable objects.

The pattern appears throughout Ruby's standard library. String methods like #strip, #downcase, and #gsub return new String objects, enabling chains like " HELLO ".strip.downcase.gsub('l', 'x'). Array methods such as #select, #map, and #sort return new arrays, supporting functional-style transformations.

# Basic chaining with built-in methods
result = [1, 2, 3, 4, 5]
  .select(&:odd?)
  .map(&:to_s)
  .join(', ')
# => "1, 3, 5"

# String chaining
formatted = "  Ruby Programming  "
  .strip
  .downcase
  .gsub(/\s+/, '_')
# => "ruby_programming"

Ruby supports two primary chaining approaches: transformation chaining where each method returns a new object of the same or compatible type, and fluent interfaces where methods return self to enable configuration-style chaining. ActiveRecord queries exemplify transformation chaining, while builder patterns demonstrate fluent interfaces.

The key requirement for chainability is that methods return objects supporting subsequent method calls. This can be the same object (self), a new object of the same class, or any object with the needed interface.

Basic Usage

String chaining represents the most common form in Ruby. String methods create new objects rather than modifying originals, making every string method call chainable with other string methods.

# String transformation chains
email = "  JOHN.DOE@EXAMPLE.COM  "
clean_email = email.strip.downcase.gsub(/\./, '_')
# => "john_doe@example_com"

# Complex string processing
text = "The quick brown fox jumps"
result = text
  .split(' ')
  .map(&:capitalize)
  .select { |word| word.length > 3 }
  .join(' -> ')
# => "Quick -> Brown -> Jumps"

Array methods provide another fundamental chaining context. Most enumerable methods return arrays or enumerables, supporting functional programming patterns.

# Array processing chains
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
result = numbers
  .select(&:even?)
  .map { |n| n * 2 }
  .sort
  .reverse
  .first(3)
# => [20, 16, 12]

# Mixed type chains
words = ['apple', 'banana', 'cherry']
lengths = words
  .map(&:upcase)
  .map(&:length)
  .reduce(:+)
# => 17

Custom objects support chaining by returning appropriate values. Methods returning self enable fluent configuration, while methods returning new objects enable transformation chains.

class StringBuilder
  def initialize(content = '')
    @content = content
  end
  
  def append(text)
    @content += text
    self
  end
  
  def prepend(text)
    @content = text + @content
    self
  end
  
  def upcase
    @content.upcase!
    self
  end
  
  def to_s
    @content
  end
end

# Fluent interface chaining
result = StringBuilder.new
  .append('Hello')
  .append(' ')
  .append('World')
  .prepend('Message: ')
  .upcase
  .to_s
# => "MESSAGE: HELLO WORLD"

Method chaining works across different object types when methods return objects with compatible interfaces. Hash methods often return arrays, which can then use array methods.

# Cross-type chaining
data = { a: 1, b: 2, c: 3, d: 4 }
result = data
  .select { |k, v| v.even? }
  .map { |k, v| "#{k}=#{v}" }
  .join('&')
# => "b=2&d=4"

Advanced Usage

Complex chaining scenarios emerge when building domain-specific languages, query builders, and configuration APIs. These patterns often combine fluent interfaces with conditional logic and method_missing hooks.

class QueryBuilder
  def initialize(table)
    @table = table
    @conditions = []
    @joins = []
    @order = []
    @limit_value = nil
  end
  
  def where(condition)
    @conditions << condition
    self
  end
  
  def join(table)
    @joins << table
    self
  end
  
  def order(column)
    @order << column
    self
  end
  
  def limit(count)
    @limit_value = count
    self
  end
  
  def to_sql
    sql = "SELECT * FROM #{@table}"
    sql += " JOIN #{@joins.join(' JOIN ')}" unless @joins.empty?
    sql += " WHERE #{@conditions.join(' AND ')}" unless @conditions.empty?
    sql += " ORDER BY #{@order.join(', ')}" unless @order.empty?
    sql += " LIMIT #{@limit_value}" if @limit_value
    sql
  end
end

# Advanced fluent chaining
query = QueryBuilder.new('users')
  .join('profiles')
  .where('age > 18')
  .where('active = true')
  .order('created_at DESC')
  .limit(10)
  .to_sql
# => "SELECT * FROM users JOIN profiles WHERE age > 18 AND active = true ORDER BY created_at DESC LIMIT 10"

Proc and lambda objects support chaining through composition, creating functional pipelines that transform data through multiple stages.

# Proc chaining for data transformation
validate_email = ->(email) { email.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i) }
normalize_email = ->(email) { email.strip.downcase }
extract_domain = ->(email) { email.split('@').last }

# Compose procs into pipeline
email_processor = ->(email) do
  normalized = normalize_email.call(email)
  return nil unless validate_email.call(normalized)
  extract_domain.call(normalized)
end

result = email_processor.call("  JOHN@EXAMPLE.COM  ")
# => "example.com"

Method chaining with blocks creates powerful data processing pipelines. Enumerator objects returned by methods like #lazy enable efficient chaining over large datasets.

# Lazy evaluation chains
def process_large_dataset(items)
  items.lazy
    .select { |item| expensive_validation(item) }
    .map { |item| transform_item(item) }
    .reject { |item| item.nil? }
    .first(100)
    .force
end

# Chaining with custom enumerators
class BatchProcessor
  def initialize(items, batch_size = 10)
    @items = items
    @batch_size = batch_size
  end
  
  def each_batch
    return enum_for(:each_batch) unless block_given?
    
    @items.each_slice(@batch_size) do |batch|
      yield batch
    end
  end
  
  def process(&block)
    each_batch.map(&block)
  end
end

# Advanced enumerable chaining
data = (1..100).to_a
results = BatchProcessor.new(data, 5)
  .each_batch
  .with_index
  .map { |batch, index| [index, batch.sum] }
  .select { |index, sum| sum > 50 }
# => [[2, 65], [3, 90], [4, 115], ...]

Metaprogramming enables dynamic method chaining through method_missing and delegation patterns. This creates flexible APIs that respond to arbitrary method calls.

class ChainableHash
  def initialize(hash = {})
    @data = hash
  end
  
  def method_missing(method, *args)
    if method.to_s.end_with?('=')
      key = method.to_s.chomp('=').to_sym
      @data[key] = args.first
      self
    elsif @data.key?(method)
      @data[method]
    else
      super
    end
  end
  
  def respond_to_missing?(method, include_private = false)
    method.to_s.end_with?('=') || @data.key?(method) || super
  end
  
  def to_h
    @data
  end
end

# Dynamic chaining
config = ChainableHash.new
  .host=('localhost')
  .port=(3000)
  .timeout=(30)
  .ssl=(true)
  .to_h
# => {:host=>"localhost", :port=>3000, :timeout=>30, :ssl=>true}

Performance & Memory

Method chaining creates intermediate objects that impact memory usage and garbage collection. Each method call in a transformation chain typically allocates new objects, multiplying memory requirements.

# Memory-intensive chaining
large_array = (1..1_000_000).to_a

# Creates multiple intermediate arrays
result = large_array
  .select(&:even?)     # ~500k elements
  .map { |n| n * 2 }   # ~500k elements  
  .select(&:odd?)      # 0 elements
  .first(10)

# More efficient approach
result = []
large_array.each do |n|
  next unless n.even?
  doubled = n * 2
  next unless doubled.odd?
  result << doubled
  break if result.size >= 10
end

String chaining creates temporary string objects at each step. For extensive string manipulation, building operations into fewer method calls reduces allocations.

require 'benchmark'

text = "the quick brown fox jumps over the lazy dog" * 1000

# Chain with many intermediate strings
Benchmark.bm do |x|
  x.report("chained") do
    1000.times do
      text.strip.upcase.gsub(/THE/, 'A').gsub(/FOX/, 'CAT').reverse
    end
  end
  
  x.report("combined") do  
    1000.times do
      text.strip.upcase.gsub(/THE/, 'A').gsub(/FOX/, 'CAT').reverse
    end
  end
end

Lazy evaluation provides memory-efficient chaining for large datasets. The #lazy method returns an Enumerator that processes elements on-demand rather than creating intermediate collections.

# Memory-efficient lazy chaining
def process_huge_file(filename)
  File.foreach(filename).lazy
    .map(&:chomp)
    .select { |line| line.start_with?('ERROR') }
    .map { |line| parse_error(line) }
    .reject(&:nil?)
    .first(100)
end

# Benchmark lazy vs eager evaluation
require 'benchmark/memory'

data = (1..1_000_000)

Benchmark.memory do |x|
  x.report("eager") do
    data.select(&:even?).map(&:to_s).first(10)
  end
  
  x.report("lazy") do
    data.lazy.select(&:even?).map(&:to_s).first(10).force
  end
end

Custom chainable objects should consider return value strategies. Returning self avoids object creation but requires careful state management, while returning new objects provides immutability at memory cost.

class OptimizedBuilder
  def initialize
    @operations = []
  end
  
  # Return self for configuration
  def configure(&block)
    instance_eval(&block)
    self
  end
  
  # Collect operations instead of immediate execution
  def transform(operation)
    @operations << operation
    self
  end
  
  # Execute all operations at once
  def execute(data)
    @operations.reduce(data) { |result, operation| 
      operation.call(result) 
    }
  end
end

# Deferred execution reduces intermediate objects
builder = OptimizedBuilder.new
  .configure { transform(->(x) { x.select(&:even?) }) }
  .configure { transform(->(x) { x.map(&:to_s) }) }

result = builder.execute([1, 2, 3, 4, 5])
# => ["2", "4"]

Common Pitfalls

Method chaining can obscure nil values that break chains unexpectedly. Ruby raises NoMethodError when calling methods on nil, but the error location may not indicate where nil originated.

# Fragile chaining
def process_user_email(user)
  user.email.strip.downcase.gsub('@', ' at ')
end

# Breaks if user or email is nil
# Better approach with safe navigation
def process_user_email(user)
  user&.email&.strip&.downcase&.gsub('@', ' at ')
end

# Or explicit nil handling
def process_user_email(user)
  return nil unless user&.email
  user.email.strip.downcase.gsub('@', ' at ')
end

Chaining methods with different return types creates confusion. Methods may return objects that don't support expected subsequent methods, leading to runtime errors.

# Type confusion in chains
numbers = [1, 2, 3, 4, 5]

# This breaks - first returns Integer, not Array
result = numbers.select(&:even?).first.map(&:to_s)
# => NoMethodError: undefined method `map' for 2:Integer

# Correct understanding of return types
evens = numbers.select(&:even?)  # => [2, 4]  
first_even = evens.first         # => 2
# Can't call map on Integer

# Fix by understanding what each method returns
result = numbers.select(&:even?).take(1).map(&:to_s)
# => ["2"]

Long chains become difficult to debug because stack traces don't clearly indicate which method failed. Breaking chains or using intermediate variables aids debugging.

# Hard to debug
result = data
  .method_a
  .method_b
  .method_c
  .method_d
  .method_e

# More debuggable approach
step1 = data.method_a
puts "After method_a: #{step1.inspect}"

step2 = step1.method_b
puts "After method_b: #{step2.inspect}"

step3 = step2.method_c
puts "After method_c: #{step3.inspect}"

result = step3.method_d.method_e

State mutation in fluent interfaces can create surprising behavior when the same object is reused or when methods modify shared state.

class MutableBuilder
  def initialize
    @items = []
  end
  
  def add(item)
    @items << item
    self
  end
  
  def items
    @items
  end
end

# Shared state problem
builder = MutableBuilder.new
list1 = builder.add('a').add('b').items
list2 = builder.add('c').items

puts list1  # => ['a', 'b', 'c'] (unexpected!)
puts list2  # => ['a', 'b', 'c']

# Fix with immutable returns
class ImmutableBuilder
  def initialize(items = [])
    @items = items.dup
  end
  
  def add(item)
    ImmutableBuilder.new(@items + [item])
  end
  
  def items
    @items.dup
  end
end

Performance problems arise from excessive intermediate object creation, especially in loops or with large datasets. Profile code to identify bottlenecks before optimizing.

# Performance trap - creates many intermediate arrays
def inefficient_processing(large_dataset)
  large_dataset
    .map { |item| expensive_operation_1(item) }
    .select { |item| expensive_check(item) }
    .map { |item| expensive_operation_2(item) }
    .sort
    .reverse
    .first(10)
end

# More efficient - single pass with early termination
def efficient_processing(large_dataset)
  results = []
  large_dataset.each do |item|
    processed = expensive_operation_1(item)
    next unless expensive_check(processed)
    
    final = expensive_operation_2(processed)
    results << final
    
    break if results.size >= 10
  end
  
  results.sort.reverse
end

Reference

Core Chaining Methods

Method Returns Chainable With Description
String#strip String String methods Removes leading/trailing whitespace
String#downcase String String methods Converts to lowercase
String#gsub(pattern, replacement) String String methods Global string substitution
String#chomp String String methods Removes trailing record separator
Array#select Array Enumerable methods Filters elements by block
Array#map Array Enumerable methods Transforms elements via block
Array#reject Array Enumerable methods Excludes elements matching block
Array#sort Array Array methods Sorts elements
Array#reverse Array Array methods Reverses element order
Array#uniq Array Array methods Removes duplicate elements
Hash#select Hash Hash methods Filters key-value pairs
Hash#transform_keys Hash Hash methods Transforms keys via block
Hash#transform_values Hash Hash methods Transforms values via block

Enumerable Chain Methods

Method Parameters Returns Description
#lazy None Enumerator::Lazy Enables lazy evaluation
#with_index(start=0) Optional start value Enumerator Adds index to elements
#each_slice(n) Slice size Enumerator Groups elements in slices
#each_cons(n) Window size Enumerator Sliding window iteration
#zip(*others) Other enumerables Array Merges multiple enumerables
#cycle(n=nil) Optional cycle count Enumerator Repeats enumerable

Safe Navigation

Operator Usage Returns Description
&. object&.method Method result or nil Calls method only if object not nil
&.[] hash&.[:key] Value or nil Safe hash/array access
&.dig nested&.dig(:a, :b) Value or nil Safe nested access

Fluent Interface Patterns

Pattern Implementation Use Case
Configuration Methods return self Builder patterns, DSLs
Transformation Methods return new objects Data processing pipelines
Delegation Proxy objects with method_missing Dynamic APIs
State Machine Methods return state objects Workflow builders

Performance Characteristics

Pattern Memory Speed Best For
Eager chaining High (intermediate objects) Fast execution Small datasets
Lazy chaining Low (on-demand) Slower per element Large datasets
Mutation chains Low (reuses objects) Fastest When immutability not required
Functional chains Medium-High Medium When immutability required

Common Return Types

Starting Type Common Chains Final Type
String #strip.downcase.gsub String
Array #select.map.sort Array
Hash #select.transform_values Hash
Enumerable #lazy.select.first(n) Array
ActiveRecord::Relation #where.joins.order ActiveRecord::Relation

Error Handling Strategies

# Defensive chaining
result = object&.method1&.method2 || default_value

# Early return pattern  
def process_chain(input)
  return nil unless input
  
  step1 = input.process_a
  return nil unless step1
  
  step1.process_b.process_c
end

# Exception handling in chains
begin
  result = data.chain.of.methods
rescue NoMethodError => e
  handle_chain_break(e)
rescue ArgumentError => e
  handle_invalid_input(e)
end