物件相等性


變數 中談過,在Ruby中,==常用來比較兩個物件的實質內容是否相同。如果想知道兩個變數是否參考同一物件,除了使用object_id得知之外,通常還可以使用equal?方法。相等比較還可以使用eql?方法,這個方法通常會檢查變數是否參考同一實例,若否則比較物件是否為同一類別的實例,若是則比較實值是否相同。

簡單地說,equal?測試是否為相同實例,==測試實值是否相同,eql?相當於先作equal?要作的事,再測試了是否為同一類別實例,最後進行==要作的事,不過要注意,eql?預設並不呼叫equal?或==。

Object上定義的相等比較還有個===,通常若===兩邊都是實例,預設實作會比較兩個變數是否參考同一實例,如果不是,會再呼叫==。如果左邊是類別而右且是實例,===比較實例是否由該類別所生成。使用case...when...else 時,就是使用===作為依據(因為===會呼叫==,所以實際上可以僅定義==來決定case..when..else的比對依據)。

實際上,如果你定義類別時,沒有重新定義==equal?、eql?、===方法,則定義預設繼承自Object,若要定義類別時需要定義相等性,必須依需求自行定義==equal?、eql?、===方法,而不是依賴Object的預設實作。

以下討論定義物件實質相等性時要考量的一些要素。例如,你可能如下定義了==方法:
class Point
attr_accessor :x, :y
def initialize(x, y)
@x = x
@y = y
end

def ==(that)
self.x == that.x && self.y == that.y
end
end

如果你這麼測試:
p1 = Point.new(2, 1)
p2 = Point.new(2, 1)
puts p1 == p2        # true

看來似乎沒錯,p1與p2座標都是同一點,所以實際上指的是相同座標,但是如果你這麼測試:
require "set"
p1 = Point.new(2, 1)
p2 = Point.new(2, 1)
set = Set.new
set << p1
puts set.include?(p2) # false
puts set.size         # 1
set << p2
puts set.size         # 2


Set是集合,相同的資料不會重複收集,不過上例中,Set顯然不認為p1與p2是相同資料,無論是從include?或最後集合的長度,都看得出來這樣的結果。

事實上在許多場合,例如將物件加入Set時,會同時利用eql?hash來判斷是否加入的是(實質上)相同的物件:
  • 在同一個應用程式執行期間,對同一物件呼叫hash方法,必須回傳相同的整數結果。
  • 如果兩個物件使用eql?測試結果為相等, 則這兩個物件呼叫hash時,必須獲得相同的整數結果。
  • 如果兩個物件使用eql?測試結果為不相等, 則這兩個物件呼叫hash時,可以獲得不同的整數結果。

以Set為例,會先使用hash得出該將物件放至哪個雜湊桶(Hash buckets)中,如果雜 湊桶有物件,再進一步使用eql?確定相等性,從而確定Set中不會有重複的物件。以下是定義了eql?與hash的Point版本:
# encoding: Big5
class Point
attr_accessor :x, :y
def initialize(x, y)
@x = x
@y = y
end

def ==(that)
self.x == that.x && self.y == that.y
end

def eql?(that)
if self.equal? that
return true
end
if that.is_a?(Point)
return self == that
end
return false
end

def hash
41 * (41 + self.x) + self.y
end
end

require "set"
p1 = Point.new(2, 1)
p2 = Point.new(2, 1)
set = Set.new
set << p1
puts set.include?(p2) # true
puts set.size # 1
set << p2
puts set.size # 1

一個重要的觀念是,定義eql?與hash時,最好 別使用狀態會改變的資料成員。你可能會想,以這個例子來說,點會移動,如果移動了就不是相同的點了,不是嗎?若x、y是個允許會變動的成 員,那麼就會發生這個情況:
require "set"
p1 = Point.new(2, 1)
set = Set.new
set << p1
puts set.include?(p1) # true
p1.x = 3
puts set.include?(p1) # false

明 明是記憶體中同一個物件,但置入集合後,最後跟我說不包括p1?這是因為,你改變了x,算出來的hash也就改變了,使用include?嘗試比對時,會看看新算出來的雜湊桶中是不是有物件,而根本不是在置入p1的雜湊桶中尋找,結果就是false了。

對Point應用於如Set的場合而言,x、y最好是不可變動的。例如:
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end

def ==(that)
self.x == that.x && self.y == that.y
end

def eql?(that)
if self.equal? that
return true
end
if that.is_a?(Point)
return self == that
end
return false
end

def hash
41 * (41 + self.x) + self.y
end
end

暫且忽略hash。來看看在實作eql?時要遵守的約定(取自java.lang.Object的 equals() 說明 ):
  • 反身性 (Reflexive):x.eql?(x)的結果要是true。
  • 對稱性 (Symmetric):x.eql?(y)與y.eql?(x)的結果必須相同。
  • 傳遞性 (Transitive):x.eql?(y)、y.eql?(z)的結果都是true,則x.eql?(z)的結果也必須是true。
  • 一 致性(Consistent):同一個執行期間,對x.eql?(y)的多次呼叫,結果必須相同。
  • 對 任何非nil的x,x.eql?(nil)必須傳回false。

目前定義的Point,其eql?方法滿足以上幾個約定(你可以自行寫程式測試)。現在考慮繼承的情況,你要定義3D的點:
class Point3D < Point
attr_reader :z
def initialize(x, y, z)
super(x, y)
@z = z
end

def ==(that)
super(that) && self.z == that.z
end

def eql?(that)
if self.equal? that
return true
end
if that.is_a?(Point3D)
return self == that
end
return false
end
end

這看來似乎沒什麼問題,3D的點要再比較z座標是沒錯。不過來測試一下:
p1 = Point.new(2, 3)
p2 = Point3D.new(2, 3, 4)
puts p1.eql?(p2)   # true
puts p2.eql?(p1)   # false

結 果該是true或false需要討論一下。3D的點與2D的點是否相等呢?假設你考慮的是點投射在xy平面上是否相等,那p1.eql?(p2)為true就可以接受,在此假設之下,再來看p2.eql?(p1)為false,這違反eql?對稱性的對稱性合約。如果你要滿足對稱性,則 要作個修改:
class Point3D < Point
attr_reader :z
def initialize(x, y, z)
super(x, y)
@z = z
end

def ==(that)
super(that) && self.z == that.z
end
def eql?(that)
if self.equal? that
return true
end
if that.is_a?(Point3D)
return self == that
end
if that.is_a?(Point)
return that == self
end
return false
end
end

再次運行上面的測試,就可以得到都是true的結果,但如果是這個:
p1 = Point.new(2, 3)
p2 = Point3D.new(2, 3, 4)
p3 = Point3D.new(2, 3, 5)
puts p2.eql?(p1)  # true
puts p1.eql?(p3)  # true
puts p2.eql?(p3)  # false

p2等於p1,p1等於p3,但p2不等於p3,這違反傳遞性合約。問題點在於,2D的點並沒有z軸資訊,無論如何也沒辦法滿足傳遞性了。

一般來說,對於不同的類別實例,會將之視為不同,基本上你可以這麼設計:
# encoding: Big5
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def ==(that)
self.x == that.x && self.y == that.y
end

def eql?(that)
if self.equal? that
return true
end
if that.is_a?(Point)
return clz_eql?(that) && self == that
end
return false
end

def hash
41 * (41 + @x) + @y
end

def clz_eql?(that)
self.class == that.class
end
end

class Point3D < Point
attr_reader :z
def initialize(x, y, z)
super(x, y)
@z = z
end

def ==(that)
super(that) && self.z == that.z
end

def eql?(that)
if self.equal? that
return true
end
if that.is_a?(Point3D)
return clz_eql?(that) && self == that
end
return false
end
end

p1 = Point.new(2, 3)
p2 = Point3D.new(2, 3, 5)
p3 = Point3D.new(2, 3, 5)
puts p2.eql?(p1) # false
puts p1.eql?(p3) # false
puts p2.eql?(p3) # true
puts p3.eql?(p2) # true

在繼承的情況下,若eql?兩旁運算元有一個是子類別實例,則會使用子類別的eql?版本進行比對。在上面的定義之下,直接將2D與3D的點視作不同的類型,這避免了2D點與3D點(父、子類別)進行比較時,無法符合對稱性、傳遞性合約的問題。

不過在以下這種需求時,這樣的定義也許不符合你的需求:
require "set"
p1 = Point.new(2, 1)
p2 = Class.new(Point) {
    def to_s
        "(#{@x}, #{@y})"
    end
}.new(2, 1)

set = Set.new
set << p1
puts set.include? p1   # 顯示 true
puts set.include? p2   # 顯示 false,但你想顯示 true

之後會看到,Ruby中的類別都是Class實例,上面的程式片段中,p2是繼承Point的匿名類別建構出來,在程式中某處又打算測試看看set中是否含有相同座標的點,但結果並不是顯示true,這是因為你嚴格地在eql?中檢查了實例的類別名稱。

你可以將定義改為以下:
# encoding: Big5
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def ==(that)
self.x == that.x && self.y == that.y
end

def eql?(that)
if self.equal? that
return true
end
if that.is_a?(Point)
return that.canEqual?(self) && self == that
end
return false
end

def hash
41 * (41 + @x) + @y
end

def canEqual?(that)
that.is_a?(Point)
end
end

在eql?中,你不僅檢查傳入的實例是否為Point,也反過來讓傳入的實例取得self的型態進行測試(這是 Visitor 模式 的實現)。而在 Point3D 中:
class Point3D < Point
attr_reader :z
def initialize(x, y, z)
super(x, y)
@z = z
end

def ==(that)
super(that) && self.z == that.z
end

def eql?(that)
if self.equal? that
return true
end
if that.is_a?(Point3D)
return that.canEquals(self) && self == that
end
return false
end

def canEqual?(that)
that.is_a? Point3D
end
end

如果p1是Point物件,而p2是Point3D物件,p1.eql?(p2)時,由於傳入的實例可以取得self的型態進行測試,p2反過來測試p1是不是Point3D,結果不是,所以eql?傳回false,利用這個方式,讓有具體名稱的子類別實例,不會與父類別實例有相等成立的可能性。如果是直接繼承Point類別的匿名類別物件,則直接繼承canEqual?方法,由於匿名類別物件還是一種Point實例,因此eql?的結果會是true。

一個測試的結果如下:
require "set"
p1 = Point.new(2, 1)
p2 = Class.new(Point) {
    def to_s
        "(#{@x}, #{@y})"
    end
}.new(2, 1)
p3 = Point3D.new(2, 1, 3)

set = Set.new
set << p1
puts set.include? p1   # true
puts set.include? p2   # true
puts set.include? p3   # false