In Ruby, how would you deep-freeze a nested document template, and which objects does a hand-written recursive freeze miss or wrongly freeze?
answer
- no deep option on freeze
- freeze first, then recurse
- custom objects hide ivars
- shared inputs frozen for everyone
- memoizing methods start raising
basics
~20 sRuby's freeze is shallow, so you recurse yourself: freeze the object, then walk Hash keys and values and Array elements. Such a helper misses state inside custom objects' instance variables, and can wrongly freeze objects the caller still shares and mutates.
solid answer
~40 sA typical helper returns early if `obj.frozen?`, freezes `obj`, then recurses into `Hash` values and `Array` elements; freezing before recursing also stops infinite loops on cyclic structures. Its blind spots: a `Section` object gets frozen itself, but objects held in its instance variables are never visited, so each custom class should override `freeze` to freeze its own parts and call `super`. It also freezes whatever it reaches, including Arrays and Strings the caller passed in and still uses elsewhere, so freeze a copy you own. After a deep freeze, methods that memoize with `@x ||=` raise `FrozenError`, and some objects refuse to freeze at all: `Thread::Queue#freeze` raises `TypeError` since Ruby 3.3. Hash String keys are already frozen copies. `Ractor.make_shareable` is the built-in deep freezer.
code
ruby · 28 linesmodule DeepFreeze
def self.call(obj)
return obj if obj.frozen?
obj.freeze
case obj
when Hash then obj.each_value { |v| call(v) }
when Array then obj.each { |v| call(v) }
end
obj
end
end
class Section
attr_reader :lines
def initialize(lines)
@lines = lines
end
def freeze
@lines.freeze
super
end
end
layout = { sections: [Section.new(["Dear customer"])] }
DeepFreeze.call(layout)
layout[:sections].first.lines.frozen? # => true, via Section#freezego deeper
Recall that freeze is shallow, so a nested structure needs every level frozen to be fully immutable.
Write a helper that checks frozen?, freezes first and recurses into Hash values and Array elements, and explain why that order handles cycles.
Point out the helper's blind spots: custom objects' instance variables, shared caller inputs, memoizing methods and objects like Thread::Queue that refuse to freeze.
Decide where immutability is enforced, at load or publish time, per class through freeze overrides, or with Ractor.make_shareable, and what that costs.
## Why you need a helper `Kernel#freeze` freezes exactly one object, and it has **no deep option**. A document template is a graph: the template object, its sections Array, each Section, each Section's lines, the Strings inside them. Freezing the template leaves every other node mutable, so a "frozen" master can still be edited through `master.sections.first.lines << "x"`. ## A recursive helper ```ruby module DeepFreeze def self.call(obj) return obj if obj.frozen? obj.freeze case obj when Hash then obj.each_value { |v| call(v) } when Array then obj.each { |v| call(v) } end obj end end ``` Three design choices matter: 1. **Check `frozen?` first.** Already-frozen subgraphs are skipped, and Integers, Symbols and similar always-frozen values return immediately. 2. **Freeze before recursing.** If the structure contains a cycle (`a = []; a << a`), the second visit sees a frozen object and stops, instead of recursing forever. 3. **Hash keys need no work** when they are Strings: `Hash#[]=` already stores a duplicated, frozen copy of an unfrozen String key. Other mutable keys are a bad idea anyway. ## What the helper misses The helper only knows about `Hash` and `Array`. A custom object such as a `Section` is **frozen itself** (its instance variables can no longer be reassigned), but the objects those instance variables reference are never visited: - `section.lines` still returns a mutable Array; - a `Struct` member holding an Array stays mutable in the same way. The robust fix is to let each class freeze its own parts: ```ruby class Section def freeze @lines.freeze super end end ``` Now `DeepFreeze.call(template)` and a plain `section.freeze` both protect the lines. The ostruct library uses the same pattern, freezing its internal table before calling `super`. ## What the helper wrongly freezes A deep freeze follows **references**, and references are shared. Trouble spots: - **Caller-owned inputs.** If the template was built from an Array the caller still holds, freezing it in place breaks the caller's later `<<` with `FrozenError`, far from the helper. Freeze a copy you own, for example after an `initialize_copy` that duplicates the sections. - **Objects shared with other templates.** Freezing a style object that another, editable template also uses freezes it for that template too. - **Objects that must stay live.** Some objects refuse or should not be frozen: since Ruby 3.3 `Thread::Queue#freeze` raises `TypeError`, and freezing a class or module stops new methods being defined on it. A helper that walks arbitrary instance variables can reach these. ## Costs after the freeze | Consequence | Symptom | Remedy | |---|---|---| | Memoization in methods | `FrozenError` on first call to `@total ||= ...` | compute eagerly before freezing, or do not cache | | Lazy setup in objects | `FrozenError` deep inside a library object | freeze only data you own | | Walking large graphs | freezing time grows with the graph | freeze once, at load or publish time | ## Built-in alternatives - `Ractor.make_shareable(obj)` deep-freezes an object graph, including plain objects' instance variables, because Ractors may only share deeply frozen data. It is the most complete built-in tool, and it raises `Ractor::Error` when the graph holds an object that cannot be made shareable. - The `# shareable_constant_value: literal` magic comment deep-freezes literals assigned to constants in that file. Both exist for Ractor safety, but they are useful whenever a structure must be fully immutable. ## Choosing an approach - A handful of known classes: give each one a `freeze` override and freeze the root; the graph freezes itself. - Plain data from YAML or JSON (Hashes, Arrays, Strings, numbers): the small recursive helper is enough. - Arbitrary object graphs that must be fully immutable: `Ractor.make_shareable`, accepting that it freezes everything it reaches and fails loudly on what it cannot. Whichever approach you choose, test it: assert that `frozen?` is `true` for a sample of nested nodes, and that editing a nested section raises `FrozenError`.
- Why does the helper freeze an object before recursing into it rather than after?Freezing first marks the node as visited. If the graph has a cycle, the recursive call reaches the same object again, sees `frozen?` is true and returns, so the walk terminates. Freezing after recursing would revisit the cycle forever and raise SystemStackError.
- How does overriding `freeze` in a custom class improve a generic deep-freeze helper?The helper cannot see a custom object's instance variables, but it does call `freeze` on the object. If the class overrides `freeze` to freeze its own parts and then call `super`, every caller, the helper included, gets the nested state frozen without the helper knowing the class.
saying these in an interview costs you the question
- Freezing each Hash and Array level also freezes the ivars of custom objects inside.
- Calling freeze on an already frozen object raises FrozenError.
- Deep-freezing arguments in place is safe because callers never reuse them.
- Methods that memoize into instance variables keep working after a deep freeze.
- A recursive freeze helper needs no guard for cyclic structures.