skip to content

In Ruby, how do you override initialize_copy so that dup and clone of a document template also copy its nested sections?

level: middleimportance: should knowfreq 38%

answer

  1. hook runs on the new copy
  2. ivars already copied before it
  3. call super, then replace ivars
  4. initialize never runs for copies
  5. private automatically

basics

~10 s

Define initialize_copy(source), call super, then replace the shared instance variables with copies, such as @sections = @sections.map(&:dup). dup reaches it through initialize_dup and clone through initialize_clone, after the ivars are copied; initialize never runs.

solid answer

~40 s

Both copy methods allocate the new object, copy the instance variable references, then call a hook on the **copy**: `dup` calls `initialize_dup(source)`, `clone` calls `initialize_clone(source)` (plus `freeze:` when given, since Ruby 3.0), and both defaults call `initialize_copy(source)`. When your `initialize_copy` runs, `@sections` already points at the source's Array, so you call `super` and reassign: `@sections = @sections.map(&:dup)`. `initialize` is not called, so anything it sets up (ids, timestamps, counters) must be handled here. Ruby makes `initialize_copy`, `initialize_dup` and `initialize_clone` private automatically. For `clone` the hook runs before the frozen state is copied, so it can assign ivars even when cloning a frozen master; override `initialize_dup` or `initialize_clone` only when the two copies must differ.

code

ruby · 22 lines
ruby
Section = Struct.new(:heading, :body)

class DocumentTemplate
  attr_reader :name, :sections

  def initialize(name, sections)
    @name = name
    @sections = sections
  end

  def initialize_copy(source)
    super
    @sections = @sections.map(&:dup)
  end
end

master = DocumentTemplate.new("Invoice", [Section.new("Header", "ACME")]).freeze
draft = master.dup
draft.sections.first.heading = "Title"
master.sections.first.heading   # => "Header"
master.clone.frozen?            # => true, the hook ran before the freeze
DocumentTemplate.private_method_defined?(:initialize_copy)  # => true

go deeper

for a junior

Recall that initialize_copy is the hook both dup and clone call on the new copy, and that it should call super.

for a middle

Explain the order: allocate, copy ivars, hook, then frozen state for clone, and why the hook reassigns ivars that still point at the source's objects.

for a senior

Handle what initialize never does for copies, such as ids and counters, keep Array and Hash subclasses working with super, and accept freeze: in initialize_clone.

for a principal

Decide which types own copy semantics and document them, so copies of shared aggregates behave predictably across teams.

## The copy protocol Every ordinary `dup` or `clone` follows the same steps inside CRuby: 1. **Allocate** an empty object of the source's class. `initialize` is **not** called. 2. **Copy instance variables**: each ivar on the copy now references the same object as on the source. 3. For `clone` only, copy the **singleton class**. 4. Call the **hook** on the copy: `initialize_dup(source)` for `dup`, `initialize_clone(source)` or `initialize_clone(source, freeze: ...)` for `clone`. 5. The default `initialize_dup` and `initialize_clone` both call **`initialize_copy(source)`**. 6. For `clone`, apply the **frozen state** (copied, or forced by `freeze:`) after the hook returns. Because step 2 already shared everything, the hook's job is to **un-share** what the copy will mutate. ## Writing initialize_copy For a document template whose sections are edited per draft: ```ruby class DocumentTemplate attr_reader :name, :sections def initialize(name, sections) @name = name @sections = sections end def initialize_copy(source) super @sections = @sections.map(&:dup) end end ``` - `source` is the original; `self` is the new copy. - `@sections` on the copy still references the source's Array when the hook starts, so `map(&:dup)` builds a new Array of copied sections. - `@name` stays shared, which is fine if names are never mutated in place. ## Why call super `Kernel#initialize_copy` checks that the source is the same kind of object and that the copy is not frozen, and core classes put real work there. `Array`, `Hash` and `String` define `initialize_copy` as their `replace` method, which is how a `dup` of them gets its contents. A subclass of `Array` or `Hash` that overrides the hook without `super` returns copies with **no elements**. Calling `super` first keeps every class in the chain working. ## Frozen sources and clone A frozen master template is the common case: - `master.dup` runs the hook on an unfrozen copy and returns it unfrozen. - `master.clone` runs the hook **before** the frozen state is applied to the copy, so assigning `@sections` works, and then the copy is frozen. - `master.clone(freeze: false)` runs the hook and leaves the copy unfrozen. The hook runs on the copy, never on the source, so a frozen source is never written to. ## initialize_dup vs initialize_clone | Hook | Called by | Default behaviour | Override when | |---|---|---|---| | `initialize_copy(source)` | both, via the two below | class check (plus `replace` in core collections) | the copy logic is the same for both | | `initialize_dup(source)` | `dup` | calls `initialize_copy` | only `dup` needs extra work | | `initialize_clone(source, freeze: nil)` | `clone` | calls `initialize_copy` | only `clone` needs extra work; accept `freeze:` since Ruby 3.0 | All three are made **private automatically**, like `initialize`, so there is no need to write `private` and callers cannot invoke them directly. ## Pitfalls - **Relying on `initialize`**: it never runs for copies. A template that assigns `@id = SecureRandom.uuid` in `initialize` will hand every copy the same id unless the hook assigns a new one. - **Copying too deep**: duplicating shared, immutable parts (a logo, a frozen style sheet) wastes memory; copy only what a copy mutates. - **Forgetting `freeze:`** in an `initialize_clone` override: `clone(freeze: true)` passes the keyword, and a signature without it raises `ArgumentError`. - **Mutating `source`**: the hook should read the source and write only to `self`. ## Testing the hook A copy hook is easy to break silently when someone adds a new mutable instance variable, so pin its behaviour with a few checks: - the copy's nested Array is not `equal?` to the source's; - editing a section on the copy leaves the source's section unchanged; - `clone` of a frozen source returns a frozen copy and does not raise; - `clone(freeze: false)` of a frozen source returns an editable copy. When a new instance variable is added to the class, the review question is always the same: is it shared on purpose, or must the hook copy it too?

  • How should an `initialize_clone` override be written on Ruby 3.0 and later?
    Accept the keyword and pass it on: `def initialize_clone(source, freeze: nil)` followed by `super`. Since Ruby 3.0, `clone(freeze: true)` and `clone(freeze: false)` call `initialize_clone` with that keyword, so an override that takes only `source` raises ArgumentError for those calls.
  • Why does a subclass of Hash lose its entries when its `initialize_copy` override skips `super`?
    The generic copy step copies instance variables, not a Hash's entries. `Hash#initialize_copy` is `Hash#replace`, which fills the new Hash from the source. Skipping `super` skips that call, so the copy starts empty.

saying these in an interview costs you the question

  • dup runs initialize again, so copy logic belongs in initialize.
  • initialize_copy must be made public for dup and clone to call it.
  • Inside initialize_copy the copy's instance variables are still nil.
  • initialize_copy runs on the source object, so it must not modify self.
  • Cloning a frozen object makes any assignment in initialize_copy raise FrozenError.