Skip to content

dup is a fresh copy, clone is a faithful one

Level: 201 · anyone who has copied an object and found the copy still shared something with the original

One line: dup and clone both make a new object with the same instance variables, and both are shallow — the copy's arrays and hashes are the original's — but clone also copies the frozen state and the singleton class while dup starts unfrozen with no singleton methods; clone(freeze: false) is the escape hatch, initialize_copy is the hook for copying a field properly, and Marshal.load(Marshal.dump(x)) is the deep copy, which refuses singletons and Procs.

Ruby has two copying methods because an object carries more than its instance variables: it may be frozen, and it may have methods of its own in a singleton class. dup copies the state and nothing else, which is what you want for a working copy of a frozen value. clone copies the state and the extras, which is what you want for a faithful duplicate. Neither copies what the instance variables point at — a copied @tags is the same Array — and both call initialize_copy on the new object with the original as the argument, which is where a class dups the fields that must not be shared. The program below builds a frozen Doc with one singleton method and copies it every way Ruby offers.

Verified output of dup_clone_and_frozen_state_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. both copy the instance variables
   original                                       #<Doc "draft" ["a"]>
   original.dup                                   #<Doc "draft" ["a"]>
   original.clone                                 #<Doc "draft" ["a"]>
   original.dup.equal?(original)                  false
2. frozen state: dup drops it, clone keeps it
   original.frozen?                               true
   original.dup.frozen?                           false
   original.clone.frozen?                         true
   original.clone(freeze: false).frozen?          false
   Doc.new("u", []).clone(freeze: true).frozen?   true
   original.clone.title = "x"                     FrozenError: can't modify frozen Doc: #<Doc "draft" ["a"]>
   fresh = original.dup; fresh.title = "copy"     #<Doc "copy" ["a"]>
3. singleton methods: dup drops them, clone keeps them
   original.singleton_methods                     [:shout]
   original.dup.singleton_methods                 []
   original.clone.singleton_methods               [:shout]
4. both are shallow
   fresh.tags.equal?(original.tags)               true
   fresh.tags << "b"; original.tags               ["a", "b"]
   original.clone.tags.equal?(original.tags)      true
5. Marshal round-trip is the deep copy
   deep = Marshal.load(Marshal.dump(plain))       #<Doc "plain" ["a"]>
   deep.tags.equal?(plain.tags)                   false
   deep.frozen?  (frozen state is not dumped)     false
   Marshal.dump(original)  (has a singleton)      TypeError: singleton can't be dumped
   Marshal.dump(proc {})                          TypeError: no _dump_data is defined for class Proc
6. the hooks: initialize_dup or initialize_clone, then initialize_copy
   Tracked.new.dup.log                            [:initialize_dup, :initialize_copy]
   Tracked.new.clone.log                          [[:initialize_clone, nil], :initialize_copy]
   Tracked.new.clone(freeze: false).log           [[:initialize_clone, false], :initialize_copy]
   copy.items.equal?(tracked.items)  (dup'd in the hook) false
   copy.log.equal?(tracked.log)  (not dup'd: shared) true
7. immediates and frozen literals
   1.dup.equal?(1)                                true
   :a.clone.equal?(:a)                            true
   nil.dup.equal?(nil)                            true
   "x".freeze.dup.frozen?                         false
   frozen? of  "lit"  :sym  1  nil  []  (1..2)    false  true  true  true  false  true
8. Data stays frozen through dup; make_shareable freezes deep
   D.new([1]).frozen?                             true
   D.new([1]).dup.frozen?                         true
   data.dup.a.equal?(data.a)  (still shallow)     true
   Ractor.make_shareable(h); h.frozen?            true
   h[:list].frozen?                               true
9. where they live
   Object.instance_method(:dup).owner             Kernel
   Object.instance_method(:clone).owner           Kernel
   Object.instance_method(:freeze).owner          Kernel
   Kernel.private_instance_methods.include?(:initialize_copy) true

Reading the output

  • 1. dup and clone each return a new object (equal? is false) with the same @title and @tags.
  • 2. The original is frozen. Its dup is not, its clone is, clone(freeze: false) gives an unfrozen clone, and clone(freeze: true) freezes the clone of an unfrozen object. Writing through a clone of the frozen original is a FrozenError; writing through the dup works, which is the everyday reason to reach for dup.
  • 3. The original has a singleton method shout. dup does not carry it; clone does — clone copies the singleton class, and its methods bind to the clone.
  • 4. Both copies are shallow: fresh.tags is the very same Array as original.tags (equal? is true), so appending through the copy shows up in the original; a clone shares it too.
  • 5. Marshal.load(Marshal.dump(obj)) rebuilds the whole graph, so deep.tags is a different Array — but the frozen state is not part of the dump, and Marshal.dump raises TypeError for an object with a singleton method (singleton can't be dumped) and for a Proc. JSON, YAML and Marshal measures the format itself.
  • 6. The hooks run in a fixed order: dup calls initialize_dup, clone calls initialize_clone (with the freeze: keyword it was given, nil when none), and both then call initialize_copy, which is where Tracked dups its @items. The @log was not dup'd, so copy.log.equal?(tracked.log) is true: the two objects hold the same Array, and the entries the copy's hooks appended are therefore in the original's log as well. A class that needs a copy to be independent overrides initialize_copy, calls super, and dups the fields that matter.
  • 7. Immediates are their own copies: 1.dup, :a.clone and nil.dup return the same object. A frozen string's dup is unfrozen — the idiom for a writable copy. Of the literals, :sym, 1, nil and (1..2) are frozen; "lit" and [] are not (a string literal is chilled, not frozen — the Ruby text library's literals are chilled ↗ measures that).
  • 8. A Data instance is frozen and its dup is frozen too — Data re-freezes the copy — while still sharing its members. Ractor.make_shareable is the deep freeze: the Hash and the Array inside it both end up frozen.
  • 9. dup, clone and freeze are Kernel methods, and initialize_copy is a private one, like initialize.

Compared with Python

Python has one shallow copy, copy.copy, because it has nothing for a second one to carry: no frozen flag and no singleton class. Row 2: a frozen=True dataclass is frozen by its class, so its copy is a FrozenDoc and just as frozen, and the FrozenInstanceError is an AttributeError; a plain object's copy is writable. Row 3 is the trap: a bound method stored on the instance is copied into the new __dict__ but still bound to the original (__self__ is original), where Ruby's clone rebinds the singleton method; copy.deepcopy gets it right, because its memo maps the original to the copy. Row 4: shallow, the same as Ruby. Row 5: copy.deepcopy and a pickle round-trip are both deep; pickle refuses a lambda (PicklingError) the way Marshal refuses a Proc, and since copy.copy itself works through __reduce_ex__, an object that cannot be pickled — a MappingProxyType — cannot even be shallow-copied, where Ruby's dup never asks. Row 6: the hooks are __copy__ and __deepcopy__(memo), and a __copy__ that does not copy a field shares it, exactly as initialize_copy does. Row 7: immutables are handed back as is — copy.copy of an int, a str, None or a tuple is the same object — but copy.deepcopy of a tuple that holds a list builds a new tuple; there is no per-object frozen state to report. Row 8: dataclasses.replace and copy.copy of a frozen dataclass are shallow, and deepcopy is not. Row 9: copy is a module, not a method of every object; object has no __copy__, only the __reduce_ex__ that copy and pickle share; and no copy runs __init__.

Verified output of dup_clone_and_frozen_state_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. copy.copy copies the instance dictionary
   original                                       Doc('draft', ['a'])
   copy.copy(original)                            Doc('draft', ['a'])
   sorted(vars(copy.copy(original)))              ['shout', 'tags', 'title']
   copy.copy(original) is original                False
2. frozen state: none to copy; a frozen dataclass is frozen by its class
   type(copy.copy(frozen)).__name__               FrozenDoc
   copy.copy(frozen) == frozen                    True
   copy.copy(frozen).title = 'x'                  FrozenInstanceError
   issubclass(FrozenInstanceError, AttributeError) True
   fresh = copy.copy(original); fresh.title = 'copy' Doc('copy', ['a'])
3. functions stored on the object: copied, still bound to the original
   'shout' in vars(original)                      True
   'shout' in vars(copy.copy(original))           True
   copy.copy(original).shout.__self__ is original True
   copy.deepcopy(original).shout.__self__ is the copy True
4. copy.copy is shallow
   fresh.tags is original.tags                    True
   fresh.tags.append('b'); original.tags          ['a', 'b']
   copy.copy(original).tags is original.tags      True
5. copy.deepcopy and a pickle round-trip are the deep copies
   copy.deepcopy(plain)                           Doc('plain', ['a'])
   deep.tags is plain.tags                        False
   pickle.loads(pickle.dumps(plain))              Doc('plain', ['a'])
   unpickled.tags is plain.tags                   False
   pickle.dumps(plain)  (holds a lambda)          PicklingError
   copy.copy(MappingProxyType({}))  (via __reduce_ex__) TypeError
6. the hooks: __copy__ and __deepcopy__(memo)
   copy.copy(tracked); tracked.log                ['__copy__']
   shallow.items is tracked.items  (copied in the hook) False
   shallow.log is tracked.log  (not copied: shared) True
   copy.deepcopy(tracked2); tracked2.log          ['__deepcopy__']
   deep2.log is tracked2.log                      False
7. immutables are handed back as is
   copy.copy(1) is 1                              True
   copy.copy('x') is 'x'                          True
   copy.copy(None) is None                        True
   copy.copy((1, [2])) is the tuple               True
   copy.deepcopy((1, [2])) is the tuple           False
   frozen-ness of  'lit'  1  None  []  (1, 2)     n/a: Python has no frozen flag; types are immutable or not
8. dataclasses.replace and copy on a frozen dataclass
   dataclasses.replace(frozen, title='r')         FrozenDoc(title='r', tags=['a'])
   replaced.tags is frozen.tags  (shallow)        True
   copy.deepcopy(frozen).tags is frozen.tags      False
   copy.copy(frozen).tags is frozen.tags          True
9. where they live
   copy.copy.__module__                           copy
   hasattr(object, '__copy__')                    False
   hasattr(object, '__reduce_ex__')  (what copy uses) True
   (__init__ runs)
   copy.copy(loud)  (no __init__ line follows)    {'x': 1}
   copy.deepcopy(loud)  (nor here)                {'x': 1}

Try it

  1. Write a Config class holding a Hash of settings and show that a plain dup shares the Hash (a change through the copy is visible in the original). Add an initialize_copy that calls super and dups the Hash, and show the copy is now independent. Then freeze the original and print frozen? for its dup and its clone, and show which of the two can be written to.
  2. Write deep_dup(obj) that recursively dups Arrays and Hashes (and returns anything else as is), and compare it with Marshal.load(Marshal.dump(obj)) on { list: [[1]], code: -> { 1 } }: which one succeeds, and is the inner [1] shared afterwards?
Solution to 1

dup_clone_and_frozen_state_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Exercise 1: a Config whose dup gets its own settings Hash. Without the hook
# the copy shares the Hash; with initialize_copy it does not. Then freeze the
# original and compare dup with clone.

class SharedConfig
  attr_reader :settings
  def initialize(settings) = @settings = settings
end

class OwnConfig < SharedConfig
  def initialize_copy(source)
    super
    @settings = @settings.dup
  end
end

shared = SharedConfig.new({ "debug" => false })
shared_copy = shared.dup
shared_copy.settings["debug"] = true
puts "without the hook: original settings  #{shared.settings.inspect}"

own = OwnConfig.new({ "debug" => false })
own_copy = own.dup
own_copy.settings["debug"] = true
puts "with the hook:    original settings  #{own.settings.inspect}"
puts "                  copy settings      #{own_copy.settings.inspect}"

own.freeze
puts "own.frozen?          #{own.frozen?}"
puts "own.dup.frozen?      #{own.dup.frozen?}"
puts "own.clone.frozen?    #{own.clone.frozen?}"
begin
  own.clone.instance_variable_set(:@settings, {})
rescue FrozenError => e
  puts "clone writable?      #{e.class}"
end
own.dup.instance_variable_set(:@settings, {})
puts "dup writable?        yes"

Verified output of dup_clone_and_frozen_state_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

without the hook: original settings  {"debug" => true}
with the hook:    original settings  {"debug" => false}
                  copy settings      {"debug" => true}
own.frozen?          true
own.dup.frozen?      false
own.clone.frozen?    true
clone writable?      FrozenError
dup writable?        yes

See also