ในโพสต์วันนี้ เราจะ看 vào Ruby's #dup และ #clone เราจะเริ่มด้วย real-life example ที่กระตุ้นความสนใจนี้ หลังจากนั้น เราจะดำดิ่งลึกขึ้นเพื่อเรียนรู้ว่า #dup ถูก implement ใน Ruby อย่างไร และมันเปรียบเทียบกับ #clone อย่างไร เราจะปิดท้ายด้วยการ implement #dup method ของตัวเอง มาเริ่มกัน!
How I Started Using Dup
เมื่อผมทำงานที่บริษัทที่專門ตั้งแคมเปญสำหรับ NGOs เพื่อเก็บเงินบริจาค ผมต้อง copy campaigns และสร้างใหม่บ่อยครั้ง ตัวอย่างเช่น หลังจากแคมเปญ 2018 จบ ต้องการแคมเปญใหม่สำหรับ 2019
แคมเป็นปกติมี loads of configuration options ซึ่งผมไม่ค่อยอยาก setup ใหม่ มันจะใช้เวลานานและ error-prone ดังนั้นผมเริ่มด้วยการ copy DB record และดำเนินต่อจากนั้น
สำหรับแคมเป็นแรกๆ ผมจริงๆ copy ด้วยมือ มัน看起來像這樣:
current_campaign = Campaign.find(1)
new_campaign = current_campaign
new_campaign.id = nil
new_campaign.created_at = nil
new_campaign.updated_at = nil
new_campaign.title = "Campaign 2019"
new_campaign.save!
這有效但需要大量打字 更不用說它容易出錯 我過去幾次忘記將 created_at 設為 nil
既然這感覺有點痛苦 我無法想像這是最好的方法 原來 有更好的方法!
new_campaign = Campaign.find(1).dup
new_campaign.title = "Campaign 2019"
new_campaign.save!
這將設定 ID 和 timestamps 為 nil ซึ่ง正是我們想達成的
這就是我第一次開始使用 #dup 的方式 現在 讓我們去深入了解 #dup 實際上如何運作
What Is Going on Under the Hood?
Default Ruby implementation ของเมธอด #dup 允許你添加一個特殊的 initializer 到你的對象 只有當對象通過 #dup 方法初始化時才會被調用 這些方法是:
initialize_copyinitialize_dup
這些方法的實現其實很有趣 因為它們預設不做任何事 它們基本上是 placeholders 供你 override
這直接取自 Ruby source code:
VALUE rb_obj_dup(VALUE obj) {
VALUE dup;
if (special_object_p(obj)) {
return obj;
}
dup = rb_obj_alloc(rb_obj_class(obj));
init_copy(dup, obj);
rb_funcall(dup, id_init_dup, 1, obj);
return dup;
}
對我們來說 有趣的部分在 line 11 Ruby 呼叫 initializer method #initialize_dup
rb_funcall是一個在 ruby C code 中大量使用的函數 它用於在對象上調用方法 在這種情況下 它將在dup對象上調用id_init_dup1告訴有多少參數 在這種情況下只有一個:obj
讓我們深入一點看那個實現:
VALUE rb_obj_init_dup_clone(VALUE obj, VALUE orig) {
rb_funcall(obj, id_init_copy, 1, orig);
return obj;
}
正如你在這個例子中看到的 除了調用 id_init_copy 之外 沒有發生任何實際的事情 現在我們進入了兔子洞 讓我們也看看那個方法:
VALUE rb_obj_init_copy(VALUE obj, VALUE orig) {
if (obj == orig) return obj;
rb_check_frozen(obj);
rb_check_trusted(obj);
if (TYPE(obj) != TYPE(orig) || rb_obj_class(obj) != rb_obj_class(orig)) {
rb_raise(rb_eTypeError, "initialize_copy should take same class object");
}
return obj;
}
即使有更多代碼 除了內部所需的一些檢查 (但那可能是另一個時候的好主題) 沒有發生特別的事情
所以在實現中發生的是 Ruby 給你一個 endpoint 並提供所需的工具來實現你自己的有趣行為
Rails' Dup Implementation
這正是 Rails 在一堆地方所做的 但現在我們只關心 id 和 timestamp fields 如何被清除
ID 在 ActiveRecord 的 core module 中被清除 它考慮了你的 primary key 是什麼 所以即使你改變了它 它仍然會 reset 它
# activerecord/lib/active_record/core.rb
def initialize_dup(other) # :nodoc:
@attributes = @attributes.deep_dup
@attributes.reset(self.class.primary_key)
_run_initialize_callbacks
@new_record = true
@destroyed = false
@_start_transaction_state = {}
@transaction_state = nil
super
end
Timestamps 在 Timestamps module 中被清除 它告訴 Rails 清除所有 Rails 可以用於 creating 和 updating 的 timestamps (created_at, created_on, updated_at 和 updated_on)
# activerecord/lib/active_record/timestamp.rb
def initialize_dup(other) # :nodoc:
super
clear_timestamp_attributes
end
這裡一個有趣的事實是 Rails 故意選擇 override #initialize_dup 方法而不是 #initialize_copy 方法 為什麼會這樣? 讓我們調查一下
Object#initialize_copy Explained
在上面的代碼片段中 我們看到 Ruby 如何在你對方法使用 .dup 時調用 #initialize_dup 但也有 #initialize_copy 方法 為了更好地解釋這用在哪裡 讓我們看個例子:
class Animal
attr_accessor :name
def initialize_copy(*args)
puts "#initialize_copy is called"
super
end
def initialize_dup(*args)
puts "#initialize_dup is called"
super
end
end
animal = Animal.new
animal.dup
# => #initialize_dup is called
# => #initialize_copy is called
我們現在可以看到調用順序 Ruby 首先調用 #initialize_dup 然後調用 #initialize_copy 如果我們在 #initialize_dup 方法中保留對 super 的調用 我們永遠不會調用 initialize_copy 所以保留它很重要
Are There Other Methods to Copy Something?
現在我們看到了這個實現 你可能會想知道有兩個 #initialize_* 方法的用例是什麼 答案是: 還有另一種複製對象的方法 叫做 #clone 你通常使用 #clone 如果你想複製一個對象包括其 internal state
這就是 Rails 用在 ActiveRecord 上的 #dup 方法 它使用 #dup 允許你複製記錄而不帶其 "internal" state (id 和 timestamps) 並將 #clone 留給 Ruby 實現
擁有這個額外的方法也要求一個特定的 initializer 當使用 #clone 方法 對於這個 你可以 override #initialize_clone 此方法使用與 #initialize_dup 相同的 lifecycle 並將調用向上到 #initialize_copy
知道這一點 初始化方法的命名更有意義了 我們可以使用 #initialize_(dup|clone) 為特定實現 取決於你使用 #dup 或 #clone 如果我們有用於兩者的 overarching behavior 你可以將其放在 #initialize_copy 內部
Cloning an Animal
(只是例子 沒有動物受傷為了這篇部落格)
現在讓我們看看實際上如何運作的例子
class Animal
attr_accessor :name, :dna, :age
def initialize
self.dna = generate_dna
end
def initialize_copy(original_animal)
self.age = 0
super
end
def initialize_dup(original_animal)
self.dna = generate_dna
self.name = "A new name"
super
end
def initialize_clone(original_animal)
self.name = "#{original_animal.name} 2"
super
end
def generate_dna
SecureRandom.hex
end
end
bello = Animal.new
bello.name = "Bello"
bello.age = 10
bello_clone = bello.clone
bello_dup = bello.dup
bello_clone.name # => "Bello 2"
bello_clone.age # => 0
bello_dup.name # => "A new name"
bello_dup.age # => 0
讓我們分解實際上發生了什麼 我們有一個叫 Animal 的類 根據我們如何複製動物 它應該有不同的行為:
- 當我們 clone 動物 DNA 保持不變 它的名字將是原名加上 2
- 當我們 duplicate 動物 我們基於原始動物創建一個新動物 它得到自己的 DNA 和新名字
- 在所有情況下 動物都從嬰兒開始
我們實現了三個不同的 initializers 來實現這一點 #initialize_(dup|clone) 方法將總是調用向上到 #initialize_copy 從而確保 age 被設為 0
Rounding up the CLONES and Other Animals
從解釋我們需要抓癢的地方開始 我們看了複製資料庫記錄 我們從 Campaign 例子中的手動複製 到 #dup 和 #clone 然後我們從實際帶到迷人並看這在 Ruby 中如何實現 我們也玩了 #cloneing 和 #duping 動物 我們希望你享受我們的 deep dive 就像我們寫它時享受一樣