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