[Ruby] Clone, Dup 和 Shallow copy
在 Ruby 裡面所見幾乎任何東西都是物件 (Object),為了要複製物件,常常我們會用到 dup
或者 clone
,本篇記錄下這兩者在使用上的不同:
a = *10.downto(1) # * = Splat operator
# => [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
b = a.clone
# => [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
c = a.dup
# => [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
從上面的結果看來,使用 dup
和 clone
似乎沒有區別,
不去深究的話可能會誤以為和 inject
& reduce
同樣是互為別名 (alias) 一般的存在,但這兩個複製物件的方法實際上是有差的。
內部狀態 (internal state)
其中一個差別在於 clone
方法在複製物件的同時,會一併複製物件完整的內部狀態 。
那麼物件有哪些可複製的內部狀態?
- frozen
- tainted
- singleton methods
舉個例子:
a = Object.new.taint.freeze
b = a.clone
b.frozen? # => true
b.tainted? # => true
c = a.dup
c.frozen? # => false
c.tainted? # => true
# Ruby 2.4 以後,可傳入 keyword argument (freeze: false) 選擇不去複製 freeze 狀態。
d = a.clone(freeze: false)
d.frozen? # => false
可以看到 dup
方法在複製物件的時候無視了原物件 frozen 的狀態,讓新產生的物件可以再次被修改。而另一方面,使用 clone
方法的話,在 Ruby 2.4 以上版本要額外加入 keyword argument freeze: false
才能從一個被凍結的物件複製出解凍的副本。
class Dog
def bark
puts "woof"
end
end
archie = Dog.new
# 給 archie 新增一個 singleton method: #owner
archie.instance_eval do
def owner
puts 'Steve'
end
end
bella = archie.clone
charlie = archie.dup
archie.owner # => Steve
bella.owner # => Steve
charlie.owner # => NoMethodError
而且只有 clone
能夠複製物件的 singleton method。
模組 (module)
根據 APIdock 對 dup 的描述,從子類別的角度來看待這兩個方法也有些語義上的差異,
clone
常用來複製目標物件和它的內部狀態,dup
則比較多用在目標的類別底下創造一個新的實例。
進一步看,dup
方法除了會忽略前面提到原物件的 frozen 狀態之外,還會忽略原物件所延伸 (extend) 的模組們。
class Klass
attr_accessor :str
end
module Foo
def foo; 'foo'; end
end
s1 = Klass.new
s1.extend(Foo)
s1.singleton_methods # => [:foo]
s1.foo # => foo
s2 = s1.clone
s3.singleton_methods # => [:foo]
s2.foo # => foo
s3 = s1.dup
s3.singleton_methods # => []
s3.foo # => NoMethodError (undefined method `foo' for #<Klass:0x00007fe10710c878>)
淺層複製 (shallow copy)
這兩個複製方法同屬淺層複製,複製對象如果含有其他物件的參照,例如陣列中包含著字串物件,結果也只是複製那些字串的參照,結果會指向同一個物件,這點可以從完全相同的 Object Id 看出來:
a = %w[app bee cake]
a.map(&:object_id)
# => [70260548767440, 70260548767420, 70260548767400]
b = a.clone
b.map(&:object_id)
# => [70260548767440, 70260548767420, 70260548767400]
在這個情況下如果修改了陣列 b
當中的字串內容,同時也會影響原始物件 a
當中的資料:
a # => ["app", "bee", "cake"]
b # => ["app", "bee", "cake"]
b[0].capitalize! # => "App"
b # => ["App", "bee", "cake"]
a # => ["App", "bee", "cake"]
以這個例子來說,重新指派值好像沒問題:
b[0] = 'Deep'
b # => ["Deep", "bee", "cake"]
a # => ["App", "bee", "cake"]
但是如果更深一層就不行了:
a = [0, [1, 2]]
b = a.clone
b[0] = 9
b[1][0] = 8
b # => [9, [8, 2]]
a # => [0, [8, 2]]
這就是淺層複製的問題,如果不希望影響到原陣列的內容,一個快速解決的手段是連同陣列的下一層也複製起來:
c = a.clone.map(&:clone)
c[0] = 9
c[1][0] = 8
c # => [9, [8, 2]]
a # => [0, [1, 2]]
或者進行深層複製 (deep copy),這可以透過 Ruby 的標準函式庫 Marshal 來處理:
a = %w[app bee cake]
b = Marshal.load(Marshal.dump(a))
a.map(&:object_id)
# => [70260548496640, 70260548496620, 70260548496460]
b.map(&:object_id)
# => [70260548515100, 70260548515020, 70260548514900]
b.each(&:upcase!) # => ["APP", "BEE", "CAKE"]
a # => ["app", "bee", "cake"]
initialize_copy
另外有一個和 class 的複製相關的特殊方法:initialize_copy
,會在複製完成後啟動:
class Klass
attr_accessor :name
attr_reader :timestamp
def initialize(name)
@name = name
@timestamp = Time.now
end
end
a = Klass.new('Object1')
b = a.clone
a.timestamp == b.timestamp # => true
可以看到兩者是完全一樣的,因為 initialize
建構子被跳過了,所以複製的物件和原物件 timestamp 完全相同。如果要在複製的同時押上新的 timestamp,可以透過 initialize_copy
方法:
class Klass
def initialize_copy(other)
@timestamp = Time.now
end
end
c = Klass.new('Object2')
d = c.clone
c.timestamp == d.timestamp # => false
小結
使用上要注意到 clone
會複製目標對象的內部狀態和 singleton_methods
,dup
只會複製對象本身的內容,且不含 frozen 狀態。此外,這兩個方法都只是淺層複製,如果一不小心直接變更複製的物件中所參照的其他物件,可能會導致意外地影響原始物件的內容,不可不慎。