程式扎記: [ Ruby Gossip ] Basic : 類別 - 物件相等性

標籤

2014年11月15日 星期六

[ Ruby Gossip ] Basic : 類別 - 物件相等性

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

物件相等性
簡單地說,equal? 測試是否為相同實例,== 測試實值是否相同,eql? 相當於先作 equal? 要作的事,再測試了是否為同一類別實例,最後進行 == 要作的事,不過要注意,eql? 預設並不呼叫 equal? 或 ==。Object上定義的相等比較還有個 ===,通常若 === 兩邊都是實例,預設實作會比較兩個變數是否參考同一實例,如果不是,會再呼叫 ==。如果左邊是類別而右且是實例,=== 比較實例是否由該類別所生成。使用 case...when...else 時,就是使用 === 作為依據(因為 === 會呼叫 ==,所以實際上可以僅定義 == 來決定case..when..else的比對依據)。

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

以下討論定義物件實質相等性時要考量的一些要素。例如,你可能如下定義了==方法:
  1. class Point  
  2.     attr_accessor :x, :y  
  3.     def initialize(x, y)  
  4.         @x = x  
  5.         @y = y  
  6.     end  
  7.   
  8.     def ==(that)  
  9.        self.x == that.x && self.y == that.y  
  10.     end  
  11. end  
如果你這麼測試:
>> require "Point"
=> true
>> p1 = Point.new(2, 1)
=> #
>> p2 = Point.new(2,1)
=> #
>> p1 == p2
=> true

看來似乎沒錯,p1 與 p2 座標都是同一點,所以實際上指的是相同座標,但是如果你這麼測試:
>> require "set"
=> true
>> set = Set.new
=> #
>> set << p1
=> #}>
>> set.include?(p2)
=> false
>> set << p2
=> #, #}>
>> 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 版本:
  1. class Point  
  2.     attr_accessor :x, :y  
  3.     def initialize(x, y)  
  4.         @x = x  
  5.         @y = y  
  6.     end  
  7.   
  8.     def ==(that)  
  9.        self.x == that.x && self.y == that.y  
  10.     end  
  11.       
  12.     def eql?(that)  
  13.         if self.equal? that  
  14.             return true  
  15.         end  
  16.         if that.is_a?(Point)  
  17.             return self == that  
  18.         end  
  19.         return false  
  20.     end  
  21.       
  22.     def hash  
  23.         41 * (41 + self.x) + self.y  
  24.     end  
  25. end  
  26.   
  27. require "set"  
  28. p1 = Point.new(21)  
  29. p2 = Point.new(21)  
  30. set = Set.new  
  31. set << p1  
  32. puts set.include?(p2) # true  
  33. puts set.size         # 1  
  34. set << p2  
  35. puts set.size         # 1  
一個重要的觀念是,定義 eql? 與 hash 時,最好 別使用狀態會改變的資料成員。你可能會想,以這個例子來說,點會移動,如果移動了就不是相同的點了,不是嗎?若 x、y 是個允許會變動的成 員,那麼就會發生這個情況:
  1. require "set"  
  2. p1 = Point.new(21)  
  3. set = Set.new  
  4. set << p1  
  5. puts set.include?(p1) # true  
  6. p1.x = 3  
  7. puts set.include?(p1) # false  
明 明是記憶體中同一個物件,但置入集合後,最後跟我說不包括 p1?這是因為,你改變了 x,算出來的 hash 也就改變了,使用 include? 嘗試比對時,會看看新算出來的雜湊桶中是不是有物件,而根本不是在置入 p1 的雜湊桶中尋找,結果就是 false了。

對 Point 應用於如 Set 的場合而言,x、y 最好是不可變動的。例如:
  1. class Point  
  2.     attr_reader :x, :y  
  3.     def initialize(x, y)  
  4.         @x = x  
  5.         @y = y  
  6.     end  
  7.   
  8.     def ==(that)  
  9.        self.x == that.x && self.y == that.y  
  10.     end  
  11.       
  12.     def eql?(that)  
  13.         if self.equal? that  
  14.             return true  
  15.         end  
  16.         if that.is_a?(Point)  
  17.             return self == that  
  18.         end  
  19.         return false  
  20.     end  
  21.       
  22.     def hash  
  23.         41 * (41 + self.x) + self.y  
  24.     end  
  25. 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 的點:
  1. require "Point2"  
  2.   
  3. class Point3D < Point  
  4.     attr_reader :z  
  5.     def initialize(x, y, z)  
  6.         super(x, y)  
  7.         @z = z  
  8.     end  
  9.       
  10.     def ==(that)  
  11.        super(that) && self.z == that.z  
  12.     end  
  13.   
  14.     def eql?(that)  
  15.         if self.equal? that  
  16.             return true  
  17.         end  
  18.         if that.is_a?(Point3D)  
  19.             return self == that  
  20.         end  
  21.         return false  
  22.     end  
  23. end  
這看來似乎沒什麼問題,3D 的點要再比較 z 座標是沒錯。不過來測試一下:
  1. p1 = Point.new(23)  
  2. p2 = Point3D.new(234)  
  3. puts p1.eql?(p2)   # true  
  4. puts p2.eql?(p1)   # false  
結 果該是 true 或 false 需要討論一下。3D 的點與 2D 的點是否相等呢?假設你考慮的是點投射在xy平面上是否相等,那 p1.eql?(p2) 為 true 就可以接受,在此假設之下,再來看 p2.eql?(p1) 為false,這違反 eql? 對稱性的對稱性合約。如果你要滿足對稱性,則 要作個修改:
  1. class Point3D < Point  
  2.     attr_reader :z  
  3.     def initialize(x, y, z)  
  4.         super(x, y)  
  5.         @z = z  
  6.     end  
  7.       
  8.     def ==(that)  
  9.        super(that) && self.z == that.z  
  10.     end  
  11.     def eql?(that)  
  12.         if self.equal? that  
  13.             return true  
  14.         end  
  15.         if that.is_a?(Point3D)  
  16.             return self == that  
  17.         end  
  18.         if that.is_a?(Point)  
  19.             return that == self  
  20.         end  
  21.         return false  
  22.     end  
  23. end  
再次運行上面的測試,就可以得到都是 true 的結果,但如果是這個:
  1. p1 = Point.new(23)  
  2. p2 = Point3D.new(234)  
  3. p3 = Point3D.new(235)  
  4. puts p2.eql?(p1)  # true  
  5. puts p1.eql?(p3)  # true  
  6. puts p2.eql?(p3)  # false  
p2 等於 p1,p1 等於 p3,但 p2 不等於 p3,這違反傳遞性合約。問題點在於,2D 的點並沒有z軸資訊,無論如何也沒辦法滿足傳遞性了。

一般來說,對於不同的類別實例,會將之視為不同,基本上你可以這麼設計:
  1. class Point  
  2.     attr_reader :x, :y  
  3.     def initialize(x, y)  
  4.         @x = x  
  5.         @y = y  
  6.     end  
  7.     def ==(that)  
  8.        self.x == that.x && self.y == that.y  
  9.     end  
  10.       
  11.     def eql?(that)  
  12.         if self.equal? that  
  13.             return true  
  14.         end  
  15.         if that.is_a?(Point)  
  16.             return clz_eql?(that) && self == that  
  17.         end  
  18.         return false  
  19.     end  
  20.   
  21.     def hash  
  22.         41 * (41 + @x) + @y  
  23.     end  
  24.       
  25.     def clz_eql?(that)  
  26.         self.class == that.class  
  27.     end  
  28. end  
  29.   
  30. class Point3D < Point  
  31.     attr_reader :z  
  32.     def initialize(x, y, z)  
  33.         super(x, y)  
  34.         @z = z  
  35.     end  
  36.       
  37.     def ==(that)  
  38.        super(that) && self.z == that.z  
  39.     end  
  40.       
  41.     def eql?(that)  
  42.         if self.equal? that  
  43.             return true  
  44.         end  
  45.         if that.is_a?(Point3D)  
  46.             return clz_eql?(that) && self == that  
  47.         end  
  48.         return false  
  49.     end  
  50. end  
  51.   
  52. p1 = Point.new(23)  
  53. p2 = Point3D.new(235)  
  54. p3 = Point3D.new(235)  
  55. puts p2.eql?(p1)  # false  
  56. puts p1.eql?(p3)  # false  
  57. puts p2.eql?(p3)  # true  
  58. puts p3.eql?(p2)  # true  
在繼承的情況下,若 eql? 兩旁運算元有一個是子類別實例,則會使用子類別的 eql? 版本進行比對。在上面的定義之下,直接將 2D 與 3D 的點視作不同的類型,這避免了2D 點與 3D 點(父、子類別)進行比較時,無法符合對稱性、傳遞性合約的問題。

不過在以下這種需求時,這樣的定義也許不符合你的需求:
  1. require "set"  
  2. p1 = Point.new(21)  
  3. p2 = Class.new(Point) {  
  4.     def to_s  
  5.         "(#{@x}, #{@y})"  
  6.     end  
  7. }.new(21)  
  8.   
  9. set = Set.new  
  10. set << p1  
  11. puts set.include? p1   # 顯示 true  
  12. puts set.include? p2   # 顯示 false,但你想顯示 true  
之後會看到,Ruby 中的類別都是 Class 實例,上面的程式片段中,p2 是繼承 Point 的匿名類別建構出來,在程式中某處又打算測試看看 set 中是否含有相同座標的點,但結果並不是顯示 true,這是因為你嚴格地在 eql? 中檢查了實例的類別名稱。

你可以將定義改為以下:
  1. class Point  
  2.     attr_reader :x, :y  
  3.     def initialize(x, y)  
  4.         @x = x  
  5.         @y = y  
  6.     end  
  7.     def ==(that)  
  8.        self.x == that.x && self.y == that.y  
  9.     end  
  10.       
  11.     def eql?(that)  
  12.         if self.equal? that  
  13.             return true  
  14.         end  
  15.         if that.is_a?(Point)  
  16.             return that.canEqual?(self) && self == that  
  17.         end  
  18.         return false  
  19.     end  
  20.       
  21.     def hash  
  22.         41 * (41 + @x) + @y  
  23.     end      
  24.       
  25.     def canEqual?(that)  
  26.         that.is_a?(Point)  
  27.     end  
  28. end  
在 eql? 中,你不僅檢查傳入的實例是否為 Point,也反過來讓傳入的實例取得 self 的型態進行測試(這是 Visitor 模式 的實現)。而在 Point3D 中:
  1. class Point3D < Point  
  2.     attr_reader :z  
  3.     def initialize(x, y, z)  
  4.         super(x, y)  
  5.         @z = z  
  6.     end  
  7.       
  8.     def ==(that)  
  9.        super(that) && self.z == that.z  
  10.     end  
  11.       
  12.     def eql?(that)  
  13.         if self.equal? that  
  14.             return true  
  15.         end  
  16.         if that.is_a?(Point3D)  
  17.             return that.canEquals(self) && self == that  
  18.         end  
  19.         return false  
  20.     end  
  21.       
  22.     def canEqual?(that)  
  23.         that.is_a? Point3D  
  24.     end  
  25. end  
如果 p1 是 Point 物件,而 p2 是 Point3D 物件,p1.eql?(p2) 時,由於傳入的實例可以取得 self 的型態進行測試,p2 反過來測試 p1 是不是 Point3D,結果不是,所以 eql? 傳回 false,利用這個方式,讓有具體名稱的子類別實例,不會與父類別實例有相等成立的可能性。如果是直接繼承 Point 類別的匿名類別物件,則直接繼承 canEqual? 方法,由於匿名類別物件還是一種 Point 實例,因此 eql? 的結果會是true。

一個測試的結果如下:
  1. require "set"  
  2. p1 = Point.new(21)  
  3. p2 = Class.new(Point) {  
  4.     def to_s  
  5.         "(#{@x}, #{@y})"  
  6.     end  
  7. }.new(21)  
  8. p3 = Point3D.new(213)  
  9.   
  10. set = Set.new  
  11. set << p1  
  12. puts set.include? p1   # true  
  13. puts set.include? p2   # true  
  14. puts set.include? p3   # false  

Supplement
stackoverflow - What's the difference between equal?, eql?, ===, and ==?
== — generic "equality"
At the Object level, == returns true only if obj and other are the same object. Typically, this method is overridden in descendant classes to provide class-specific meaning.

=== — case equality
For class Object, effectively the same as calling #==, but typically overridden by descendants to provide meaningful semantics in case statements.

eql? — Hash equality
The eql? method returns true if obj and other refer to the same hash key. This is used by Hash to test members for equality. For objects of class Object, eql? is synonymous with ==. Subclasses normally continue this tradition by aliasing eql? to their overridden == method, but there are exceptions. Numeric types, for example, perform type conversion across ==, but not across eql?, so:
  1. 1 == 1.0     #=> true  
  2. 1.eql? 1.0   #=> false  

equal? — identity comparison
Unlike ==, the equal? method should never be overridden by subclasses: it is used to determine object identity (that is, a.equal?(b) iff a is the same object as b).

Stackoverflow - Ruby: kind_of? vs. instance_of? vs. is_a?
kind_of? and is_a? are synonymous. instance_of? is different from the other two in that it only returns true if the object is an instance of that exact class, not a subclass.


沒有留言:

張貼留言

網誌存檔

關於我自己

我的相片
Where there is a will, there is a way!