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.
dupandcloneeach return a new object (equal?isfalse) with the same@titleand@tags. - 2. The original is frozen. Its
dupis not, itscloneis,clone(freeze: false)gives an unfrozen clone, andclone(freeze: true)freezes the clone of an unfrozen object. Writing through a clone of the frozen original is aFrozenError; writing through thedupworks, which is the everyday reason to reach fordup. - 3. The original has a singleton method
shout.dupdoes not carry it;clonedoes —clonecopies the singleton class, and its methods bind to the clone. - 4. Both copies are shallow:
fresh.tagsis the very same Array asoriginal.tags(equal?istrue), so appending through the copy shows up in the original; acloneshares it too. - 5.
Marshal.load(Marshal.dump(obj))rebuilds the whole graph, sodeep.tagsis a different Array — but the frozen state is not part of the dump, andMarshal.dumpraisesTypeErrorfor an object with a singleton method (singleton can't be dumped) and for aProc. JSON, YAML and Marshal measures the format itself. - 6. The hooks run in a fixed order:
dupcallsinitialize_dup,clonecallsinitialize_clone(with thefreeze:keyword it was given,nilwhen none), and both then callinitialize_copy, which is whereTrackeddups its@items. The@logwas not dup'd, socopy.log.equal?(tracked.log)istrue: 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 overridesinitialize_copy, callssuper, and dups the fields that matter. - 7. Immediates are their own copies:
1.dup,:a.cloneandnil.dupreturn the same object. A frozen string'sdupis unfrozen — the idiom for a writable copy. Of the literals,:sym,1,niland(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
Datainstance is frozen and itsdupis frozen too —Datare-freezes the copy — while still sharing its members.Ractor.make_shareableis the deep freeze: the Hash and the Array inside it both end up frozen. - 9.
dup,cloneandfreezeareKernelmethods, andinitialize_copyis a private one, likeinitialize.
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¶
- Write a
Configclass holding a Hash of settings and show that a plaindupshares the Hash (a change through the copy is visible in the original). Add aninitialize_copythat callssuperand dups the Hash, and show the copy is now independent. Thenfreezethe original and printfrozen?for itsdupand itsclone, and show which of the two can be written to. - Write
deep_dup(obj)that recursively dups Arrays and Hashes (and returns anything else as is), and compare it withMarshal.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.
See also¶
- Every object has a class of its own — the singleton class that
clonecopies anddupleaves behind newisallocatetheninitialize—initialize_copynext toinitialize, and why neither copy callsinitialize- Variables are references — why a shallow copy shares its arrays
freezeandFrozenError— the frozen state itself,Ractor.make_shareableand the frozen-string-literal comment- Struct and Data —
Datais frozen from birth,withis its copy-and-change - JSON, YAML and Marshal — the
Marshalformat, and what it refuses - Ractors share nothing — where
make_shareable's deep freeze is required - ObjectSpace and GC — copies are new objects; what happens to the ones nobody holds
- String literals are chilled, not frozen ↗ — why
"lit".frozen?isfalsein row 7 - Ruby docs:
Object#cloneandObject#dup↗ — the reference, with thefreeze:keyword - Python docs:
copy↗ — shallow, deep, and the two hooks - A Hash key needs
eql?andhash, not==— AHashfinds a key by callinghashand theneql?