CrackedRuby CrackedRuby

Overview

Pure functions represent a fundamental concept in functional programming where a function's return value depends solely on its input parameters, with no observable side effects. The function produces the same output given the same input across all invocations, regardless of program state or execution context.

The concept originates from mathematical functions, where f(x) consistently maps each input to exactly one output. In software development, pure functions provide predictability, testability, and reasoning capabilities that impure functions cannot match. Code containing pure functions becomes easier to debug, test, and parallelize because function behavior remains isolated from external state.

Pure functions contrast with impure functions that modify external state, depend on mutable data, perform I/O operations, or generate random values. The distinction affects code reliability, maintainability, and concurrent execution safety.

# Pure function - same input always produces same output
def calculate_total(price, quantity)
  price * quantity
end

calculate_total(10, 5)  # => 50
calculate_total(10, 5)  # => 50 (always returns 50)

# Impure function - modifies external state
@total = 0
def add_to_total(amount)
  @total += amount
end

add_to_total(10)  # => 10
add_to_total(10)  # => 20 (different result with same input)

Pure functions enable referential transparency, meaning any function call can be replaced with its return value without changing program behavior. This property facilitates code optimization, memoization, and parallel execution strategies.

Key Principles

Pure functions adhere to three fundamental requirements that distinguish them from standard functions. These requirements establish predictable behavior and eliminate hidden dependencies.

Deterministic Output: A pure function returns identical output for identical input across all invocations. The function's behavior depends exclusively on explicit parameters, never on time, random number generators, global variables, or external state. This determinism enables confident reasoning about code behavior and simplifies debugging.

# Deterministic - pure function
def calculate_discount(price, discount_rate)
  price * (1 - discount_rate)
end

# Non-deterministic - impure function
def calculate_time_based_discount(price)
  hour = Time.now.hour
  discount_rate = hour < 12 ? 0.1 : 0.2
  price * (1 - discount_rate)
end

No Side Effects: Pure functions do not modify state outside their scope. They avoid mutating arguments, changing instance variables, altering class variables, writing to files, making network requests, printing output, or modifying databases. The function's only observable effect is its return value.

# No side effects - pure function
def format_name(first_name, last_name)
  "#{last_name}, #{first_name}"
end

# Has side effects - impure function
def format_and_log_name(first_name, last_name)
  formatted = "#{last_name}, #{first_name}"
  puts "Formatted name: #{formatted}"  # Side effect: I/O
  formatted
end

Referential Transparency: Any function call can be replaced with its return value without altering program behavior. This property emerges from determinism and lack of side effects. Referential transparency enables aggressive compiler optimizations, memoization strategies, and parallel execution.

# Referentially transparent
def square(x)
  x * x
end

result = square(5) + square(5)
# Can be replaced with: result = 25 + 25

# Not referentially transparent
def get_next_id
  @id_counter += 1
end

result = get_next_id + get_next_id
# Cannot be replaced with a constant value

Input Immutability: Pure functions treat input parameters as immutable values. They never modify arguments passed to them, instead creating and returning new values when transformations are needed.

# Respects input immutability - pure
def add_element(array, element)
  array + [element]  # Returns new array
end

original = [1, 2, 3]
result = add_element(original, 4)
# original: [1, 2, 3] (unchanged)
# result: [1, 2, 3, 4]

# Violates input immutability - impure
def add_element_mutating(array, element)
  array << element  # Modifies argument
  array
end

original = [1, 2, 3]
result = add_element_mutating(original, 4)
# original: [1, 2, 3, 4] (modified!)
# result: [1, 2, 3, 4]

Pure functions compose cleanly because they lack dependencies between invocations. The output of one pure function can serve as input to another without concern for hidden state or ordering requirements. This composability enables building complex operations from simple, testable components.

Ruby Implementation

Ruby provides several approaches for implementing pure functions despite being primarily object-oriented. The language supports functional programming patterns through its method definition syntax, lambda expressions, and immutable data structures.

Basic Pure Function Implementation: Methods defined at the module or class level can function as pure functions when they avoid instance variables and mutable operations.

module MathOperations
  def self.calculate_compound_interest(principal, rate, years)
    principal * ((1 + rate) ** years)
  end
  
  def self.calculate_payment(principal, rate, periods)
    (principal * rate * (1 + rate) ** periods) / 
    ((1 + rate) ** periods - 1)
  end
end

MathOperations.calculate_compound_interest(1000, 0.05, 10)
# => 1628.89 (always returns same result)

Lambda and Proc Usage: Ruby's lambda syntax creates function objects that capture no state when written properly. Lambdas enforce argument count, making them suitable for pure function implementations.

# Pure function as lambda
calculate_area = ->(width, height) { width * height }
calculate_area.call(10, 5)  # => 50

# Composing pure lambdas
double = ->(x) { x * 2 }
square = ->(x) { x * x }
compose = ->(f, g) { ->(x) { f.call(g.call(x)) } }

double_then_square = compose.call(square, double)
double_then_square.call(3)  # => 36 ((3 * 2) ** 2)

Avoiding Mutation with Copy Operations: Ruby's methods for creating copies enable pure functions that transform data structures without modifying originals.

def update_user(user, updates)
  user.dup.tap do |updated_user|
    updates.each { |key, value| updated_user[key] = value }
  end
end

original_user = { name: "Alice", age: 30 }
updated_user = update_user(original_user, { age: 31 })
# original_user: { name: "Alice", age: 30 }
# updated_user: { name: "Alice", age: 31 }

Immutable Data Structures: While Ruby lacks built-in immutable collections, the language provides frozen objects and gems like Hamster or Ice Nine for immutability.

def add_item(list, item)
  list + [item]  # Array concatenation creates new array
end

def merge_config(base_config, overrides)
  base_config.merge(overrides)  # Hash merge creates new hash
end

base = { timeout: 30, retries: 3 }
custom = merge_config(base, { timeout: 60 })
# base: { timeout: 30, retries: 3 } (unchanged)
# custom: { timeout: 60, retries: 3 }

Method Chaining with Pure Functions: Ruby's enumerable methods like map, select, and reduce operate as pure functions when given pure transformation blocks.

def process_numbers(numbers)
  numbers
    .select { |n| n > 0 }
    .map { |n| n * 2 }
    .reduce(0, :+)
end

data = [1, -2, 3, -4, 5]
result = process_numbers(data)
# data: [1, -2, 3, -4, 5] (unchanged)
# result: 18

Pure Function Wrapper Pattern: Isolate side effects by wrapping impure operations and returning pure function interfaces.

def create_user_validator
  ->(user) do
    errors = []
    errors << "Name required" if user[:name].to_s.empty?
    errors << "Invalid age" if user[:age].to_i < 0
    { valid: errors.empty?, errors: errors }
  end
end

validator = create_user_validator
validator.call({ name: "Alice", age: 30 })  # => { valid: true, errors: [] }
validator.call({ name: "", age: -5 })       # => { valid: false, errors: [...] }

Ruby's emphasis on expressiveness allows pure function implementations that remain readable while adhering to functional programming principles. The language's flexibility accommodates both object-oriented and functional styles within the same codebase.

Practical Examples

Pure functions apply across diverse programming scenarios from data transformation to business logic calculation. The examples demonstrate how purity constraints influence design decisions.

Data Transformation Pipeline: Processing collections of data requires transformations that preserve original inputs while producing derived outputs.

# Pure functions for data processing
def normalize_text(text)
  text.downcase.strip.gsub(/\s+/, ' ')
end

def parse_csv_line(line)
  line.split(',').map(&:strip)
end

def extract_email_domain(email)
  email.split('@').last
end

# Composing pure functions
def process_user_data(csv_lines)
  csv_lines
    .map { |line| parse_csv_line(line) }
    .map { |fields| { name: fields[0], email: fields[1] } }
    .map { |user| user.merge(domain: extract_email_domain(user[:email])) }
end

csv_data = [
  "Alice Smith, alice@example.com",
  "Bob Jones, bob@test.org"
]
processed = process_user_data(csv_data)
# csv_data remains unchanged, processed contains transformed data

Price Calculation System: Business logic benefits from pure functions that calculate prices, discounts, and taxes without modifying state.

def calculate_subtotal(items)
  items.reduce(0) { |sum, item| sum + (item[:price] * item[:quantity]) }
end

def apply_discount(amount, discount_percentage)
  amount * (1 - discount_percentage / 100.0)
end

def calculate_tax(amount, tax_rate)
  amount * tax_rate
end

def calculate_final_price(items, discount_percentage, tax_rate)
  subtotal = calculate_subtotal(items)
  discounted = apply_discount(subtotal, discount_percentage)
  tax = calculate_tax(discounted, tax_rate)
  discounted + tax
end

order = [
  { price: 29.99, quantity: 2 },
  { price: 49.99, quantity: 1 }
]

final = calculate_final_price(order, 10, 0.08)
# => 104.37
# Can call repeatedly with same inputs, always get 104.37

Configuration Merging: Application configuration often requires combining multiple configuration sources without mutating original configurations.

def merge_configurations(base, *overrides)
  overrides.reduce(base) do |merged, override|
    deep_merge(merged, override)
  end
end

def deep_merge(hash1, hash2)
  hash1.merge(hash2) do |key, old_val, new_val|
    if old_val.is_a?(Hash) && new_val.is_a?(Hash)
      deep_merge(old_val, new_val)
    else
      new_val
    end
  end
end

default_config = {
  database: { host: 'localhost', port: 5432 },
  cache: { ttl: 3600 }
}

env_config = {
  database: { host: 'db.production.com' }
}

user_config = {
  cache: { ttl: 7200 }
}

final_config = merge_configurations(default_config, env_config, user_config)
# All input configs remain unchanged
# final_config contains merged result

Filter and Search Operations: Searching and filtering data structures works naturally with pure functions that test conditions without modifying inputs.

def filter_by_price_range(products, min_price, max_price)
  products.select do |product|
    product[:price] >= min_price && product[:price] <= max_price
  end
end

def filter_by_category(products, category)
  products.select { |product| product[:category] == category }
end

def sort_by_field(items, field, descending: false)
  sorted = items.sort_by { |item| item[field] }
  descending ? sorted.reverse : sorted
end

def search_products(products, min_price:, max_price:, category: nil, sort_by: :name)
  results = filter_by_price_range(products, min_price, max_price)
  results = filter_by_category(results, category) if category
  sort_by_field(results, sort_by)
end

products = [
  { name: "Laptop", price: 999, category: "Electronics" },
  { name: "Desk", price: 299, category: "Furniture" },
  { name: "Mouse", price: 25, category: "Electronics" }
]

results = search_products(
  products,
  min_price: 20,
  max_price: 1000,
  category: "Electronics",
  sort_by: :price
)
# products array unchanged, results contains filtered/sorted data

Statistical Calculations: Mathematical and statistical operations exemplify pure functions that perform calculations without side effects.

def calculate_mean(numbers)
  return 0 if numbers.empty?
  numbers.reduce(0.0, :+) / numbers.length
end

def calculate_variance(numbers)
  return 0 if numbers.length < 2
  mean = calculate_mean(numbers)
  squared_diffs = numbers.map { |n| (n - mean) ** 2 }
  calculate_mean(squared_diffs)
end

def calculate_standard_deviation(numbers)
  Math.sqrt(calculate_variance(numbers))
end

def calculate_statistics(numbers)
  {
    mean: calculate_mean(numbers),
    variance: calculate_variance(numbers),
    std_dev: calculate_standard_deviation(numbers),
    min: numbers.min,
    max: numbers.max
  }
end

data = [10, 20, 30, 40, 50]
stats = calculate_statistics(data)
# => { mean: 30.0, variance: 200.0, std_dev: 14.14, min: 10, max: 50 }

Each example maintains purity by avoiding state mutation, producing consistent results for identical inputs, and enabling safe concurrent execution without synchronization requirements.

Common Patterns

Several patterns emerge when designing systems around pure functions. These patterns address common challenges in maintaining functional purity while building practical applications.

Separation of Pure and Impure Code: Isolate side effects at system boundaries while keeping core logic pure. This pattern pushes I/O, database access, and stateful operations to the edges of the application.

# Pure core logic
def calculate_order_total(items, tax_rate)
  subtotal = items.sum { |item| item[:price] * item[:quantity] }
  tax = subtotal * tax_rate
  { subtotal: subtotal, tax: tax, total: subtotal + tax }
end

# Impure boundary layer
def process_order(order_id)
  items = database.fetch_order_items(order_id)  # Impure: database access
  tax_rate = config.fetch_tax_rate              # Impure: external config
  
  result = calculate_order_total(items, tax_rate)  # Pure: calculation
  
  database.save_order_total(order_id, result)   # Impure: database write
  send_confirmation_email(order_id, result)     # Impure: I/O
  
  result
end

Immutable Data Transformation: Create new data structures rather than modifying existing ones. This pattern ensures that pure functions never alter their inputs.

def add_timestamps(record)
  now = Time.now.utc.iso8601
  record.merge(created_at: now, updated_at: now)
end

def update_record(record, changes)
  record.merge(changes).merge(updated_at: Time.now.utc.iso8601)
end

def remove_sensitive_fields(record, sensitive_fields)
  record.reject { |key, _| sensitive_fields.include?(key) }
end

# Chaining transformations
def prepare_for_api(record)
  record
    .then { |r| remove_sensitive_fields(r, [:password, :ssn]) }
    .then { |r| add_timestamps(r) }
end

Function Composition: Build complex operations by combining simple pure functions. Composition preserves purity when all component functions remain pure.

def compose(*functions)
  ->(arg) do
    functions.reverse.reduce(arg) do |result, func|
      func.call(result)
    end
  end
end

# Individual pure functions
upcase_text = ->(text) { text.upcase }
trim_text = ->(text) { text.strip }
add_prefix = ->(text) { "PREFIX: #{text}" }

# Composed function
process_text = compose(add_prefix, upcase_text, trim_text)
process_text.call("  hello world  ")
# => "PREFIX: HELLO WORLD"

Dependency Injection for Pure Functions: Pass dependencies as parameters rather than accessing global state or class variables. This technique maintains purity by making all dependencies explicit.

# Impure - depends on global config
def calculate_discounted_price(price)
  discount_rate = AppConfig.discount_rate  # Hidden dependency
  price * (1 - discount_rate)
end

# Pure - discount_rate injected as parameter
def calculate_discounted_price(price, discount_rate)
  price * (1 - discount_rate)
end

# Pure - configuration object injected
def calculate_price_with_config(price, config)
  discounted = price * (1 - config[:discount_rate])
  taxed = discounted * (1 + config[:tax_rate])
  taxed
end

Builder Pattern for Complex Objects: Construct complex objects through chains of pure transformations rather than incremental mutation.

def create_base_user(name, email)
  { name: name, email: email, roles: [], preferences: {} }
end

def add_role(user, role)
  user.merge(roles: user[:roles] + [role])
end

def set_preference(user, key, value)
  updated_prefs = user[:preferences].merge(key => value)
  user.merge(preferences: updated_prefs)
end

def build_admin_user(name, email)
  create_base_user(name, email)
    .then { |u| add_role(u, :admin) }
    .then { |u| add_role(u, :moderator) }
    .then { |u| set_preference(u, :theme, 'dark') }
    .then { |u| set_preference(u, :notifications, true) }
end

admin = build_admin_user("Alice", "alice@example.com")

Memoization for Performance: Cache results of expensive pure function calls. Purity guarantees that cached results remain valid for identical inputs.

def memoize(func)
  cache = {}
  ->(*args) do
    cache[args] ||= func.call(*args)
  end
end

# Expensive pure function
def calculate_fibonacci(n)
  return n if n <= 1
  calculate_fibonacci(n - 1) + calculate_fibonacci(n - 2)
end

# Memoized version
fibonacci_cached = memoize(method(:calculate_fibonacci))
fibonacci_cached.call(100)  # Fast on subsequent calls

Validation and Error Handling with Pure Functions: Return result objects that encapsulate success or failure states rather than throwing exceptions or modifying state.

def validate_user_input(input)
  errors = []
  errors << "Name required" if input[:name].to_s.empty?
  errors << "Invalid email" unless input[:email] =~ /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  
  {
    valid: errors.empty?,
    errors: errors,
    data: input
  }
end

def process_if_valid(validation_result, processor)
  if validation_result[:valid]
    { success: true, data: processor.call(validation_result[:data]) }
  else
    { success: false, errors: validation_result[:errors] }
  end
end

# Usage
result = validate_user_input({ name: "Alice", email: "alice@example.com" })
result = process_if_valid(result, ->(data) { data.merge(processed: true) })

These patterns demonstrate how pure functions combine to build larger systems while maintaining functional purity principles throughout the application architecture.

Design Considerations

Choosing between pure and impure functions involves trade-offs between predictability, performance, and practical constraints. Understanding these trade-offs guides architectural decisions.

When Pure Functions Excel: Pure functions provide maximum benefit in calculation-heavy code, data transformation pipelines, and business logic that requires testing. Code that processes immutable data, performs mathematical operations, or implements algorithms benefits from purity's guarantees.

Testing pure functions requires no setup or teardown, no mocks for external dependencies, and no complex state management. Each test verifies that specific inputs produce expected outputs without concerning itself with side effects or execution order. This simplicity reduces test complexity and increases test reliability.

Concurrent and parallel execution becomes safe with pure functions. Multiple threads can execute pure functions simultaneously without race conditions, deadlocks, or synchronization overhead. This property enables straightforward parallelization for performance gains.

# Pure function safe for parallel execution
def process_chunk(data_chunk)
  data_chunk.map do |item|
    expensive_calculation(item[:value])
  end
end

# Can safely parallelize
results = Parallel.map(data_chunks) { |chunk| process_chunk(chunk) }

When Impure Functions Are Necessary: Applications must interact with the external world through I/O operations, database access, network requests, and user interfaces. These operations inherently involve side effects and state changes. File system operations, HTTP requests, and database queries cannot be pure functions.

Performance considerations sometimes favor mutation over copying. Creating new data structures on every modification incurs memory allocation and copying costs. For large data structures or performance-critical code, in-place mutation may prove more efficient than functional approaches.

# Pure approach - creates new array each iteration
def pure_process(items)
  items.reduce([]) do |results, item|
    results + [transform(item)]  # Creates new array each iteration
  end
end

# Impure approach - modifies array in place
def impure_process(items)
  results = []
  items.each do |item|
    results << transform(item)  # Modifies existing array
  end
  results
end

Stateful operations like random number generation, timestamp creation, or sequence generation cannot be pure. These operations inherently depend on external state or produce different results on each invocation.

Balancing Purity and Pragmatism: Effective architectures isolate impure operations at system boundaries while keeping core logic pure. The functional core, imperative shell pattern separates pure business logic from impure I/O operations.

# Pure core - business logic
module OrderPricing
  def self.calculate_total(items, discount_code, tax_rate)
    subtotal = items.sum { |item| item[:price] * item[:quantity] }
    discount = calculate_discount(subtotal, discount_code)
    taxable = subtotal - discount
    tax = taxable * tax_rate
    { subtotal: subtotal, discount: discount, tax: tax, total: taxable + tax }
  end
  
  def self.calculate_discount(amount, code)
    # Pure calculation based on discount code
  end
end

# Impure shell - I/O and coordination
class OrderProcessor
  def process_order(order_id)
    items = fetch_order_items(order_id)        # Impure: database
    discount_code = fetch_discount_code(order_id)  # Impure: database
    tax_rate = fetch_tax_rate                  # Impure: config
    
    result = OrderPricing.calculate_total(items, discount_code, tax_rate)
    
    save_order_total(order_id, result)         # Impure: database
    result
  end
end

Performance Trade-offs: Pure functions enable optimization through memoization and lazy evaluation but may incur costs from immutability. Profile performance-critical code sections to determine whether purity costs outweigh benefits.

Ruby's garbage collector handles short-lived objects efficiently, making functional approaches with frequent allocations viable for most use cases. Reserve mutation-based optimization for proven bottlenecks identified through profiling.

Team and Codebase Considerations: Teams familiar with object-oriented programming may face learning curves adopting functional patterns. Gradual introduction of pure functions in new code while maintaining existing patterns can ease transitions.

Mixing pure and impure code requires clear boundaries and conventions. Naming conventions, module organization, or documentation can distinguish pure from impure functions, helping developers understand which functions have side effects.

The decision to use pure functions depends on specific requirements, team expertise, and performance constraints. Most applications benefit from pure core logic with impure boundaries rather than enforcing purity throughout the entire codebase.

Testing Approaches

Pure functions simplify testing by eliminating external dependencies and state management. Tests for pure functions focus exclusively on input-output relationships without setup complexity or side effect verification.

Basic Pure Function Testing: Tests verify that specific inputs produce expected outputs. No mocks, stubs, or complex test fixtures required.

require 'minitest/autorun'

class PureFunctionTest < Minitest::Test
  def test_calculate_discount
    assert_equal 90.0, calculate_discount(100.0, 0.10)
    assert_equal 50.0, calculate_discount(100.0, 0.50)
    assert_equal 100.0, calculate_discount(100.0, 0.0)
  end
  
  def test_merge_configurations
    base = { timeout: 30, retries: 3 }
    override = { timeout: 60 }
    
    result = merge_configurations(base, override)
    
    assert_equal 60, result[:timeout]
    assert_equal 3, result[:retries]
    assert_equal 30, base[:timeout]  # Verifies immutability
  end
end

Property-Based Testing: Test pure functions against properties that should hold for all valid inputs rather than specific example cases. Property-based testing generates random inputs to find edge cases.

require 'rantly'
require 'rantly/minitest_extensions'

class PropertyTest < Minitest::Test
  def test_sort_is_idempotent
    property_of {
      array(range(1, 100)) { integer }
    }.check { |arr|
      sorted_once = sort_array(arr)
      sorted_twice = sort_array(sorted_once)
      assert_equal sorted_once, sorted_twice
    }
  end
  
  def test_filter_reduces_or_maintains_size
    property_of {
      array(range(1, 100)) { integer }
    }.check { |arr|
      filtered = filter_positive(arr)
      assert filtered.length <= arr.length
    }
  end
end

Testing Edge Cases: Pure functions enable straightforward edge case testing without state management concerns. Test boundary conditions, empty inputs, and extreme values directly.

class EdgeCaseTest < Minitest::Test
  def test_calculate_mean_with_empty_array
    assert_equal 0, calculate_mean([])
  end
  
  def test_calculate_mean_with_single_element
    assert_equal 42, calculate_mean([42])
  end
  
  def test_calculate_mean_with_negative_numbers
    assert_equal 0, calculate_mean([-10, 10])
  end
  
  def test_merge_with_empty_hashes
    assert_equal({}, merge_configurations({}, {}))
  end
  
  def test_filter_with_no_matches
    result = filter_by_price_range(products, 1000, 2000)
    assert_equal [], result
  end
end

Composition Testing: Test composed functions by verifying both individual components and composite behavior. Pure function composition ensures that testing components independently validates the composition.

class CompositionTest < Minitest::Test
  def test_individual_transformations
    assert_equal "HELLO", upcase_text("hello")
    assert_equal "hello", trim_text("  hello  ")
  end
  
  def test_composed_transformation
    process = compose(upcase_text, trim_text)
    assert_equal "HELLO", process.call("  hello  ")
  end
  
  def test_composition_order_matters
    process1 = compose(trim_text, upcase_text)
    process2 = compose(upcase_text, trim_text)
    
    input = "  hello  "
    # Both should produce same result if order-independent
    assert_equal process1.call(input), process2.call(input)
  end
end

Determinism Verification: Tests can verify function purity by calling functions multiple times with identical inputs and asserting identical outputs.

class DeterminismTest < Minitest::Test
  def test_function_is_deterministic
    input = [1, 2, 3, 4, 5]
    
    result1 = process_numbers(input)
    result2 = process_numbers(input)
    result3 = process_numbers(input)
    
    assert_equal result1, result2
    assert_equal result2, result3
  end
  
  def test_function_does_not_modify_input
    input = { name: "Alice", age: 30 }
    input_copy = input.dup
    
    update_user(input, { age: 31 })
    
    assert_equal input_copy, input  # Input unchanged
  end
end

Parallel Execution Testing: Verify that pure functions produce consistent results when executed concurrently, demonstrating thread safety.

require 'concurrent'

class ConcurrencyTest < Minitest::Test
  def test_concurrent_execution_produces_consistent_results
    inputs = (1..100).to_a
    expected = inputs.map { |n| expensive_calculation(n) }
    
    # Execute in parallel
    results = Concurrent::Array.new
    inputs.each do |input|
      Concurrent::Future.execute do
        results << expensive_calculation(input)
      end
    end
    
    sleep 0.1 until results.length == inputs.length
    
    assert_equal expected.sort, results.sort
  end
end

Testing pure functions requires significantly less code and complexity than testing stateful or impure functions. The absence of side effects and external dependencies makes tests faster, more reliable, and easier to understand.

Common Pitfalls

Maintaining function purity requires attention to subtle behaviors that can introduce impurity. Several common mistakes compromise purity despite appearing innocent.

Hidden State Through Object Methods: Methods that access instance variables or class variables become impure even when they appear stateless. The hidden state dependency violates purity requirements.

class Calculator
  def initialize
    @precision = 2
  end
  
  # Impure - depends on instance variable
  def round_number(number)
    number.round(@precision)
  end
end

# Pure alternative - explicit parameter
def round_number(number, precision)
  number.round(precision)
end

Mutating Method Arguments: Ruby's mutating methods like push, delete, or upcase! modify their receivers, creating side effects. Pure functions must avoid calling mutating methods on arguments.

# Impure - modifies argument
def add_item_impure(array, item)
  array << item  # Modifies array in place
  array
end

# Pure - creates new array
def add_item_pure(array, item)
  array + [item]  # Returns new array
end

# Subtle impurity - map! modifies array
def transform_items_impure(items)
  items.map! { |item| item * 2 }  # map! modifies in place
end

# Pure - map creates new array
def transform_items_pure(items)
  items.map { |item| item * 2 }  # map returns new array
end

Time and Random Number Dependencies: Functions that call Time.now, rand, or other non-deterministic sources become impure. These dependencies must be injected as parameters for purity.

# Impure - depends on current time
def is_business_hours?
  hour = Time.now.hour
  hour >= 9 && hour < 17
end

# Pure - time injected as parameter
def is_business_hours?(current_time)
  hour = current_time.hour
  hour >= 9 && hour < 17
end

# Impure - generates random value
def generate_discount
  rand(10..30)
end

# Pure - random value injected
def apply_discount(price, discount_percentage)
  price * (1 - discount_percentage / 100.0)
end

Default Parameter Side Effects: Default parameter values evaluated at method call time can introduce impurity if they access external state.

# Impure - default parameter uses current time
def create_record(data, timestamp: Time.now)
  data.merge(created_at: timestamp)
end

# Pure - timestamp required as explicit parameter
def create_record(data, timestamp)
  data.merge(created_at: timestamp)
end

External Configuration Access: Reading from configuration objects, environment variables, or globals creates hidden dependencies that violate purity.

# Impure - reads from global config
def calculate_price_with_tax(price)
  tax_rate = AppConfig.tax_rate  # Hidden dependency
  price * (1 + tax_rate)
end

# Pure - configuration injected
def calculate_price_with_tax(price, tax_rate)
  price * (1 + tax_rate)
end

Closure State Capture: Lambdas and procs that capture mutable variables from outer scopes can become impure if those variables change.

counter = 0

# Impure - captures and modifies external variable
increment = -> {
  counter += 1  # Side effect
}

# Pure - no captured state
create_counter = ->(initial) {
  ->(increment) { initial + increment }
}

counter = create_counter.call(0)
counter.call(1)  # => 1 (pure)

Hash and Array Reference Mutations: Receiving a hash or array and modifying nested structures creates side effects even if the top-level reference remains unchanged.

# Impure - modifies nested structure
def add_address_field(user, field, value)
  user[:address][field] = value  # Modifies nested hash
  user
end

# Pure - creates new nested structure
def add_address_field(user, field, value)
  updated_address = user[:address].merge(field => value)
  user.merge(address: updated_address)
end

Exception Handling Side Effects: Rescue blocks that log, send notifications, or modify external state create side effects that compromise purity.

# Impure - logging side effect in rescue
def safe_divide(a, b)
  a / b
rescue ZeroDivisionError => e
  logger.error("Division by zero")  # Side effect
  nil
end

# Pure - returns result indicating error
def safe_divide(a, b)
  return { success: false, error: "Division by zero" } if b == 0
  { success: true, value: a / b }
end

Database and I/O Operations: Any function that reads from or writes to databases, files, networks, or other I/O sources becomes impure. Isolate these operations from pure core logic.

Recognizing these pitfalls enables writing genuinely pure functions that deliver reliability, testability, and reasoning benefits. Vigilance during code review catches purity violations before they compromise system design.

Reference

Pure Function Characteristics

Characteristic Description Requirement
Deterministic Same input always produces same output Must return identical results for identical arguments
No Side Effects No observable changes outside function scope Cannot modify arguments, globals, I/O, or external state
Referential Transparency Call can be replaced with return value Function evaluation has no observable effects beyond return value
Input Immutability Arguments remain unchanged Cannot call mutating methods on parameters
No Hidden Dependencies All inputs explicit as parameters Cannot access instance variables, class variables, globals

Purity Violations in Ruby

Violation Example Pure Alternative
Instance variable access @config in method Pass config as parameter
Mutating array methods array.push(item) array + [item]
Time dependencies Time.now in logic Pass time as parameter
Random number generation rand(100) Pass random value as parameter
I/O operations File.read('config.yml') Pass file contents as parameter
Global state access $config[:key] Pass config as parameter
Class variable access @@counter in method Pass counter as parameter
Destructive string methods string.gsub! string.gsub (non-mutating)

Pure Function Testing Patterns

Pattern Purpose Example
Input-Output Verification Verify specific inputs produce expected outputs assert_equal 50, calculate(10, 5)
Edge Case Testing Test boundary conditions Test empty arrays, nil values, zero
Property-Based Testing Verify properties hold for all inputs Idempotence, commutativity verification
Immutability Verification Confirm inputs remain unchanged Compare input before and after call
Determinism Testing Multiple calls produce identical results Call 100 times, assert all equal
Composition Testing Test composed functions Verify component and composite behavior
Concurrent Execution Verify thread safety Execute function across multiple threads

Ruby Immutable Operations

Mutable (Impure) Immutable (Pure) Result
array.push(item) array + [item] New array with item added
array.delete(item) array - [item] New array with item removed
hash[:key] = value hash.merge(key: value) New hash with key added
string.upcase! string.upcase New uppercased string
array.map! array.map New transformed array
hash.delete(key) hash.reject { k == key } New hash without key
array.sort! array.sort New sorted array
string.gsub! string.gsub New modified string

Function Composition Helpers

# Compose functions right to left
def compose(*functions)
  ->(arg) { functions.reverse.reduce(arg) { |result, f| f.call(result) } }
end

# Pipe functions left to right
def pipe(*functions)
  ->(arg) { functions.reduce(arg) { |result, f| f.call(result) } }
end

# Partial application
def partial(func, *partial_args)
  ->(*remaining_args) { func.call(*partial_args, *remaining_args) }
end

# Memoization wrapper
def memoize(func)
  cache = {}
  ->(*args) { cache[args] ||= func.call(*args) }
end

Common Pure Function Signatures

# Data transformation
transform: (data, transformation_rules) -> transformed_data

# Filtering
filter: (collection, predicate) -> filtered_collection

# Calculation
calculate: (input_values, parameters) -> result

# Validation
validate: (data, validation_rules) -> validation_result

# Mapping
map_values: (collection, mapping_function) -> mapped_collection

# Reduction
reduce_data: (collection, initial_value, reducer) -> accumulated_value

# Composition
compose_operations: (operations, initial_data) -> final_result