相等與否看似簡單議題,但因語言中夾雜多個元素而變得複雜,不明就裡的開發者面對時戰戰競競,瞭若指掌的開發者侃侃而談玩Wat(What諧音字,無意義但出乎意料的結果。Wat是國外有講者用來搞爆笑的哏,在JSDC Taiwan 2012亦有講者用過,請參考http://goo.gl/MnmxG)。

相等性的諸多議題反映出語言中的細微特性,不搞懂這些細微特性,就會出現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方法。

有些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視為新型態,ArrayList視為新型態,那麼new ArrayList().equals(new ArrayList())的結果是什麼?答案是true!Wat?ArrayList與ArrayList,不是應該算不同型態?

Java泛型採用型態抹除,泛型語法中指定的型態資訊,主要用於編譯時期檢查,執行時期無法使用泛型語法中指定的型態資訊。具體來說,如果有個class Basket包裹了T[],其equals方法定義為:

if(o instanceof Basket>) {
Basket that = (Basket) o;
return Arrays.deepEquals(this.things, that.things);
} return false;

程式中Basket>不可改為Basket(會造成編譯錯誤),因為執行時期無法使用泛型語法中對T的實際型態指定(那是用於編譯時期檢查),Arrays.deepEquals會先比較this.things與that.things的長度,長度不同傳回false,如果相同,則逐一取得元素使用equals比較,只要有一個比較結果為false,結果就是false,否則就為true。

在new Basket().equals(new Basket())時,內部包裹物件長度都為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?

作者簡介


Advertisement

更多 iThome相關內容