Overview
Function composition is a mathematical and programming concept where two or more functions combine to produce a new function. In mathematical notation, composing functions f and g creates a new function (f ∘ g)(x) = f(g(x)), where g executes first and its result feeds into f.
This technique originates from mathematical function theory but applies directly to software development. Rather than nesting function calls or storing intermediate results in variables, composition creates a pipeline where data flows through a series of transformations.
# Without composition - nested calls
result = uppercase(trim(input))
# With composition - creating a new function
process = uppercase ∘ trim
result = process.call(input)
Function composition supports building complex operations from simple, reusable components. Each function performs one specific transformation, and composition chains these transformations into complete workflows. This approach reduces coupling between components since each function operates independently without knowledge of the others in the chain.
The concept appears throughout software development: Unix pipes compose command-line tools, middleware stacks in web frameworks compose request handlers, and data processing pipelines compose transformation steps. Understanding composition provides a foundation for recognizing and applying these patterns across different contexts.
Key Principles
Function composition operates on the principle that functions are first-class values that can be passed, returned, and combined like any other data type. A function accepts input, produces output, and causes no side effects beyond returning its result. This property enables safe composition since each function's behavior remains predictable regardless of its position in a composition chain.
Mathematical Foundation
The composition of functions f and g, written as f ∘ g, creates a new function h where h(x) = f(g(x)). Composition is associative: (f ∘ g) ∘ h equals f ∘ (g ∘ h). This property means the grouping of compositions doesn't affect the result, allowing flexible construction of complex function chains.
# Demonstrating associativity
add_ten = ->(x) { x + 10 }
multiply_two = ->(x) { x * 2 }
square = ->(x) { x ** 2 }
# These produce identical results
result1 = add_ten.call(multiply_two.call(square.call(3)))
result2 = add_ten.call(multiply_two.call(9))
# => 28
Data Flow Direction
Composition establishes a data flow direction. In traditional mathematical notation (f ∘ g), execution proceeds right to left: g executes first, then f receives g's output. Some programming languages and APIs reverse this to left-to-right execution for improved readability, matching the natural reading order of most written languages.
# Right-to-left (mathematical)
composed = f ∘ g # g executes, then f
# Left-to-right (pipeline style)
composed = g >> f # g executes, then f
Purity and Referential Transparency
Pure functions form ideal composition building blocks. A pure function always returns the same output for the same input and produces no observable side effects. This predictability means composed functions behave consistently and can be reasoned about locally without considering the entire system state.
# Pure function - composable
double = ->(x) { x * 2 }
# Impure function - problematic for composition
counter = 0
increment_and_double = ->(x) { counter += 1; x * 2 } # Side effect
Type Compatibility
Successful composition requires type compatibility between functions. The output type of one function must match the input type of the next function in the chain. Type mismatches break composition and generate runtime errors in dynamically typed languages or compilation errors in statically typed languages.
Identity and Unit
The identity function serves as the composition identity element. Composing any function with the identity function returns the original function unchanged: f ∘ identity = identity ∘ f = f. This property provides a neutral element for composition chains, similar to how zero functions in addition or one in multiplication.
identity = ->(x) { x }
double = ->(x) { x * 2 }
# Composing with identity returns the original function
composed = identity >> double
composed.call(5) # => 10 (same as double.call(5))
Ruby Implementation
Ruby provides multiple mechanisms for function composition through Proc, lambda, and Method objects. Each offers different characteristics and composition capabilities.
Proc and Lambda Composition
Ruby 2.6 introduced composition operators for Proc objects. The << operator composes right-to-left (f << g applies g then f), while >> composes left-to-right (f >> g applies f then g).
add_five = ->(x) { x + 5 }
multiply_three = ->(x) { x * 3 }
square = ->(x) { x ** 2 }
# Right-to-left composition (mathematical notation)
# Read: multiply_three after add_five
process_rtl = multiply_three << add_five
process_rtl.call(10) # => (10 + 5) * 3 = 45
# Left-to-right composition (pipeline notation)
# Read: add_five then multiply_three
process_ltr = add_five >> multiply_three
process_ltr.call(10) # => (10 + 5) * 3 = 45
# Chaining multiple functions
complex = add_five >> multiply_three >> square
complex.call(2) # => ((2 + 5) * 3) ** 2 = 441
Method Object Composition
Method objects obtained through the method method support composition operators after conversion to Proc using to_proc:
class Calculator
def add_ten(x)
x + 10
end
def double(x)
x * 2
end
def format(x)
"Result: #{x}"
end
end
calc = Calculator.new
composed = calc.method(:add_ten).to_proc >>
calc.method(:double).to_proc >>
calc.method(:format).to_proc
composed.call(5) # => "Result: 30"
Building a Composition Helper
Ruby doesn't provide built-in composition for methods without conversion to Proc. A helper method can simplify composition of multiple functions:
def compose(*functions)
functions.reduce do |f, g|
->(x) { f.call(g.call(x)) }
end
end
add_one = ->(x) { x + 1 }
double = ->(x) { x * 2 }
negate = ->(x) { -x }
composed = compose(negate, double, add_one)
composed.call(5) # => -((5 + 1) * 2) = -12
Partial Application with Composition
Combining partial application with composition creates configurable transformation pipelines:
multiply_by = ->(factor) { ->(x) { x * factor } }
add_value = ->(value) { ->(x) { x + value } }
clamp = ->(min, max) { ->(x) { [[x, min].max, max].min } }
# Create specialized transformations
double = multiply_by.call(2)
add_ten = add_value.call(10)
limit = clamp.call(0, 100)
# Compose into a processing pipeline
normalize = add_ten >> double >> limit
normalize.call(40) # => 100 (clamped)
normalize.call(-5) # => 10
Handling Multiple Arguments
Standard composition works with single-argument functions. Multi-argument functions require currying or parameter binding:
# Using curry for multi-argument functions
power = ->(base, exp) { base ** exp }
curried_power = power.curry
square = curried_power.call(2) # Partially applied with base=2
cube = curried_power.call(3)
# Compose with curried functions
add_five = ->(x) { x + 5 }
composed = add_five >> square
composed.call(3) # => 64 (8 ** 2)
Composition with Symbol#to_proc
Ruby's Symbol#to_proc enables method chaining that resembles composition:
words = ["hello", "world", "ruby"]
# Using Symbol#to_proc in a pipeline style
result = words
.map(&:upcase)
.map(&:reverse)
.map { |w| w + "!" }
# => ["OLLEH!", "DLROW!", "YBUR!"]
# Creating reusable transformations
upcase = :upcase.to_proc
reverse = :reverse.to_proc
process_word = ->(w) { reverse.call(upcase.call(w)) + "!" }
words.map { |w| process_word.call(w) }
Common Patterns
Pipeline Pattern
The pipeline pattern chains transformations that each modify data and pass results forward. This pattern appears frequently in data processing and ETL operations:
# Data processing pipeline
parse_csv = ->(text) { text.split("\n").map { |line| line.split(",") } }
filter_valid = ->(rows) { rows.reject { |row| row.any?(&:empty?) } }
extract_numbers = ->(rows) { rows.map { |row| row.map(&:to_i) } }
sum_rows = ->(rows) { rows.map { |row| row.sum } }
calculate_total = ->(sums) { sums.sum }
pipeline = parse_csv >> filter_valid >> extract_numbers >>
sum_rows >> calculate_total
data = "1,2,3\n4,5,6\n7,,9\n10,11,12"
pipeline.call(data) # => 69 (sum of valid rows)
Validation Chain
Composing validation functions creates comprehensive validation logic where each validator checks specific criteria:
# Individual validators return the value or raise an error
not_empty = ->(val) { val.empty? ? (raise "Empty value") : val }
min_length = ->(min) { ->(val) { val.length >= min ? val : (raise "Too short") } }
max_length = ->(max) { ->(val) { val.length <= max ? val : (raise "Too long") } }
alphanumeric = ->(val) { val.match?(/\A[a-zA-Z0-9]+\z/) ? val : (raise "Invalid chars") }
# Compose validators
validate_username = not_empty >>
min_length.call(3) >>
max_length.call(20) >>
alphanumeric
begin
validate_username.call("user123") # => "user123" (valid)
validate_username.call("ab") # Raises "Too short"
rescue => e
puts e.message
end
Transformation Stack
Building stacks of transformations that apply in sequence, commonly used in text processing, image manipulation, or request/response handling:
# Text transformation stack
remove_whitespace = ->(text) { text.gsub(/\s+/, ' ').strip }
normalize_case = ->(text) { text.downcase }
remove_punctuation = ->(text) { text.gsub(/[[:punct:]]/, '') }
remove_articles = ->(text) { text.gsub(/\b(a|an|the)\b/i, '').gsub(/\s+/, ' ').strip }
split_words = ->(text) { text.split(' ') }
text_processor = remove_whitespace >>
normalize_case >>
remove_punctuation >>
remove_articles >>
split_words
input = " The Quick, Brown FOX! "
text_processor.call(input) # => ["quick", "brown", "fox"]
Strategy Selection Pattern
Composing functions based on runtime conditions creates flexible processing strategies:
# Different processing strategies
aggressive_clean = ->(text) { text.gsub(/[^a-z0-9]/i, '').downcase }
gentle_clean = ->(text) { text.gsub(/[^\w\s-]/, '').strip.downcase }
format_snake = ->(text) { text.gsub(/\s+/, '_') }
format_kebab = ->(text) { text.gsub(/\s+/, '-') }
def build_processor(cleaning: :gentle, format: :snake)
cleaner = cleaning == :aggressive ? aggressive_clean : gentle_clean
formatter = format == :kebab ? format_kebab : format_snake
cleaner >> formatter
end
# Create different processors for different contexts
slug_processor = build_processor(cleaning: :aggressive, format: :kebab)
tag_processor = build_processor(cleaning: :gentle, format: :snake)
slug_processor.call("Hello, World!") # => "hello-world"
tag_processor.call("Ruby on Rails") # => "ruby_on_rails"
Decorator Pattern with Composition
Composing decorator functions adds behavior to core functionality without modifying the original function:
# Core operation
fetch_data = ->(id) { "data_#{id}" }
# Decorators add behavior
add_logging = ->(f) do
->(x) do
puts "Calling with: #{x}"
result = f.call(x)
puts "Result: #{result}"
result
end
end
add_caching = ->(f) do
cache = {}
->(x) do
cache[x] ||= f.call(x)
end
end
add_error_handling = ->(f) do
->(x) do
begin
f.call(x)
rescue => e
puts "Error: #{e.message}"
nil
end
end
end
# Compose decorators
enhanced_fetch = add_error_handling.call(
add_caching.call(
add_logging.call(fetch_data)
)
)
enhanced_fetch.call(123) # Logs, caches, and handles errors
Practical Examples
Building a Data Processing Pipeline
Real-world data processing often requires multiple transformation steps. Composition organizes these steps into clear, testable units:
# Define individual transformation steps
parse_json = ->(json_string) do
require 'json'
JSON.parse(json_string)
end
extract_users = ->(data) { data['users'] || [] }
filter_active = ->(users) do
users.select { |u| u['status'] == 'active' }
end
enrich_with_metadata = ->(users) do
users.map do |u|
u.merge({
'processed_at' => Time.now.iso8601,
'full_name' => "#{u['first_name']} #{u['last_name']}"
})
end
end
sort_by_name = ->(users) do
users.sort_by { |u| u['full_name'] }
end
format_output = ->(users) do
users.map do |u|
"#{u['full_name']} (#{u['email']}) - #{u['status']}"
end.join("\n")
end
# Compose the complete pipeline
user_processor = parse_json >>
extract_users >>
filter_active >>
enrich_with_metadata >>
sort_by_name >>
format_output
json_data = '{"users": [
{"first_name": "John", "last_name": "Doe", "email": "john@example.com", "status": "active"},
{"first_name": "Jane", "last_name": "Smith", "email": "jane@example.com", "status": "inactive"},
{"first_name": "Bob", "last_name": "Johnson", "email": "bob@example.com", "status": "active"}
]}'
puts user_processor.call(json_data)
# => Bob Johnson (bob@example.com) - active
# John Doe (john@example.com) - active
Request Processing Middleware
Web frameworks use composition to build middleware stacks. Each middleware function handles one aspect of request processing:
# Middleware functions that wrap the next handler
add_request_id = ->(handler) do
->(request) do
request[:id] = SecureRandom.uuid
handler.call(request)
end
end
log_request = ->(handler) do
->(request) do
puts "[#{request[:id]}] #{request[:method]} #{request[:path]}"
result = handler.call(request)
puts "[#{request[:id]}] Status: #{result[:status]}"
result
end
end
authenticate = ->(handler) do
->(request) do
if request[:headers]['Authorization']
request[:user] = "authenticated_user"
handler.call(request)
else
{ status: 401, body: "Unauthorized" }
end
end
end
parse_body = ->(handler) do
->(request) do
if request[:body]
request[:parsed_body] = JSON.parse(request[:body])
end
handler.call(request)
end
end
# Base handler
app = ->(request) do
{
status: 200,
body: "Hello #{request[:user]}",
request_id: request[:id]
}
end
# Compose middleware stack
middleware_stack = add_request_id.call(
log_request.call(
authenticate.call(
parse_body.call(app)
)
)
)
request = {
method: 'GET',
path: '/api/users',
headers: { 'Authorization' => 'Bearer token123' },
body: '{"key": "value"}'
}
response = middleware_stack.call(request)
Financial Calculation Pipeline
Financial calculations benefit from composition where each function performs one calculation step with clear semantics:
# Define calculation functions
apply_discount = ->(percentage) do
->(amount) { amount * (1 - percentage / 100.0) }
end
add_tax = ->(rate) do
->(amount) { amount * (1 + rate / 100.0) }
end
apply_shipping = ->(method) do
shipping_costs = { standard: 10, express: 25, overnight: 50 }
->(amount) { amount + shipping_costs[method] }
end
round_to_cents = ->(amount) do
(amount * 100).round / 100.0
end
format_currency = ->(amount) do
"$#{'%.2f' % amount}"
end
# Build different pricing calculators
standard_checkout = apply_discount.call(10) >>
add_tax.call(8.5) >>
apply_shipping.call(:standard) >>
round_to_cents >>
format_currency
premium_checkout = apply_discount.call(15) >>
add_tax.call(8.5) >>
apply_shipping.call(:express) >>
round_to_cents >>
format_currency
standard_checkout.call(100) # => "$107.65"
premium_checkout.call(100) # => "$117.22"
Content Sanitization and Normalization
Content processing requires multiple sanitization and normalization steps. Composition chains these operations cleanly:
# HTML processing functions
strip_html_tags = ->(html) do
html.gsub(/<[^>]+>/, '')
end
decode_entities = ->(text) do
require 'cgi'
CGI.unescapeHTML(text)
end
normalize_whitespace = ->(text) do
text.gsub(/\s+/, ' ').strip
end
truncate = ->(max_length) do
->(text) do
if text.length > max_length
text[0...max_length].rstrip + '...'
else
text
end
end
end
capitalize_sentences = ->(text) do
text.gsub(/([.!?]\s+)(\w)/) { |m| "#{$1}#{$2.upcase}" }
.sub(/\A(\w)/) { |m| m.upcase }
end
# Compose sanitization pipeline
sanitize_html = strip_html_tags >>
decode_entities >>
normalize_whitespace >>
truncate.call(200) >>
capitalize_sentences
html_input = "<p>Hello & welcome to our site! <strong>Enjoy</strong> your stay.</p>"
sanitize_html.call(html_input)
# => "Hello & welcome to our site! Enjoy your stay."
Design Considerations
When to Use Function Composition
Function composition fits scenarios where data flows through a series of transformations. ETL pipelines, request/response processing, data validation, and formatting operations benefit from composition's clarity. The pattern works best when each transformation step has a single responsibility and functions remain pure or have minimal side effects.
Consider composition when building reusable transformation chains. If the same sequence of operations applies in multiple contexts, composing them into a named function reduces duplication. Composition also improves testability since each component function tests independently.
# Without composition - repeated logic
def process_user_name_for_display(name)
name.strip.downcase.capitalize
end
def process_user_name_for_search(name)
name.strip.downcase
end
# With composition - reusable components
strip = :strip.to_proc
downcase = :downcase.to_proc
capitalize = :capitalize.to_proc
for_display = strip >> downcase >> capitalize
for_search = strip >> downcase
for_display.call(" JOHN ") # => "John"
for_search.call(" JOHN ") # => "john"
Trade-offs
Composition introduces abstraction that can obscure program flow. Deeply nested compositions or long chains make debugging difficult since stack traces show multiple composed functions rather than clear execution paths. Each composition level adds function call overhead, though this cost remains negligible for most applications.
Type safety decreases in dynamically typed languages like Ruby. Composition connects functions based on developer intent, but the runtime doesn't verify that function outputs match subsequent function inputs until execution. Type mismatches surface as runtime errors rather than early feedback.
# Type mismatch caught only at runtime
to_integer = ->(x) { x.to_i }
double = ->(x) { x * 2 }
upcase = ->(x) { x.upcase } # Expects string
# This composes successfully but fails at runtime
bad_composition = to_integer >> double >> upcase
bad_composition.call("5") # NoMethodError: undefined method 'upcase' for 10
Composing functions with side effects creates unpredictable behavior. If composed functions modify external state or depend on mutable state, the composition's behavior varies based on execution context and ordering. Pure functions eliminate these issues but aren't always practical for real-world applications.
Alternatives to Composition
Method chaining provides similar benefits with more conventional Ruby style. Rather than composing separate functions, method chaining calls methods sequentially on intermediate results. This approach feels more natural in Ruby and provides better IDE support and error messages.
# Function composition
trim = :strip.to_proc
downcase = :downcase.to_proc
reverse = :reverse.to_proc
process = trim >> downcase >> reverse
process.call(" HELLO ") # => "olleh"
# Method chaining
" HELLO ".strip.downcase.reverse # => "olleh"
Explicit intermediate variables offer maximum clarity at the cost of verbosity. Assigning each transformation result to a named variable documents the process explicitly and simplifies debugging since each step can be inspected independently.
# Explicit steps
input = get_data()
validated = validate(input)
normalized = normalize(validated)
enriched = enrich(normalized)
result = format(enriched)
Object-oriented design with command objects encapsulates transformation logic within classes. Each command class implements a common interface, and a coordinator chains commands together. This approach provides better structure for complex transformations requiring state management.
Composition vs Method Chaining
Method chaining reads left-to-right and integrates naturally with Ruby's object-oriented design. Each method call returns an object that supports subsequent method calls. This pattern works well with Ruby's standard library and aligns with developer expectations.
Function composition separates data transformation from data structure. Composed functions work with any compatible data type, while method chaining ties operations to specific classes. Composition provides more flexibility but requires more explicit plumbing.
# Method chaining - tied to String class
text = "hello world"
result = text.upcase.reverse.chars.sort.join
# => "WROLDLEH"
# Function composition - works with any type
upcase = ->(x) { x.upcase }
reverse = ->(x) { x.reverse }
to_chars = ->(x) { x.chars }
sort = ->(x) { x.sort }
join = ->(x) { x.join }
processor = upcase >> reverse >> to_chars >> sort >> join
processor.call("hello world") # => "WROLDLEH"
Performance Implications
Each composition level adds function call overhead. For simple transformations on small datasets, this overhead remains negligible. Processing large datasets or performance-critical code paths may see measurable impact from deeply nested compositions.
Composition prevents certain optimizations. Fusing multiple transformation steps into a single pass improves performance by eliminating intermediate allocations and reducing iteration count. Composed functions execute sequentially, creating intermediate results between each step.
# Composed functions - multiple passes
add_ten = ->(arr) { arr.map { |x| x + 10 } }
double = ->(arr) { arr.map { |x| x * 2 } }
filter_even = ->(arr) { arr.select(&:even?) }
composed = add_ten >> double >> filter_even
composed.call([1, 2, 3, 4, 5]) # Three passes over the data
# Fused operation - single pass
def process(arr)
arr.map { |x| (x + 10) * 2 }.select(&:even?)
end
process([1, 2, 3, 4, 5]) # One pass over the data
Reference
Composition Operators
| Operator | Direction | Execution Order | Example |
|---|---|---|---|
| << | Right-to-left | g executes first, then f | f << g |
| >> | Left-to-right | f executes first, then g | f >> g |
Function Types for Composition
| Type | Description | Composition Suitability |
|---|---|---|
| Pure Function | No side effects, deterministic output | Ideal for composition |
| Lambda | Ruby proc with strict argument checking | Full composition support |
| Proc | Ruby proc with flexible arguments | Full composition support |
| Method Object | Bound method reference | Requires to_proc conversion |
| Symbol to_proc | Method name as callable | Works with map, select, etc |
Common Composition Patterns
| Pattern | Structure | Use Case |
|---|---|---|
| Pipeline | f >> g >> h | Sequential data transformation |
| Validation Chain | v1 >> v2 >> v3 | Multi-step validation |
| Decorator Stack | d1(d2(d3(f))) | Adding behavior to functions |
| Strategy Composition | condition ? (a >> b) : (c >> d) | Conditional processing paths |
| Transformation Chain | parse >> filter >> transform >> format | ETL operations |
Type Compatibility Requirements
| Situation | Requirement | Result if Violated |
|---|---|---|
| f >> g | Output type of f must match input type of g | Runtime type error |
| Curried functions | Arity matches at each composition point | Argument error |
| Multi-argument functions | Must be curried or partially applied | Wrong number of arguments error |
| Side effects | Functions should be pure or controlled | Unpredictable behavior |
Creating Composed Functions
| Method | Code | Notes |
|---|---|---|
| Direct operator | f >> g | Available for Proc and Lambda |
| Compose helper | compose(f, g, h) | Custom implementation needed |
| Method conversion | method(:name).to_proc >> other | Converts Method to Proc |
| Reduce pattern | fns.reduce { pipe } | Dynamic composition |
Performance Characteristics
| Aspect | Impact | Mitigation |
|---|---|---|
| Function call overhead | Each composition adds call | Profile before optimizing |
| Intermediate allocations | Each step may create objects | Consider fused operations for hot paths |
| Stack depth | Deep composition increases stack | Limit composition depth |
| Lazy evaluation | Ruby eager by default | Use Enumerator for lazy chains |
Common Gotchas
| Issue | Cause | Solution |
|---|---|---|
| Type mismatch at runtime | Output type doesn't match next input | Document expected types |
| Side effects causing bugs | Composed functions modify state | Prefer pure functions |
| Confusing execution order | << vs >> semantics | Use >> for left-to-right readability |
| Debugging difficulty | Stack traces show composition | Use intermediate variables during debug |
| Argument count mismatch | Multi-arg function in composition | Use curry or partial application |
Testing Composed Functions
| Approach | Method | Example |
|---|---|---|
| Unit test components | Test each function independently | assert add_ten.call(5) == 15 |
| Integration test composition | Test the composed function | assert pipeline.call(input) == expected |
| Property-based testing | Verify composition laws | test associativity holds |
| Mock dependencies | Stub functions in composition | replace fetch with mock |