新手不小心會踩到的坑:我真的複製出物件了嗎?

大家好,今天要來跟大家說一個如果是非本科系的工程師不確定上課有沒有學過,如果是本科系的應該是上課有學過,菜鳥工程師可能會不小心踩到的坑,在開始說明之前先給大家看一段程式碼。

這是一段很簡單的 Ruby 程式碼,表示調整廣播電台頻道的類別,支援預設電台的功能,也就是說使用者在按下某個按鈕,就可以跳到喜歡的頻道(話說現在年輕人還知道電台嗎?@@)。

class Tuner
  def initialize(presets)
    @presets = presets

    clean
  end

  private

  def clean
    @presets.delete_if { |preset| preset[-1].to_i.even? }
  end
end

p presets = %w(90.1 106.2 88.5) # -> ["90.1", "106.2", "88.5"]
p turner = Tuner.new(presets) # -> <Tuner:0x00007fe7b08ff170 @presets=["90.1", "88.5"]>
p presets # -> ["90.1", "88.5"]

大家發現了嗎? presets 已經被我們改變了,在討論為什麼之前我們先來看看有什麼方式可以傳遞引數!

傳遞引數的方式

  1. Pass-by-reference:丟到方法裡的引數實際上只是變數的參考(reference),修改引數就會修改到原始的變數。

  2. Pass-by-value:丟到方法裡的引數是變數的值(value),修改引數不會修改到原始的變數。

Ruby 的引數是如何傳遞?

參考官網的FAQ

在 Ruby 中所有的變數和常數都會指向一個參考物件,除了直接使用會噴 NameError 例外錯誤的未初始化區域變數(沒有參考),當我們指派一個變數或初始化一個常數,表示我們設定了變數或常數指向的參考物件。

意思是指派這件事情,實際上並不會產生一個複製的新物件,例如前面範例程式碼中 presets = %w(90.1 106.2 88.5) 表示說 presets 是會指向 %w(90.1 106.2 88.5) 參考物件的變數。

Fixnum, true, nil, 和 false 是例外,他們是 immediate values,變數會保持本身的物件而不是另外指向一個參考物件,這些例外被指派的引數會產生這個類型的複製物件。

當方法被調用時,引數被指派為參數:

def addOne(n)
  n += 1
end

  a = 1
p addOne(a) # -> 2
p a         # -> 1

當傳遞的是物件的參考時,一個方法是有可能改變傳進來的可變物件(mutable object):

def downer(string)
  string.downcase!
end

p a = "HELLO" # -> "HELLO"
p downer(a)   # -> "hello"
p a           # -> "hello"

小結:

  • Ruby 傳遞引數的方法並沒有相當於其他語言的 pass-by-reference。
  • Ruby 是 pass-by-value,但是 values 是 references,Pass by reference value 可能是相對準確的說法。

回頭看看上面的 code,把 object_id 印出來觀察,presets 改變的原因是因為傳進方法的是可變物件的參考(相同 object_id),當我們直接改變物件就直接改到了原來的物件。

class Tuner
  def initialize(presets)
    @presets = presets

    clean
  end

  private

  def clean
    @presets.delete_if { |preset| preset[-1].to_i.even? }
    p @presets.object_id # -> 70140344834080
  end
end

p presets = %w(90.1 106.2 88.5) # -> ["90.1", "106.2", "88.5"]
p presets.object_id # -> 70140344834080
p turner = Tuner.new(presets) # -> <Tuner:0x00007fe7b08ff170 @presets=["90.1", "88.5"]>
p presets # -> ["90.1", "88.5"]

解決方法

上面的範例,看起來可以改成這樣:

class Tuner
  def initialize(presets)
    @presets = clean(presets)
  end

  private

  def clean(presets)
    presets.reject { |preset| preset[-1].to_i.even? }
  end
end

p presets = %w(90.1 106.2 88.5) # -> ["90.1", "106.2", "88.5"]
p turner = Tuner.new(presets) # -> <Tuner:0x00007f9adfaa2b80 @presets=["90.1", "88.5"]>
p presets # -> ["90.1", "106.2", "88.5"]

因為 reject 會以傳回一個新的陣列代替更改接收端(presets),故原本的物件並不會被我們修改到,但這並不一定是個好方法,如果我們不需要在初始化的階段就更改 presets,或是其他實體的方法才需要更改 presets,還是有可能會遇到麻煩。

所以其實我們真正想要的是,可以用複製物件來代替指向原本物件的參考。

Ruby 複製物件的方法

Ruby 有兩種複製物件的方法:dupclone,雖然兩者都會根據接收端建立新物件,但 clone 會保存原始物件的 2 項額外功能,dup 則不會。

clone 不同於 dup 的 2 項額外功能:

  1. clone 會顧及接收端的凍結狀態(frozen status),如果原始物件已經凍結,那副本也會是凍結的。

    object = Object.new
    object.freeze
    p object.frozen? # -> true
    dup_object = object.dup
    p dup_object.frozen? # -> false
    clone_object = object.clone
    p clone_object.frozen? # -> true
    
  2. 如果接收端有單例方法,clone 會複製單例類別(singleton class)。

    object = Object.new
    def object.wow; :wow end
    p object.singleton_methods # -> [:wow]
    p object.wow # -> :wow
    clone_object = object.clone
    dup_object = object.dup
    p clone_object.wow # -> :wow
    p dup_object.wow # -> undefined method `wow' for #<Object:0x00007f7effa8af40> (NoMethodError)
    

所以當我們想修改物件的時候,大部分會選則用 dup 而不是 clone,因為凍結的物件無法修改或解凍,所以 clone 可能會回傳無法修改的物件。

修改程式碼

所以我們現在將程式碼改成這樣:

class Tuner
  def initialize(presets)
    @presets = presets.dup

    clean
  end

  private

  def clean
    @presets.delete_if { |preset| preset[-1].to_i.even? }
  end
end

p presets = %w(90.1 106.2 88.5) # -> ["90.1", "106.2", "88.5"]
p turner = Tuner.new(presets) # -> <Tuner:0x00007fe7b08ff170 @presets=["90.1", "88.5"]>
p presets # -> ["90.1", "106.2", "88.5"]

恭喜!這樣就解決了我們一開始更改到原始物件的問題了!

注意

雖然我們已經解決問題了,但要留意 dupclone 只會產生淺層複製(shallow copy)的物件,意思是對於 Array 這類的集合物件,雖然複製了容器,但並未複製容器內的元素,我們可以在不影響原始物件的情況下加入或移除元素,但是如果我們試圖要修改 Array 裡的元素,就會修改到原始元素,例如:

array = ["Polar"]
p dup_array = array.dup << "Bear" # -> ["Polar","Bear"]
p dup_array.each { |element| element.sub!("lar", "oh") } # -> ["Pooh", "Bear"]
p array # -> ["Pooh"]

可以看到我們還是修改到原始物件的元素了,編寫自己的類別的時候我們可以複寫 initialize_copy 這個方法來控制複製過逞的深度,如果需要使用既有類別深層副本的物件,就必須自行處理。

另外有一個方法是可以使用 Marshal 類別來序列化,然後再對集合及其元素進行序列化還原:

array = ["Polar"]
p dup_array = Marshal.load(Marshal.dump(array)) << "Bear" # -> ["Polar","Bear"]
p dup_array.each { |element| element.sub!("lar", "oh") } # -> ["Pooh", "Bear"]
p array # -> ["Polar"]

使用 Marshal 會有相當的限制,除了時間,他要對物件序列化並還原序列化,還必須考慮記憶體的需求數量。複製的物件將會佔用它自己的記憶體空間,如此一來使得 Marshal::dump 建立序列化位元組資料流,因此假如載入大型物件會使得程式大幅增加記憶體需求。

另一個問題是 Marshal 並非可以對所有的物件作序列化,例如 IO 類別的實體、單例物件(singleton objects)等,會引發 TypeError 的例外。

總結

  • 大部分的情況我們使用 dup 就可以解決想要複製物件的問題了,但如果我們可以了解這些限制能讓我們將來在需要做深層副本的時候遠離麻煩。
  • Ruby 表現得像為不可變物件傳遞值(pass-by-value),為可變物件傳遞參考(pass-by-reference)。
  • dupclone 只會建立淺層副本。
  • 大多數物件來說,Marshal 能在需要時用來建立深層副本。

參考: Effective Ruby