Overview
Parameter passing mechanisms determine how arguments flow from a caller to a function or method. When code invokes a function with arguments, the language runtime must decide whether to transmit the actual data, a copy of the data, or a reference to the data's memory location. This decision affects program behavior, performance, memory usage, and the ability of functions to modify caller data.
Different programming languages adopt different strategies. C passes arguments by value, making copies of primitive types. C++ offers both pass by value and pass by reference through reference parameters. Python and Ruby use pass by object reference, where references to objects are passed by value. Understanding these mechanisms explains why mutations inside functions sometimes affect external state and sometimes don't.
The choice of parameter passing mechanism influences API design, memory efficiency, and program correctness. Functions that accept large data structures benefit from reference-based passing to avoid expensive copies. Functions that need to modify caller state require either reference parameters or return values. Immutable objects reduce the complexity of reasoning about parameter passing because they cannot be modified regardless of the mechanism.
def modify_array(arr)
arr << 4 # Modifies the original array
end
numbers = [1, 2, 3]
modify_array(numbers)
numbers
# => [1, 2, 3, 4]
def reassign_array(arr)
arr = [9, 8, 7] # Creates new binding, doesn't affect caller
end
original = [1, 2, 3]
reassign_array(original)
original
# => [1, 2, 3]
Key Principles
Pass by Value transmits a copy of the argument to the function. The function receives an independent copy of the data, and modifications to the parameter do not affect the original argument. This mechanism guarantees isolation between caller and callee but incurs copying costs for large data structures. C uses pass by value for all parameters, requiring explicit pointer usage for reference semantics.
Pass by Reference transmits the memory address of the argument. The function parameter becomes an alias for the original argument, and any modifications affect the caller's data directly. This mechanism enables functions to modify caller state efficiently without copying data. C++ implements pass by reference through reference parameters (int& x), while languages like Fortran use pass by reference by default for certain types.
Pass by Object Reference (also called pass by sharing or pass by assignment) transmits a reference to an object, but the reference itself is passed by value. The function cannot rebind the caller's variable to a different object, but it can mutate the object through the reference. Python, Ruby, Java, and JavaScript use this mechanism. The distinction between mutable and immutable objects becomes significant because immutable objects cannot be modified, mimicking pass by value semantics.
Reference Semantics vs Value Semantics describes whether operations on a type affect the original data or a copy. Types with value semantics (integers, structs copied on assignment) behave as if passed by value regardless of the underlying mechanism. Types with reference semantics (objects, arrays) maintain identity across assignments and parameter passing.
Aliasing occurs when multiple references point to the same object. Parameter passing creates aliasing when a function parameter and an external variable reference the same object. Aliasing complicates reasoning about program state because mutations through one reference affect all other references. Immutability eliminates aliasing concerns for the objects themselves, though mutable containers holding immutable objects still create aliasing scenarios.
Rebinding vs Mutation represents a critical distinction in reference-based languages. Rebinding assigns a new object to a variable, changing what the variable references. Mutation modifies the existing object without changing references. Parameter passing mechanisms affect rebinding behavior (functions cannot rebind caller variables in pass by object reference) but not mutation (functions can mutate objects).
# Demonstrates rebinding vs mutation
def demonstrate_rebinding(x)
x = x + [4, 5] # Creates new array, rebinds x
end
def demonstrate_mutation(x)
x.concat([4, 5]) # Mutates existing array
end
arr1 = [1, 2, 3]
demonstrate_rebinding(arr1)
arr1 # => [1, 2, 3] - rebinding didn't affect caller
arr2 = [1, 2, 3]
demonstrate_mutation(arr2)
arr2 # => [1, 2, 3, 4, 5] - mutation affected caller
The object identity can be tested to verify parameter passing behavior. Objects that maintain identity across function calls indicate reference transmission, while objects that change identity indicate copying.
Ruby Implementation
Ruby uses pass by object reference exclusively. When a method is called, Ruby passes references to objects, but these references are passed by value. The method receives a copy of the reference, not a copy of the object. This means methods can mutate objects through their parameters but cannot reassign the caller's variables.
All Ruby values are objects, including integers and symbols. However, some objects are immutable (integers, symbols, frozen strings), which gives them value-like semantics despite being passed by reference. Attempting to "modify" an immutable object actually creates a new object, leaving the original unchanged.
def modify_string(str)
str << " World" # Mutates the string object
end
def reassign_string(str)
str = str + " World" # Creates new string, rebinds local str
end
greeting = "Hello"
modify_string(greeting)
greeting # => "Hello World"
greeting = "Hello"
reassign_string(greeting)
greeting # => "Hello" - reassignment didn't affect caller
The object_id method returns a unique identifier for each object, demonstrating whether operations create new objects or modify existing ones:
def show_object_ids(obj)
puts "Inside method: #{obj.object_id}"
obj << " modified"
puts "After modification: #{obj.object_id}"
end
str = "Original"
puts "Before call: #{str.object_id}"
show_object_ids(str)
puts "After call: #{str.object_id}"
# All object_ids are identical - same object throughout
Ruby's immutable types include Integer, Float, Symbol, and TrueClass/FalseClass/NilClass. Operations on these types always return new objects:
def try_modify_integer(n)
n += 10 # Creates new Integer object
n
end
x = 5
result = try_modify_integer(x)
x # => 5
result # => 15
Strings in Ruby are mutable by default but can be frozen. Frozen strings behave like immutable objects, raising exceptions on modification attempts:
def modify_frozen(str)
str << " World"
end
frozen_str = "Hello".freeze
modify_frozen(frozen_str)
# Raises FrozenError: can't modify frozen String
Arrays and hashes are mutable containers. Methods can modify their contents through parameter references:
def modify_array_contents(arr)
arr[0] = 100 # Modifies element
arr << 200 # Appends element
end
def modify_hash_contents(hash)
hash[:new_key] = "new_value"
hash[:existing] = "modified"
end
numbers = [1, 2, 3]
modify_array_contents(numbers)
numbers # => [100, 2, 3, 200]
data = { existing: "original" }
modify_hash_contents(data)
data # => { existing: "modified", new_key: "new_value" }
The dup and clone methods create shallow copies, giving callers control over whether functions affect original objects:
def destructive_operation(arr)
arr.clear
arr << 999
end
original = [1, 2, 3]
destructive_operation(original.dup)
original # => [1, 2, 3] - unaffected because dup created copy
Method parameters with default values are evaluated at call time, and the same default object is used across calls:
def append_to_list(item, list = [])
list << item
end
append_to_list(1) # => [1]
append_to_list(2) # => [1, 2] - same array object!
append_to_list(3) # => [1, 2, 3]
# Correct approach
def append_to_list_safe(item, list = nil)
list ||= []
list << item
end
Keyword arguments follow the same pass by object reference semantics:
def process_options(name:, settings: {})
settings[:processed] = true
"Processed #{name}"
end
opts = { color: "blue" }
process_options(name: "widget", settings: opts)
opts # => { color: "blue", processed: true }
Practical Examples
Example 1: Building Fluent Interfaces
Methods that mutate and return self create fluent interfaces, taking advantage of Ruby's reference passing:
class QueryBuilder
def initialize
@conditions = []
@order = nil
end
def where(condition)
@conditions << condition
self # Returns reference to same object
end
def order_by(field)
@order = field
self
end
def to_sql
sql = "SELECT * FROM table"
sql += " WHERE #{@conditions.join(' AND ')}" unless @conditions.empty?
sql += " ORDER BY #{@order}" if @order
sql
end
end
query = QueryBuilder.new
query.where("age > 18").where("active = true").order_by("name")
query.to_sql
# => "SELECT * FROM table WHERE age > 18 AND active = true ORDER BY name"
Example 2: Accumulator Pattern with Side Effects
Functions that accumulate state by modifying passed collections:
def collect_errors(validation_result, errors)
errors << "Name cannot be blank" if validation_result[:name].nil?
errors << "Email is invalid" unless validation_result[:email]&.include?("@")
errors << "Age must be positive" if validation_result[:age]&.negative?
end
def validate_user(user_data)
errors = []
collect_errors(user_data, errors)
if errors.empty?
{ valid: true }
else
{ valid: false, errors: errors }
end
end
result = validate_user({ name: nil, email: "invalid", age: -5 })
# => { valid: false, errors: ["Name cannot be blank", "Email is invalid", "Age must be positive"] }
Example 3: Destructive vs Non-Destructive Operations
Designing methods with clear semantics about mutation:
class TextProcessor
# Non-destructive: returns new string
def self.sanitize(text)
text.gsub(/[<>]/, '')
end
# Destructive: modifies in place
def self.sanitize!(text)
text.gsub!(/[<>]/, '')
text
end
# Guard against nil
def self.sanitize_safe(text)
return "" if text.nil?
text.gsub(/[<>]/, '')
end
end
original = "Hello <world>"
cleaned = TextProcessor.sanitize(original)
original # => "Hello <world>" - unchanged
cleaned # => "Hello world"
content = "Test <script>"
TextProcessor.sanitize!(content)
content # => "Test script" - modified in place
Example 4: Shared State in Callbacks
Callbacks that modify shared state through parameter references:
class EventProcessor
def process_events(events, handlers, context)
events.each do |event|
handlers.each do |handler|
handler.call(event, context)
end
end
end
end
# Context accumulates state across all callbacks
context = { processed: 0, errors: [] }
handlers = [
->(event, ctx) { ctx[:processed] += 1 },
->(event, ctx) {
ctx[:errors] << "Invalid event" if event[:data].nil?
}
]
events = [
{ type: "create", data: { name: "Test" } },
{ type: "update", data: nil },
{ type: "delete", data: { id: 123 } }
]
processor = EventProcessor.new
processor.process_events(events, handlers, context)
context
# => { processed: 3, errors: ["Invalid event"] }
Example 5: Protecting Against Unintended Mutation
Using freezing and duplication to control mutability:
class ConfigurationManager
def initialize(defaults)
@defaults = defaults.freeze # Prevents modification
end
def get_config(overrides = {})
@defaults.dup.merge(overrides) # Returns mutable copy with overrides
end
def apply_settings(config)
# Create frozen copy to prevent external modification
@current_config = config.dup.freeze
end
end
defaults = { timeout: 30, retries: 3 }
manager = ConfigurationManager.new(defaults)
config = manager.get_config(timeout: 60)
config[:timeout] = 90 # OK, modifying copy
defaults[:timeout] # => 30, still unchanged
manager.apply_settings(config)
# Attempting to modify @current_config would raise FrozenError
Common Patterns
Defensive Copying Pattern
Creating copies of mutable parameters to prevent external modifications:
class UserRegistry
def initialize
@users = []
end
# Accept array but work with copy
def bulk_add(users)
users_copy = users.dup
users_copy.each do |user|
@users << validate_and_normalize(user)
end
end
# Return copy to prevent external modification
def all_users
@users.dup
end
private
def validate_and_normalize(user)
# Validation and normalization logic
user.dup
end
end
Builder Pattern with Chaining
Methods return self to enable fluent interfaces:
class EmailBuilder
def initialize
@to = []
@subject = ""
@body = ""
end
def to(*addresses)
@to.concat(addresses)
self
end
def subject(text)
@subject = text
self
end
def body(text)
@body = text
self
end
def send
# Send email logic
{ to: @to, subject: @subject, body: @body }
end
end
email = EmailBuilder.new
.to("user@example.com")
.subject("Welcome")
.body("Thank you for signing up")
.send
Accumulator Parameter Pattern
Passing a mutable accumulator through recursive or iterative calls:
def flatten_nested_array(array, accumulator = [])
array.each do |element|
if element.is_a?(Array)
flatten_nested_array(element, accumulator)
else
accumulator << element
end
end
accumulator
end
nested = [1, [2, [3, 4], 5], 6, [7]]
result = flatten_nested_array(nested)
# => [1, 2, 3, 4, 5, 6, 7]
Command Pattern with Mutable State
Commands that encapsulate operations on shared mutable state:
class Command
def execute(state)
raise NotImplementedError
end
end
class AddItemCommand < Command
def initialize(item)
@item = item
end
def execute(state)
state[:items] << @item
end
end
class RemoveItemCommand < Command
def initialize(item)
@item = item
end
def execute(state)
state[:items].delete(@item)
end
end
# State is mutated by commands
state = { items: [] }
commands = [
AddItemCommand.new("apple"),
AddItemCommand.new("banana"),
RemoveItemCommand.new("apple")
]
commands.each { |cmd| cmd.execute(state) }
state[:items] # => ["banana"]
Freezing for Immutability
Using freeze to prevent modification after initialization:
class ImmutablePoint
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
freeze # Prevent any further modifications
end
def translate(dx, dy)
# Return new instance instead of modifying
ImmutablePoint.new(@x + dx, @y + dy)
end
end
point = ImmutablePoint.new(10, 20)
new_point = point.translate(5, 5)
point.x # => 10
new_point.x # => 15
Design Considerations
Mutability vs Immutability Trade-offs
Mutable parameters enable efficient in-place modifications but create aliasing risks. Methods accepting mutable parameters must document whether they mutate arguments. Callers must understand whether passed objects will be modified. Immutable parameters eliminate aliasing concerns but require object creation for each modification, increasing memory allocation and garbage collection pressure.
For small, frequently created objects, immutability simplifies reasoning without significant performance cost. For large data structures or performance-critical code, in-place mutation reduces overhead. Ruby's convention of bang methods (methods ending in !) indicates destructive operations, helping callers understand mutation semantics.
Return Value vs Parameter Modification
Functions can communicate results through return values or by modifying parameters. Return values make data flow explicit and support functional programming patterns. Parameter modification enables functions to update multiple values or avoid return value overhead. Ruby's support for multiple return values through array destructuring makes return values more attractive for multi-value results:
# Return values approach
def parse_coordinates(text)
parts = text.split(',')
[parts[0].to_i, parts[1].to_i]
end
x, y = parse_coordinates("10,20")
# Parameter modification approach
def parse_coordinates_into(text, result)
parts = text.split(',')
result[:x] = parts[0].to_i
result[:y] = parts[1].to_i
end
coords = {}
parse_coordinates_into("10,20", coords)
Deep vs Shallow Copying
dup and clone create shallow copies, copying the object structure but not nested objects. Modifications to nested objects affect both the original and the copy. Deep copying requires recursive duplication of all nested objects:
def deep_copy(obj)
Marshal.load(Marshal.dump(obj))
end
original = { users: [{ name: "Alice" }] }
shallow = original.dup
deep = deep_copy(original)
shallow[:users] << { name: "Bob" }
original[:users] # => [{ name: "Alice" }, { name: "Bob" }]
deep[:users] << { name: "Charlie" }
original[:users] # => [{ name: "Alice" }, { name: "Bob" }]
Deep copying incurs significant overhead and may not work for objects containing non-serializable data (procs, lambdas, IO objects). Design APIs to minimize the need for deep copying through careful ownership and mutation rules.
Keyword Arguments vs Positional Parameters
Keyword arguments make mutation semantics clearer by naming the parameters being modified. They also prevent accidental parameter reordering:
def update_user(id, name: nil, email: nil, metadata: {})
# Clear which parameters are optional and mutable
metadata[:updated_at] = Time.now
# Update logic
end
# Caller clearly sees what's being passed and potentially modified
user_metadata = {}
update_user(123, name: "Alice", metadata: user_metadata)
user_metadata[:updated_at] # Now contains timestamp
Frozen Collections with Mutable Elements
Freezing a collection prevents adding or removing elements but does not freeze contained objects:
array = ["hello", "world"].freeze
array << "test" # Raises FrozenError
array[0] << " there" # Works! String is mutable
array # => ["hello there", "world"]
To create deeply frozen structures, recursively freeze all nested objects or use immutable data structure libraries.
Common Pitfalls
Default Parameter Object Reuse
Ruby evaluates default parameter values once at method definition, not at each call. Mutable default objects are shared across all calls:
# Problematic
def add_to_collection(item, collection = [])
collection << item
end
add_to_collection(1) # => [1]
add_to_collection(2) # => [1, 2] - unexpected!
# Correct
def add_to_collection(item, collection = nil)
collection ||= []
collection << item
end
Modifying Method Parameters
Modifying parameters inside methods creates surprising behavior for callers:
def process_name(name)
name.upcase! # Modifies caller's string
name.strip!
name
end
user_name = " alice "
result = process_name(user_name)
user_name # => "ALICE" - unexpected modification!
# Better approach
def process_name(name)
name.upcase.strip # Returns new string
end
Assuming Integer Mutability
Attempting to modify integers fails silently because operations create new objects:
def increment(n)
n += 1 # Creates new Integer, doesn't affect caller
end
counter = 5
increment(counter)
counter # => 5, still unchanged
# Must use return value
counter = increment(counter) # Now counter is 6
Mixing Destructive and Non-Destructive Methods
Inconsistent naming leads to confusion about whether methods mutate:
text = "hello"
# Non-destructive, returns new string
text.upcase # => "HELLO"
text # => "hello"
# Destructive, modifies in place
text.upcase! # => "HELLO"
text # => "HELLO"
# Inconsistent - concat is destructive but doesn't have !
text.concat(" world")
text # => "HELLO world" - modified
Frozen String Modifications
Frozen strings raise errors on mutation attempts:
CONSTANT = "immutable"
def modify_string(str)
str << " modified"
end
modify_string(CONSTANT)
# Raises FrozenError: can't modify frozen String
# Check frozen status
if CONSTANT.frozen?
new_str = CONSTANT + " modified"
else
CONSTANT << " modified"
end
Hash Modification Through Parameters
Hashes passed to methods can be unexpectedly modified:
def set_defaults(options)
options[:timeout] ||= 30
options[:retries] ||= 3
end
user_options = { timeout: 60 }
set_defaults(user_options)
user_options # => { timeout: 60, retries: 3 } - modified!
# Avoid mutation
def set_defaults(options)
{ timeout: 30, retries: 3 }.merge(options)
end
Shallow Copy Surprise
Shallow copies share nested objects with the original:
original = { config: { timeout: 30 } }
copy = original.dup
copy[:config][:timeout] = 60
original[:config][:timeout] # => 60, also changed!
# Need deep copy
require 'json'
deep_copy = JSON.parse(JSON.generate(original))
# or
deep_copy = Marshal.load(Marshal.dump(original))
Reference
Parameter Passing Semantics
| Mechanism | Definition | Variable Rebinding | Object Mutation | Languages |
|---|---|---|---|---|
| Pass by Value | Copy of data passed to function | No effect on caller | No effect on caller | C, Go |
| Pass by Reference | Memory address passed to function | Affects caller variable | Affects caller object | C++ with &, Fortran |
| Pass by Object Reference | Reference passed by value | No effect on caller | Affects caller object | Ruby, Python, Java, JavaScript |
Ruby Mutability Reference
| Type | Mutable | Mutating Operations | Creating New Objects |
|---|---|---|---|
| Integer | No | N/A | All arithmetic operations |
| Float | No | N/A | All arithmetic operations |
| Symbol | No | N/A | N/A |
| String | Yes | concat, <<, upcase!, gsub! | +, upcase, gsub |
| Array | Yes | push, <<, pop, concat | +, -, map, select |
| Hash | Yes | []= assignment, merge!, update | merge, select, transform_values |
| Range | No | N/A | N/A |
| TrueClass/FalseClass/NilClass | No | N/A | N/A |
Common Method Behaviors
| Method Pattern | Behavior | Example |
|---|---|---|
| method | Non-destructive, returns new object | String#upcase |
| method! | Destructive, modifies in place | String#upcase! |
| method= | Setter, modifies object state | attr_accessor |
| method? | Predicate, returns boolean | Array#empty? |
Copying Methods
| Method | Behavior | Preserves Frozen State | Copies Singleton Methods |
|---|---|---|---|
| dup | Shallow copy | No | No |
| clone | Shallow copy | Yes | Yes |
| Marshal.load/dump | Deep copy | Yes | No |
Protection Patterns
| Pattern | Implementation | Use Case |
|---|---|---|
| Defensive Copy In | parameter.dup at method entry | Prevent caller from affecting internal state |
| Defensive Copy Out | @ivar.dup in getter | Prevent external modification of internal state |
| Freeze After Initialize | freeze in initialize | Create immutable objects |
| Frozen Default Parameters | CONSTANT.dup as default | Prevent default object mutation |
Testing for Mutability
| Method | Purpose | Returns |
|---|---|---|
| frozen? | Check if object is frozen | true or false |
| object_id | Get unique object identifier | Integer |
| equal? | Check object identity | true or false |
| == | Check object equality | true or false |
Parameter Declaration Styles
| Style | Syntax | Characteristics |
|---|---|---|
| Positional | def method(a, b) | Order matters, all or none |
| Optional Positional | def method(a, b = default) | Defaults for trailing params |
| Splat | def method(*args) | Variable number of positional |
| Keyword | def method(a:, b:) | Named, order independent |
| Optional Keyword | def method(a: 1, b: 2) | Named with defaults |
| Keyword Splat | def method(**kwargs) | Variable number of keyword args |
| Block | def method(&block) | Captures block as proc |