skip to content

In Ruby, what does Object#freeze prevent, and why can a frozen constant Hash still see its nested Array change?

level: juniorimportance: must knowfreq 64%

answer

  1. freeze returns self
  2. FrozenError, a RuntimeError
  3. no unfreeze; dup copies
  4. one level deep only
  5. reassigning a constant only warns

basics

~20 s

freeze stops changes to that one object's own state and returns the object; later writes raise FrozenError. It is shallow: objects it references stay mutable. Freezing a constant's value also does not stop the constant being reassigned, which only warns.

solid answer

~40 s

`obj.freeze` marks the object so any write to it, such as `<<`, `[]=`, an attribute writer or `@x =` inside a method, raises `FrozenError`, a subclass of `RuntimeError`. It returns the object, `frozen?` reports the state, and there is no unfreeze; `dup` returns an unfrozen copy instead. The freeze covers only that object: with `DEFAULTS = { sections: ["intro"] }.freeze`, `DEFAULTS[:extra] = 1` raises, but `DEFAULTS[:sections] << "body"` succeeds, because the Array is a separate, unfrozen object. The constant itself is a second, independent issue: `DEFAULTS = {}` later in the program rebinds it and prints `already initialized constant DEFAULTS`. Integers, Floats and Symbols are always frozen.

code

ruby · 15 lines
ruby
DEFAULTS = { sections: ["intro"], title: "Invoice" }.freeze

DEFAULTS.frozen?                 # => true
DEFAULTS[:sections] << "body"    # works: the nested Array is not frozen
DEFAULTS[:sections]              # => ["intro", "body"]

begin
  DEFAULTS[:footer] = "x"
rescue FrozenError => e
  e.message   # => "can't modify frozen Hash: {sections: [\"intro\", \"body\"], title: \"Invoice\"}"
  e.receiver.equal?(DEFAULTS)    # => true
end

DEFAULTS = {}                    # warning: already initialized constant DEFAULTS
5.frozen?                        # => true

go deeper

for a junior

Recall that freeze makes writes raise FrozenError, that frozen? reports it and that there is no unfreeze, only dup.

for a middle

Explain that freeze is one level deep and that rebinding a constant is a separate issue that only produces a warning.

for a senior

Freeze shared constants and their nested collections, avoid memoizing on frozen objects, and use FrozenError#receiver to trace offending writes.

for a principal

Set a codebase convention for immutable shared configuration, including how deep constants are frozen and how violations are caught in review.

## What freeze does `Kernel#freeze` sets a flag on **one object**. From then on: - any operation that would change that object's state raises **`FrozenError`**, whose message reads like `can't modify frozen Hash`; - `frozen?` returns `true`; - the flag is permanent: Ruby has **no unfreeze**. To get an editable version you make a copy with `dup`, which is never frozen. `freeze` returns the receiver, which is why the idiom `CONSTANT = [...].freeze` works in one expression. `FrozenError` is a subclass of **`RuntimeError`**, so a bare `rescue` catches it, and `FrozenError#receiver` returns the object that refused the write. ## What counts as a modification For a frozen object, all of these raise `FrozenError`: 1. Mutating methods of core classes: `Array#<<`, `Hash#[]=`, `Hash#delete`, `String#<<`. 2. Assigning an instance variable, including through an `attr_writer` and including **memoization** with `@cache ||= ...` inside a method. 3. Defining a singleton method on the object. Reading is always allowed, and so is calling non-mutating methods such as `map` or `merge`, which return **new** objects. ## One level deep `freeze` does not look inside the object. Everything the frozen object references keeps its own frozen state: | Code after `DEFAULTS = { sections: ["intro"], title: "Invoice" }.freeze` | Result | |---|---| | `DEFAULTS[:footer] = "x"` | `FrozenError`: the Hash is frozen | | `DEFAULTS.delete(:title)` | `FrozenError` | | `DEFAULTS[:sections] << "body"` | succeeds: the Array is not frozen | | `DEFAULTS[:sections].frozen?` | `false` | | `DEFAULTS.merge(footer: "x")` | returns a new, unfrozen Hash | To protect the nested levels you have to freeze them too, for example `{ sections: ["intro"].freeze }.freeze`, or use a deep-freeze helper. ## Constants: two separate protections A constant name and the object it names are independent, and `freeze` touches only the object: - **Rebinding the name**: `DEFAULTS = {}` a second time is allowed. Ruby prints `already initialized constant DEFAULTS` and, when it knows the location, `previous definition of DEFAULTS was here`, then rebinds. - **Mutating the object**: prevented only by freezing it, one level at a time. So a frozen constant can still be rebound, and an unfrozen constant can be mutated in place from anywhere with no warning at all. The in-place mutation is the more dangerous one because it is silent; that is why style guides and RuboCop's `Style/MutableConstant` cop push for freezing constant values. ## Values that are always frozen Some objects cannot change and report `frozen?` as `true` from the start: Integers, Floats, Symbols, `nil`, `true` and `false`. Calling `freeze` on them is harmless. Calling `freeze` on an object that is already frozen simply returns it. ## Freezing your own objects A class can freeze its instances at the end of `initialize` (`freeze` as the last line), which turns every later attribute write into a `FrozenError`. That makes an object a read-only value from birth, but it carries the same one-level limit: instance variables that hold Arrays or Hashes should be frozen, or copied and frozen, before the final `freeze`. It also rules out lazy caching in instance variables, so compute derived values up front. ## Practical guidance - Freeze constant collections and their nested collections, so shared defaults cannot be edited by one caller for everyone. - Use `frozen?` in guards and `dup` when you need an editable copy of a frozen value. - Do not memoize into instance variables on objects you intend to freeze. - When a `FrozenError` appears, `receiver` on the error names the object, which usually points straight at the shared constant someone tried to edit.

  • How do you get an editable version of a frozen object?
    Copy it with `dup`, which always returns an unfrozen copy, or `clone(freeze: false)` when singleton methods must be kept. The original stays frozen; there is no way to unfreeze it. Remember the copy is shallow, so nested objects are shared with the frozen original.
  • Why does a method that memoizes with `@total ||= compute` raise on a frozen object?
    `||=` assigns the instance variable when it is nil, and assigning an instance variable is a modification of the object. On a frozen object that raises FrozenError the first time the method runs, so objects meant to be frozen must compute such values eagerly or not cache them.

saying these in an interview costs you the question

  • freeze recursively freezes every object the receiver references.
  • Freezing a constant's value stops the constant from being reassigned.
  • Calling dup on a frozen object unfreezes that same object.
  • Writing to a frozen object raises TypeError.
  • Integers and Symbols stay mutable until you call freeze on them.