Overview
Immutability defines objects whose state cannot change after construction. When an operation appears to modify an immutable object, the system creates and returns a new object with the modified state, leaving the original unchanged. This contrasts with mutable objects, which allow in-place modifications to their internal state.
The concept emerged from functional programming languages like Haskell and Lisp, where immutability serves as a foundational principle. Modern object-oriented and multi-paradigm languages have adopted immutability patterns to address concurrency challenges, reduce bugs from unexpected state changes, and create more predictable code.
In Ruby, immutability exists on a spectrum. The language provides the freeze method to prevent modifications to objects, but unlike purely functional languages, Ruby defaults to mutable objects. Strings, arrays, and hashes are mutable by default, requiring explicit action to make them immutable.
# Mutable behavior - modifies original
names = ["Alice", "Bob"]
names << "Carol"
names
# => ["Alice", "Bob", "Carol"]
# Immutable approach - creates new object
names = ["Alice", "Bob"].freeze
new_names = names + ["Carol"]
names
# => ["Alice", "Bob"]
new_names
# => ["Alice", "Bob", "Carol"]
Immutability affects memory usage, performance, and program design. Each modification creates a new object, potentially increasing memory consumption. However, immutable objects enable safe concurrent access without locks, as no thread can modify shared state. The trade-off between these factors determines when immutability provides value.
Key Principles
State Permanence forms the core principle. Once created, an immutable object's internal state remains constant throughout its lifetime. Any method that appears to modify the object returns a new instance instead. This guarantee allows code to safely share references without defensive copying.
Structural Sharing addresses memory efficiency concerns. Rather than copying entire data structures, immutable collections share unchanged portions between versions. When adding an element to an immutable list, the new list references the original elements and adds only the new data. This technique makes immutability practical for large data structures.
# Without structural sharing (naive approach)
class ImmutableArray
def initialize(items)
@items = items.dup.freeze
end
def add(item)
ImmutableArray.new(@items + [item]) # Full copy
end
end
# With structural sharing concept (simplified)
class SharedArray
def initialize(items, suffix = [])
@items = items
@suffix = suffix
end
def add(item)
SharedArray.new(@items, @suffix + [item]) # Shares @items
end
end
Thread Safety emerges as a direct consequence. Multiple threads can access immutable objects simultaneously without synchronization mechanisms. No thread can modify shared state, eliminating race conditions related to concurrent modifications. This property simplifies concurrent programming significantly.
Referential Transparency describes functions that always produce the same output for identical inputs, with no side effects. Immutability enables referential transparency by preventing functions from modifying their arguments or shared state. This property makes code easier to reason about, test, and parallelize.
# Mutable approach - not referentially transparent
def process_data(records)
records.sort! # Modifies input
records.map { |r| r[:value] * 2 }
end
# Immutable approach - referentially transparent
def process_data(records)
sorted = records.sort
sorted.map { |r| r[:value] * 2 }
end
Value Semantics treats objects as values rather than entities with identity. Two immutable objects with identical content are considered equal, regardless of whether they occupy the same memory location. This contrasts with identity semantics, where object equality depends on reference identity.
Temporal Coupling Reduction occurs because immutable objects don't depend on the order of operations. When objects can change, the sequence of method calls matters. Immutable objects eliminate this concern, as operations don't affect existing instances.
Ruby Implementation
Ruby provides several mechanisms for immutability, though the language design favors mutability by default. The freeze method prevents modifications to an object, raising FrozenError if code attempts changes.
str = "hello".freeze
str << " world" # Raises FrozenError
array = [1, 2, 3].freeze
array << 4 # Raises FrozenError
hash = {a: 1, b: 2}.freeze
hash[:c] = 3 # Raises FrozenError
Shallow vs Deep Freezing creates a critical distinction. The freeze method performs shallow freezing, preventing modifications to the object itself but not to objects it references. Deep immutability requires recursive freezing.
# Shallow freeze - nested objects remain mutable
config = {
database: {
host: "localhost",
port: 5432
}
}.freeze
config[:database][:host] = "example.com" # Succeeds
config
# => {:database=>{:host=>"example.com", :port=>5432}}
# Deep freeze implementation
def deep_freeze(obj)
obj.freeze
case obj
when Hash
obj.each_value { |v| deep_freeze(v) }
when Array
obj.each { |item| deep_freeze(item) }
end
obj
end
config = deep_freeze({
database: {
host: "localhost",
port: 5432
}
})
config[:database][:host] = "example.com" # Raises FrozenError
String Literals and Frozen String Literals demonstrate Ruby's evolution toward immutability. The magic comment # frozen_string_literal: true makes all string literals in a file frozen by default, reducing object allocations.
# frozen_string_literal: true
str = "immutable"
str.frozen? # => true
# Force mutable with unary plus
mutable_str = +"mutable"
mutable_str.frozen? # => false
# Force frozen with unary minus
frozen_str = -"frozen"
frozen_str.frozen? # => true
Immutable Value Objects use Ruby's Struct or Data classes to create simple immutable types. The Data class, introduced in Ruby 3.2, provides immutable value objects with pattern matching support.
# Using Data class for immutable value objects
Point = Data.define(:x, :y) do
def distance_from_origin
Math.sqrt(x**2 + y**2)
end
end
p1 = Point.new(3, 4)
p1.x = 5 # Raises NoMethodError - no setter
# Create modified version
p2 = Point.new(p1.x * 2, p1.y * 2)
p2
# => #<data Point x=6, y=8>
# Pattern matching
case p1
in Point(x: 0, y: 0)
"origin"
in Point(x:, y:)
"point at (#{x}, #{y})"
end
# => "point at (3, 4)"
Functional Methods in Ruby's standard library support immutable operations. Methods without the bang (!) suffix return new objects, while bang methods modify in place.
numbers = [3, 1, 4, 1, 5]
# Immutable operation
sorted = numbers.sort
numbers # => [3, 1, 4, 1, 5] (unchanged)
sorted # => [1, 1, 3, 4, 5]
# Mutable operation
numbers.sort!
numbers # => [1, 1, 3, 4, 5] (modified)
# Chaining immutable operations
result = [1, 2, 3]
.map { |n| n * 2 }
.select { |n| n > 3 }
.reduce(:+)
# => 10 (original array unchanged)
Ice Nine Gem provides deep freezing functionality for complex object graphs. This gem handles circular references and offers efficient deep freezing.
require 'ice_nine'
data = {
users: [
{name: "Alice", posts: [{title: "First"}]},
{name: "Bob", posts: [{title: "Second"}]}
]
}
frozen_data = IceNine.deep_freeze(data)
frozen_data[:users][0][:name] = "Charlie" # Raises FrozenError
Common Patterns
Immutable Collections replace mutable arrays and hashes with frozen counterparts, creating new instances for modifications. This pattern works best when collection sizes remain small or modifications are infrequent.
class ImmutableList
def initialize(items = [])
@items = items.freeze
end
def add(item)
ImmutableList.new(@items + [item])
end
def remove(item)
ImmutableList.new(@items.reject { |i| i == item })
end
def map(&block)
ImmutableList.new(@items.map(&block))
end
def each(&block)
@items.each(&block)
self
end
def to_a
@items.dup
end
end
list = ImmutableList.new([1, 2, 3])
list2 = list.add(4)
list3 = list2.map { |n| n * 2 }
list.to_a # => [1, 2, 3]
list2.to_a # => [1, 2, 3, 4]
list3.to_a # => [2, 4, 6, 8]
Copy-on-Write delays copying until modification occurs. Immutable wrappers maintain a reference to the underlying mutable structure. When modification is requested, the wrapper creates a copy first.
class CowArray
def initialize(items)
@items = items
@frozen = false
end
def [](index)
@items[index]
end
def []=(index, value)
ensure_writable
@items[index] = value
end
def freeze
@frozen = true
@items.freeze
self
end
private
def ensure_writable
if @frozen
@items = @items.dup
@frozen = false
end
end
end
Builder Pattern for Immutable Objects separates construction from representation, allowing complex object assembly before freezing. This addresses the challenge of constructing immutable objects with many attributes.
class User
attr_reader :name, :email, :role, :preferences
def initialize(name:, email:, role:, preferences:)
@name = name
@email = email
@role = role
@preferences = preferences
freeze
end
class Builder
def initialize
@attributes = {}
end
def name(value)
@attributes[:name] = value
self
end
def email(value)
@attributes[:email] = value
self
end
def role(value)
@attributes[:role] = value
self
end
def preferences(value)
@attributes[:preferences] = value
self
end
def build
User.new(**@attributes)
end
end
def self.build
builder = Builder.new
yield builder
builder.build
end
end
user = User.build do |b|
b.name("Alice")
b.email("alice@example.com")
b.role(:admin)
b.preferences({theme: "dark", notifications: true})
end
Update Pattern with Keyword Arguments creates modified copies using keyword arguments, common in Ruby codebases. This pattern provides a clean API for immutable updates.
class Configuration
attr_reader :host, :port, :timeout, :retry_count
def initialize(host:, port: 80, timeout: 30, retry_count: 3)
@host = host
@port = port
@timeout = timeout
@retry_count = retry_count
freeze
end
def with(**changes)
Configuration.new(
host: changes.fetch(:host, @host),
port: changes.fetch(:port, @port),
timeout: changes.fetch(:timeout, @timeout),
retry_count: changes.fetch(:retry_count, @retry_count)
)
end
end
config = Configuration.new(host: "localhost")
config2 = config.with(port: 443, timeout: 60)
config.port # => 80
config2.port # => 443
Persistent Data Structures use structural sharing to efficiently represent multiple versions. The Hamster gem provides immutable collections with this optimization.
require 'hamster'
# Hamster provides efficient immutable collections
list = Hamster::List[1, 2, 3]
list2 = list.add(4)
list3 = list2.add(5)
# All versions coexist efficiently
list.to_a # => [1, 2, 3]
list2.to_a # => [1, 2, 3, 4]
list3.to_a # => [1, 2, 3, 4, 5]
# Hash with structural sharing
hash = Hamster::Hash[a: 1, b: 2, c: 3]
hash2 = hash.put(:d, 4)
hash3 = hash2.delete(:a)
hash.size # => 3
hash2.size # => 4
hash3.size # => 3
Practical Examples
Configuration Management benefits from immutability by preventing accidental modifications to application settings. Immutable configuration ensures all components see consistent values.
class AppConfig
attr_reader :environment, :database_url, :cache_store,
:log_level, :feature_flags
def initialize(
environment:,
database_url:,
cache_store: :memory,
log_level: :info,
feature_flags: {}
)
@environment = environment
@database_url = database_url
@cache_store = cache_store
@log_level = log_level
@feature_flags = feature_flags.freeze
deep_freeze
end
def with(**changes)
AppConfig.new(
environment: changes.fetch(:environment, @environment),
database_url: changes.fetch(:database_url, @database_url),
cache_store: changes.fetch(:cache_store, @cache_store),
log_level: changes.fetch(:log_level, @log_level),
feature_flags: changes.fetch(:feature_flags, @feature_flags)
)
end
def feature_enabled?(flag)
@feature_flags[flag] || false
end
private
def deep_freeze
freeze
@feature_flags.freeze
end
end
# Load configuration once at startup
config = AppConfig.new(
environment: :production,
database_url: ENV['DATABASE_URL'],
cache_store: :redis,
feature_flags: {
new_ui: true,
beta_features: false
}
)
# Safe to share across threads
Thread.new { process_with_config(config) }
Thread.new { serve_request(config) }
Event Sourcing with Immutable Events stores all state changes as a sequence of immutable events. Each event represents a fact that occurred, never modified after creation.
class Event
attr_reader :id, :type, :data, :timestamp, :metadata
def initialize(type:, data:, metadata: {})
@id = SecureRandom.uuid
@type = type
@data = data.freeze
@timestamp = Time.now
@metadata = metadata.freeze
freeze
end
end
class Account
attr_reader :id, :balance, :version
def initialize(id:)
@id = id
@balance = 0
@version = 0
@events = []
freeze
end
def deposit(amount)
event = Event.new(
type: :deposit,
data: {account_id: @id, amount: amount}
)
apply_event(event)
end
def withdraw(amount)
return self if amount > @balance
event = Event.new(
type: :withdrawal,
data: {account_id: @id, amount: amount}
)
apply_event(event)
end
private
def apply_event(event)
new_balance = case event.type
when :deposit
@balance + event.data[:amount]
when :withdrawal
@balance - event.data[:amount]
end
Account.build(
id: @id,
balance: new_balance,
version: @version + 1,
events: @events + [event]
)
end
def self.build(id:, balance:, version:, events:)
account = allocate
account.instance_variable_set(:@id, id)
account.instance_variable_set(:@balance, balance)
account.instance_variable_set(:@version, version)
account.instance_variable_set(:@events, events.freeze)
account.freeze
account
end
end
# Usage
account = Account.new(id: "acc-123")
account = account.deposit(100)
account = account.withdraw(30)
account.balance # => 70
Redux-Style State Management uses immutable state updates, where each action produces a new state rather than modifying existing state. This pattern enables time-travel debugging and predictable state changes.
class Store
def initialize(initial_state, reducer)
@state = initial_state.freeze
@reducer = reducer
@listeners = []
end
def state
@state
end
def dispatch(action)
@state = @reducer.call(@state, action).freeze
@listeners.each { |listener| listener.call(@state) }
action
end
def subscribe(&listener)
@listeners << listener
-> { @listeners.delete(listener) }
end
end
# Reducer function
def counter_reducer(state, action)
case action[:type]
when :increment
state.merge(count: state[:count] + 1)
when :decrement
state.merge(count: state[:count] - 1)
when :add
state.merge(count: state[:count] + action[:value])
else
state
end
end
# Create store
store = Store.new({count: 0}, method(:counter_reducer))
# Subscribe to changes
store.subscribe do |state|
puts "Count: #{state[:count]}"
end
# Dispatch actions
store.dispatch({type: :increment}) # Count: 1
store.dispatch({type: :add, value: 5}) # Count: 6
store.dispatch({type: :decrement}) # Count: 5
Cache Keys from Immutable Objects use immutable objects as hash keys safely, preventing bugs from key modifications after insertion.
class CacheKey
attr_reader :namespace, :id, :version
def initialize(namespace:, id:, version:)
@namespace = namespace
@id = id
@version = version
freeze
end
def eql?(other)
other.is_a?(CacheKey) &&
namespace == other.namespace &&
id == other.id &&
version == other.version
end
def hash
[namespace, id, version].hash
end
end
cache = {}
key1 = CacheKey.new(namespace: :users, id: 123, version: 1)
cache[key1] = {name: "Alice", email: "alice@example.com"}
key2 = CacheKey.new(namespace: :users, id: 123, version: 2)
cache[key2] = {name: "Alice", email: "alice@newdomain.com"}
# Both versions coexist in cache
cache[key1] # => {name: "Alice", email: "alice@example.com"}
cache[key2] # => {name: "Alice", email: "alice@newdomain.com"}
Design Considerations
Memory vs Safety Trade-off represents the primary decision point. Immutability creates new objects for modifications, increasing memory usage and garbage collection pressure. Applications with frequent updates to large data structures may experience performance degradation. However, immutability eliminates entire classes of bugs related to shared mutable state.
The decision depends on several factors:
- Update frequency: High-frequency updates favor mutable approaches
- Data structure size: Large structures benefit from persistent data structures with structural sharing
- Concurrency requirements: Concurrent access strongly favors immutability
- Bug complexity: Complex state management benefits from immutability's simplicity
# High-frequency updates with small data - immutability acceptable
def process_stream(events)
events.reduce({}) do |state, event|
state.merge(event.id => event.data) # New hash each iteration
end
end
# High-frequency updates with large data - mutable more efficient
def process_stream_efficient(events)
state = {}
events.each do |event|
state[event.id] = event.data # In-place update
end
state.freeze
end
Granularity of Immutability determines which objects should be immutable. Applying immutability at every level creates overhead, while selective application balances benefits and costs.
Typical granularity choices:
- Value objects (coordinates, money, dates): Always immutable
- Domain entities (users, orders): Often mutable with immutable components
- Collections: Depends on usage patterns
- Configuration: Always immutable after initialization
Defensive Copying Requirements address the gap between immutable and mutable code. When immutable objects contain references to mutable objects, defensive copying prevents external modifications.
class ImmutableContainer
def initialize(items)
@items = items.map(&:dup).freeze # Deep copy
end
def items
@items.map(&:dup) # Return copies
end
end
# Without defensive copying
mutable_array = [1, 2, 3]
container = ImmutableContainer.new([mutable_array])
mutable_array << 4 # Mutates container's internal state
# With defensive copying
mutable_array = [1, 2, 3]
container = ImmutableContainer.new([mutable_array])
mutable_array << 4 # Container remains unchanged
Testing Complexity decreases with immutability. Tests don't need to verify state isolation between test cases, as immutable objects cannot affect each other. However, testing may require more object instantiations.
API Design Impact affects how users interact with immutable objects. Methods must return new instances rather than modifying in place. Clear naming conventions help users understand whether methods mutate or return new objects.
Ruby convention uses method names without ! suffix for immutable operations and names with ! for mutations. However, immutable objects never have ! methods, which signals their nature.
Migration Strategy from mutable to immutable requires careful planning. Converting existing codebases incrementally reduces risk:
- Identify boundaries where immutability provides value
- Introduce immutable types at architectural boundaries
- Gradually expand immutable regions
- Maintain mutable/immutable interfaces during transition
Performance Considerations
Allocation Overhead increases with immutability, as each modification creates a new object. Ruby's garbage collector must reclaim these objects, potentially causing performance issues in tight loops or high-throughput scenarios.
require 'benchmark'
# Mutable approach
def mutable_accumulate(count)
result = []
count.times { |i| result << i }
result
end
# Immutable approach
def immutable_accumulate(count)
count.times.reduce([]) { |acc, i| acc + [i] }
end
n = 10_000
Benchmark.bm do |x|
x.report("mutable: ") { mutable_accumulate(n) }
x.report("immutable: ") { immutable_accumulate(n) }
end
# Results show mutable approach significantly faster
# mutable: 0.003 seconds
# immutable: 2.456 seconds
Structural Sharing Efficiency mitigates memory overhead for persistent data structures. Libraries like Hamster implement efficient immutable collections that share structure between versions.
require 'hamster'
require 'benchmark'
n = 100_000
Benchmark.bm do |x|
x.report("Ruby Array: ") do
array = []
n.times { |i| array = array + [i] }
end
x.report("Hamster List: ") do
list = Hamster::List.empty
n.times { |i| list = list.add(i) }
end
end
# Hamster significantly faster due to structural sharing
# Ruby Array: 142.3 seconds
# Hamster List: 0.8 seconds
String Allocation represents a common performance bottleneck. Frozen string literals reduce allocations by reusing identical strings.
require 'benchmark/memory'
def with_mutable_strings(iterations)
iterations.times do
str = "constant_string"
str.upcase
end
end
def with_frozen_strings(iterations)
iterations.times do
str = "constant_string".freeze
str.upcase
end
end
Benchmark.memory do |x|
x.report("mutable") { with_mutable_strings(10_000) }
x.report("frozen") { with_frozen_strings(10_000) }
x.compare!
end
# Frozen strings allocate less memory
Copy-on-Write Optimization delays copying until necessary, improving performance when modifications are rare.
class CowString
def initialize(string)
@string = string
@owned = false
end
def [](index)
@string[index]
end
def []=(index, value)
ensure_owned
@string[index] = value
end
private
def ensure_owned
unless @owned
@string = @string.dup
@owned = true
end
end
end
# Fast when no modifications occur
text = CowString.new("hello")
1000.times { |i| text[i % 5] } # Only reads, no copying
# Copies only once when modification happens
text[0] = 'H' # Triggers copy
Hash Consing eliminates duplicate immutable objects by maintaining a canonical instance for each unique value. This optimization reduces memory usage when many equal immutable objects exist.
class InternedString
@pool = {}
def self.new(value)
@pool[value] ||= super(value)
end
def initialize(value)
@value = value.freeze
freeze
end
def to_s
@value
end
end
# Same string values share memory
s1 = InternedString.new("hello")
s2 = InternedString.new("hello")
s1.equal?(s2) # => true (same object)
Benchmark Immutable Patterns reveals performance characteristics for different scenarios. Testing specific use cases determines whether immutability suits the application.
Common Pitfalls
Shallow Freezing Assumptions lead to bugs when developers assume freeze provides deep immutability. Nested mutable objects remain modifiable despite the container being frozen.
# Pitfall: Assuming freeze protects nested objects
config = {
database: {
host: "localhost"
}
}.freeze
config[:database][:host] = "evil.com" # Succeeds!
# Solution: Deep freeze
def deep_freeze(obj)
case obj
when Hash
obj.each { |k, v| deep_freeze(k); deep_freeze(v) }
when Array
obj.each { |item| deep_freeze(item) }
end
obj.freeze
end
config = deep_freeze({
database: {
host: "localhost"
}
})
config[:database][:host] = "evil.com" # Raises FrozenError
Performance Degradation from Naive Immutability occurs when applying immutability without considering performance implications. Building large collections with repeated concatenation creates excessive allocations.
# Pitfall: Quadratic performance
def build_list_slowly(n)
(1..n).reduce([]) { |acc, i| acc + [i] } # O(n²)
end
# Solution: Build mutably, freeze result
def build_list_efficiently(n)
result = (1..n).to_a
result.freeze
end
# Or use persistent data structures
require 'hamster'
def build_hamster_list(n)
(1..n).reduce(Hamster::List.empty) { |acc, i| acc.add(i) } # O(n)
end
Forgetting to Freeze Return Values breaks immutability guarantees when methods return mutable objects that expose internal state.
class User
def initialize(name, tags)
@name = name
@tags = tags.freeze
freeze
end
# Pitfall: Returns mutable reference
def tags
@tags
end
end
user = User.new("Alice", ["ruby", "rails"])
user.tags << "python" # Raises FrozenError due to frozen array
# Still problematic if array wasn't frozen
class BadUser
def initialize(name, tags)
@name = name
@tags = tags
freeze
end
def tags
@tags # Returns mutable reference
end
end
user = BadUser.new("Bob", ["ruby"])
user.tags << "python" # Succeeds! Breaks immutability
Circular Reference Freezing creates complexity when freezing object graphs with circular references. Naive deep freezing implementations infinite loop.
# Pitfall: Infinite loop with circular references
class Node
attr_accessor :value, :next
def initialize(value)
@value = value
@next = nil
end
end
n1 = Node.new(1)
n2 = Node.new(2)
n1.next = n2
n2.next = n1 # Circular reference
def naive_deep_freeze(obj)
obj.freeze
obj.instance_variables.each do |var|
value = obj.instance_variable_get(var)
naive_deep_freeze(value) # Infinite loop!
end
end
# Solution: Track visited objects
def safe_deep_freeze(obj, visited = Set.new)
return if visited.include?(obj.object_id)
visited.add(obj.object_id)
obj.freeze
obj.instance_variables.each do |var|
value = obj.instance_variable_get(var)
safe_deep_freeze(value, visited)
end
end
safe_deep_freeze(n1)
Mixing Frozen and Unfrozen String Literals causes confusion when some strings are frozen and others are not. The magic comment affects all string literals in a file.
# frozen_string_literal: true
def process
frozen = "frozen by default"
mutable = +"explicitly mutable"
frozen << " more" # Raises FrozenError
mutable << " more" # Works
end
Hash Key Mutations break hash lookups when mutable objects used as keys are modified after insertion.
# Pitfall: Modifying hash keys
key = {id: 123}
cache = {}
cache[key] = "value"
key[:id] = 456 # Modifies key
cache[key] # => nil (lookup fails)
# Solution: Use immutable keys
ImmutableKey = Data.define(:id)
key = ImmutableKey.new(123)
cache = {}
cache[key] = "value"
cache[key] # => "value" (reliable lookup)
Reference
Core Methods
| Method | Object Type | Description | Result |
|---|---|---|---|
| freeze | Any | Prevents modifications to object | Returns self, frozen |
| frozen? | Any | Checks if object is frozen | Returns boolean |
| dup | Any | Creates shallow copy, unfrozen | Returns new object |
| clone | Any | Creates shallow copy, preserves frozen state | Returns new object |
String Operations
| Method | Mutating | Immutable Alternative |
|---|---|---|
| concat, << | Yes | + operator |
| upcase! | Yes | upcase |
| downcase! | Yes | downcase |
| gsub! | Yes | gsub |
| strip! | Yes | strip |
| delete! | Yes | delete |
Array Operations
| Method | Mutating | Immutable Alternative |
|---|---|---|
| push, <<, append | Yes | + operator or concat |
| pop | Yes | [0...-1] or take |
| shift | Yes | [1..-1] or drop(1) |
| unshift | Yes | prepend with + |
| delete | Yes | reject or select |
| sort! | Yes | sort |
| map! | Yes | map |
Hash Operations
| Method | Mutating | Immutable Alternative |
|---|---|---|
| []= | Yes | merge with single key |
| delete | Yes | reject or select |
| merge! | Yes | merge |
| transform_keys! | Yes | transform_keys |
| transform_values! | Yes | transform_values |
Freeze Behavior
| Type | Effect of freeze | Notes |
|---|---|---|
| String | Prevents content modification | Common in Ruby 3+ with frozen_string_literal |
| Array | Prevents add/remove elements | Elements themselves not frozen |
| Hash | Prevents add/remove/modify keys | Values themselves not frozen |
| Numeric | No effect | Already immutable |
| Symbol | No effect | Already immutable |
| true/false/nil | No effect | Already immutable |
Persistent Data Structure Libraries
| Library | Collections | Performance | Status |
|---|---|---|---|
| Hamster | List, Vector, Hash, Set | Optimized with structural sharing | Mature |
| Immutable-Ruby | List, Map, Set | Structural sharing | Active |
| Concurrent-Ruby | Array, Hash, Map | Thread-safe with copy-on-write | Production-ready |
Immutability Decision Matrix
| Scenario | Recommendation | Rationale |
|---|---|---|
| Configuration objects | Always immutable | Prevents accidental changes, safe sharing |
| Value objects | Always immutable | Enables value semantics, safe hash keys |
| Domain entities | Selective | Balance between simplicity and performance |
| Large collections with frequent updates | Mutable or persistent structures | Avoid allocation overhead |
| Concurrent data access | Immutable | Eliminates synchronization needs |
| Cache keys | Always immutable | Prevents lookup failures |
| Temporary calculations | Mutable | Avoid unnecessary allocations |
Common Patterns Summary
| Pattern | Use Case | Implementation Cost |
|---|---|---|
| Frozen primitives | Simple immutability | Very low |
| Deep freeze | Complete immutability | Low |
| Copy-on-write | Optimize read-heavy workloads | Medium |
| Persistent structures | Efficient versioning | Medium to high |
| Builder pattern | Complex object construction | Medium |
| Update with keyword args | Selective modifications | Low |
Thread Safety Characteristics
| Approach | Thread Safety | Synchronization Needed | Performance |
|---|---|---|---|
| Immutable objects | Complete | None | Excellent for reads |
| Frozen mutable objects | Complete after freeze | None | Good |
| Mutable with locks | Requires care | Explicit locking | Overhead from locking |
| Copy-on-write | Safe | None | Good for read-heavy |
Performance Comparison
| Operation | Mutable | Immutable Naive | Persistent Structure |
|---|---|---|---|
| Single update | O(1) | O(n) copy | O(log n) |
| Multiple updates | O(n) | O(n²) | O(n log n) |
| Read access | O(1) | O(1) | O(log n) |
| Memory per version | O(1) | O(n) | O(log n) |
| Concurrent reads | Requires locks | Lock-free | Lock-free |