Object#freeze turns an object into an immutable one. In this article we are going to uncover a counterintuitive behavior related to the difference between a reference and a variable.

From the documentation:

Prevents further modifications to obj. A RuntimeError will be raised if modification is attempted. There is no way to unfreeze a frozen object.

a = ["a", "b", "c"].freeze
a.frozen? # => true
a << "d" # => RuntimeError: can't modify frozen Array
a.push("d") # => RuntimeError: can't modify frozen Array
a += ["d"] # => ["a", "b", "c", "d"]
a # => ["a", "b", "c", "d"]
a.frozen? # => false

In fact, the explanation behind this behavior is quite obvious.

Object#freeze acts on an object reference, not on the variable itself. += is an assignment operation, not a method call. This is why using += cannot throw an exception.

a += x is equivalent to a = a + x. a + x produces a new object that will be assigned to a. The object previously referenced by a is still present in memory until the garbage collector starts. Now a can no longer be used to access this (frozen) object because a refers to the newly created object (not frozen).

Demonstration with code:

a = [ "a", "b", "c" ].freeze
a.object_id # => 70102913497680
a += ["d"] # => ["a", "b", "c", "d"]
a.object_id # => 70102913523760
ObjectSpace._id2ref(70102913497680) # => ["a", "b", "c"]
ObjectSpace._id2ref(70102913497680) # => RangeError: 0x003fc21b41ba50 is recycled object

In this demonstration we clearly see that += produces a new object (cf. Object#object_id) that is assigned to a. We can still access the previous object by using ObjectSpace::_id2ref. Once we start the garbage collector, this object is freed from memory (RangeError).

The question you have to ask yourself is whether you want to modify an existing object (calling a method) or create a new one (assignment).