Wat?為何不相等?


iThome 網站首載:Wat?為何不相等?

相等與否看似簡單議題,但因語言中夾雜多個元素而變得複雜,不明就理的開發者面對時戰戰競競,瞭若指掌的開發者侃侃而談玩Wat(What諧音字,無意義但出乎意料的結果)。相等性的諸多議題反映出語言中的細微特性,不搞懂這些細微特性,程式中隨時就會出現Wat脫口而出的狀況。

基本型態與物件型態的相等性

有些程式語言為了效率,型態系統中有基本型態與物件型態,基本型態的相等性是比較值,物件型態的相等性通常是依方法定義比較狀態,有時則會針對物件參考(Reference)是否相等進行比較。程式普遍來說多採用==作為相等性的比較符號,然而==符號作用為何,依程式語言而有所不同。

以Java為例,基本型態使用==比較時,是在比較數值是否相同,物件型態使用==比較時,是在比較參考是否相同,如果要比較兩物件狀態的相等性,則必須定義並使用equals方法。JavaScript基本上也使用==進行相等比較,基本型態比較值,物件型態則比較物件參考,JavaScript沒有規範比較狀態相等性的方法名稱,有賴開發者自行定義。

有些語言只有物件型態,然而物件的相等性依舊有兩種情況,也就是比較物件狀態或是物件參考的相等性。以Ruby為例,所有資料都是物件,==用於比較物件狀態相等性,可自行定義==方法定義比較流程,如果要比較參考相等性,則使用equal?方法

語言特殊定義影響相等性判斷

程式語言普遍來說,基本型態的相等性是比較值,物件型態的相等性是比較物件狀態或物件參考,然而讓事情變得複雜的是語言中的特殊定義。

例如Java中被""包括的字串無論出現幾次,只要字元序列相同,只會在字串池(String pool)產生一個String實例,因此new String("ABC") == new String("ABC")會是false,但"ABC" == "ABC"卻會是true;如果Integer a = 100; Integer b = 100;,則a == b會是true,但a與b的值100改為200時,a == b卻會是false,這主要是自動裝箱(Auto-boxing)語法動了手腳,開發者若不明就理,Wat就脫口而出。追根究底,如果開發者想要的是物件狀態相等性,那在Java中應該使用equals方法而不是==

JavaScript雖可使用==進行相等比較,然而JavaScript偏向弱型別(Weak type),也就是許多情況下可自動發生型態轉換以換取語法簡潔,例如==可允許型態轉換後的比較,像是'123' == 123、'' == 0、[] + [] == ""等都會是true,然而JavaScript過於寬鬆的==常令開發者難以掌握,建議採用嚴格的===,只要兩邊型態不一,就會判斷為false,型態相同時才進一步比較參考。有些語言中還會有些特殊值,必須用特殊方法比較。例如JavaScript中的NaN絕不等於任何值,如果想判斷某變數是否為NaN,必須使用isNaN方法來判斷。

同時運用equals與hashCode的場合

有些API對相等性會有特定要求,通常要求在定義狀態相等性方法時,同時定義可傳回雜湊碼的方法。以Java為例,通常要求定義equals時同時定義hashCode方法,Ruby則是定義eql?與hash方法,JavaScript則視程式庫要求的方法名稱而定。舉例來說,如果Point類別中有兩個public的x與y成員,而且僅定義equals方法如下
if(that instanceof Point) {
    Point p = (Point) that;
    return this.x == p.x && this.y == p.y;
}
return false;


若s參考HashSet實例,呼叫s.add(new Point(1, 1))兩次,可能會收集到兩個代表座標(1, 1)的Point實例,這是因為HashSet實作會先在內部資料結構中,看看對應hashCode的雜湊桶(Hash bucket)看看是否已收集物件,如果有才進一步使用equals比較狀態相等性,如果對應的雜湊桶沒有收集物件,那麼就直接把新物件放到該雜湊桶。在新建物件時,預設的hashCode實作通常會有不同值,因此先前HashSet才會收集到兩個物件,如果Point實例代表的座標相同時不想重複收集,需依Java API文件中Object類別對於hashCode的規範進行定義。

通常定義equals引用的物件資料成員,在定義hashCode時也會用來產生雜湊碼,因此定義equasl與hashCode時,應避免使用會變動的資料成員。例如上述Point類別的hashCode若定義為傳回41 * (41 + x) + y,如果p參考至座標(1, 1)的Point實例,s參考至HashSet實例,s.add(p)後若執行p.x = 2,測試set.contains(p)時就會是false,造成明明是同一實例,HashSet中卻找不到的問題,原因在於hashCode根據x、y計算雜湊值,x既然變動,算出來的雜湊值就不同,依照先前HashSet判斷物件是否重複的規則,自然就會認定set.contains(p)結果是false。

參數化型態的相等性

在能夠參數化型態的語言中,型態參數實例化可視為新型態,例如Java中,ArrayList<String>可視為新型態,ArrayList<Integer>可視為新型態,那麼new ArrayList<String>().equals(new ArrayList<Integer>())的結果會是什麼呢?答案是true!Wat?ArrayList<String>與ArrayList<Integer>不是應該算不同型態嗎?

Java泛型採用型態抹除,泛型語法中指定的型態資訊主要用於編譯時期檢查,執行時期無法使用泛型語法中指定的型態資訊。具體來說,如果有個class Basket<T>包裹了T[],其equals方法定義為:
if(o instanceof Basket<?>) {
    Basket that = (Basket) o;
    return Arrays.deepEquals(this.things, that.things);
}
return false;

程式中Basket<?>不可改為Basket<T>(會造成編譯錯誤),因為執行時期無法使用泛型語法中對T的實際型態指定(那是用於編譯時期檢查),Arrays.deepEquals會先比較this.things與that.things的長度,長度不同傳回false,如果相同則逐一取得元素使用equals比較,只要有一個比較結果為false,結果就是false,否則就為true。在new Basket<Integer>().equals(new Basket<String>())時,內部包裹物件長度都為0,沒有任何元素可比較出false的結果,所以結果自然是true。

繼承關係下父子類別的相等性

如果有Point3D繼承先前Point類別並新增z軸資訊,有趣現象就發生了,依目前Point的equals定義,new Point(1, 1).equals(new Point3D(1, 1, 1))會是true。Wat?平面座標的點怎麼會等於立體座標的點?假設這是你要的結果,因為你考慮的是立體座標的點投射在xy平面上是否相等,那麼new Point(1, 1, 1).equals(new Point3D(1, 1))會是true或是false呢?如果是false,那就違反Java API文件Object對equals規範的對稱性(Symmetric)原則,如果是true,那麼你顯然忽略z軸資訊。如果Point3D的equals定義傳入Point實例時只比較x、y,傳入Point3D實例就比較x、y、z呢?那麼又會違反equals規範的傳遞性(Transitive)原則。

一般來說,對於不同的類別實例,會將之視為不同。上例基本上可以在instanceof判斷後,再使用this.getClass() == p.getClass()判斷,也就是直接判斷實例的類別,讓不同類別的實例視為不相等,就此例而言,使得Point只能與Point比,Point3D只能與Point3D比,直接解決不同繼承階層下equals的原則問題。

Wat?為何相等性要考量這麼多因素?這無非反映出程式語言都會有些細微特性,有的特性是好的,有的特性則要迴避,使用程式庫時也要瞭解規範,網路上偶而會出現「其實你並不懂 XXX」的文章,通常也在強調對語言或技術必須有一定程度瞭解,才不至於誤觸地雷。那麼,若a為0.1,那麼a + a + a == 0.3的結果是true還是false呢?Wat?