CrackedRuby CrackedRuby

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:

  1. Identify boundaries where immutability provides value
  2. Introduce immutable types at architectural boundaries
  3. Gradually expand immutable regions
  4. 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