0. 文章目的 本文面向有一定.NET C#基礎知識的學習者,介紹在C#中的常用的對象比較手段,並提供一些編碼上的建議。 1. 閱讀基礎 1:理解C#基本語法與基本概念(如類、方法、欄位與變數聲明,知道class引用類型與struct值類型之間存在差異) 2:理解OOP基本概念(如類、對象、方法的概 ...
0. 文章目的
本文面向有一定.NET C#基礎知識的學習者,介紹在C#中的常用的對象比較手段,並提供一些編碼上的建議。
1. 閱讀基礎
1:理解C#基本語法與基本概念(如類、方法、欄位與變數聲明,知道class引用類型與struct值類型之間存在差異)
2:理解OOP基本概念(如類、對象、方法的概念)
2. 概念:相等性與同一性
在開始前,我們需要先來明確兩個概念
相等性:或者稱為值相等性,指示兩個物品在某個比較規則下存在值上的相等。相等性只考慮值是否相等,譬如若兩個整型變數a和b的值均為1,雖然是兩個變數但它們具有相等性。
同一性:兩個物品是實質上就是同一個物品。譬如假設你給你家的貓分別在卧室和客廳拍了兩張照片,兩張照片中的貓雖然形態可能不同,所處位置不同,但它們是同一隻貓,也就是說具有同一性。
相等性的實際判定邏輯依賴於實際需求,因此一般來說我們對相等性判定的操作空間較大。但相等性的判定應當遵頊以下原則(=號表示相等性判定):
1、自反性:自己=自己
2、對稱性:A=B與B=A返回值相同
3、可傳遞性:若A=B,B=C,則A=C
4、一致性:若A不變,B不變,則A=B不變
而同一性的判定原則被明確為用於判定兩個物品是否為同一物品,在大多數的編程語言中,這一判定體現為指示兩個引用是否指向同一對象。基於這個原因,我們在同一性判定方面基本沒有什麼操作空間(當然,這是合理的)。
另外需要註意的是,具有相等性的兩個對象不一定能夠具有同一性,但在同一時間具有同一性的兩個對象一定具有相等性。
3. C#中的相等性與同一性
儘管通常我們應該只需要一個方法判定相等性,一個方法判定同一性,這樣不僅可以減少類設計者的工作量,也可以減少編碼失誤。然而有趣的是,C#卻為此這類比較判定提供了多種常用的比較方式:
- ==與!=運算符
- object類的Equals方法
- object類的Equals靜態方法
- object類的ReferenceEquals靜態方法
- IEquatable<>泛型介面的Equals方法
- object類的GetHashCode方法
- is運算符
對於C#來說,相等性和同一性的比較方式在很多時候是設計上的選擇。這裡的意思是,一種比較行為究竟被實現為比較相等性還是同一性,很多時候取決於類自身的設計。譬如,即便在通常來說,某些語言的愛好者可能傾向於認為==運算符比較的是同一性,而Equals方法比較的是相等性。但由於C#允許運算符重載,配合方法重寫,如果類的設計者願意,那麼完全可以把==操作符重載為比較相等性的實現(例如C#的string類型便重載了==讓其實行相等性比較,因此在C#中可以使用==符號來判定兩個string是否具有相等性),或者把Equals方法作為比較同一性的實現(答應我,別這麼乾)。
C#中提供了多種常用的比較判定方式,給開發者提供了相當的自由,但自由的同時也意味著如果不遵循一些共同的規範,那麼類的設計將會變得混亂。本文將會逐一對上述列出的比較方式進行介紹,並提供一些個人的使用建議。
4. 從示例入手,如何實現判定相等性和同一性
在開始之前,先對上面提到的判斷方法進行一些歸類,這裡歸為4類:
相等性比較 | 同一性比較 | 相等或同一性比較 | 特殊比較 |
object的Equals方法 | object的ReferenceEquals靜態方法 | ==運算符 | is運算符 |
object的Equals靜態方法 | !=運算符 | object的GetHashCode方法 | |
IEquatable<>泛型介面的Equals方法 |
4.1 相等性比較
4.1.1 object的Equals方法
(1)基本信息
Equals方法被定義在object類中,其方法聲明如下:
public virtual bool Equals(Object? obj);
從其方法名可以看出,Equals方法應當被定義為用於比較相等性,該方法接受一個Object類型的參數,返回相等性的比較結果。然而,儘管Equals方法在概念上被用於比較相等性,但Equals方法的預設實現方式卻是比較兩者的同一性,也就是說,預設情況下,它只會判定兩個引用是否指向同一對象,就像下圖所示
class Cat
{
public string? CatID { get; set; }
}
Cat cat1 = new Cat();
Cat cat2 = new Cat();
Console.WriteLine(cat1.Equals(cat2)); // 輸出為False
因此,如果要正確實現相等性比較,應當重寫Equals方法。
(2)基本使用
現在假設我們期望只要兩個Cat對象的CatID相等,那麼這兩個Cat對象就具有相等性。那麼顯然預設的Equals方法是無法滿足我們的需求的。所幸的是,Equals是一個被virtual修飾的虛方法,這意味著它可以簡單地被其子類重寫。並且不要忘了,由於object是所有類型的基類,因此所有的自定義類型都可以重寫該方法。就像下圖所示。
class Cat
{
public string? CatID { get; set; }
public override bool Equals(object? obj)
{
return this.CatID == ((Cat)obj).CatID;
}
}
Cat cat1 = new Cat();
Cat cat2 = new Cat();
Console.WriteLine(cat1.Equals(cat2)); // 輸出為True
當然,上面這樣的實現是缺乏穩健性的,譬如,如果傳入的是一個null參數呢?或者參數無法轉化為Cat類型呢?顯然這個時候上面的實現就會拋出異常。然而,從實踐角度出發,幾乎沒有任何理由讓一個判定相等性的方法拋出異常-兩個對象要麼相等,要麼不相等,拋出異常對於程式流程來說幾乎沒有意義。因此,Equals方法的實現可能比你想的要複雜一些,但也不會太複雜:
- 如果參數obj為null,直接返回false
- 如果參數obj和調用方具有同一性,直接返回true
- 如果參數obj的類型和目標類型不一致,直接返回false
- 其他根據業務要求需要進行的相等性比較,可能需要調用基類的Equals方法
根據上述流程,一個更好的重寫方式應該如下:
class Cat
{
public string? CatID { get; set; }
public override bool Equals(object? obj)
{
// 如果參數obj為null,直接返回false
if (obj == null)
{
return false;
}
// 如果參數obj和調用方具有同一性,直接返回true
if (ReferenceEquals(this, obj))
{
return true;
}
// 如果參數obj的類型和目標類型不一致,直接返回false
if (this.GetType() != obj.GetType())
{
return false;
}
// 只要兩個對象的CatID相等,那麼就視為具有相等性
return this.CatID == ((Cat)obj).CatID;
}
}
儘管這種實現看起來比原來複雜,但實際上前三個步驟與類型本身無關,因此是可以通用的。此外,你可能註意到上面示例中使用了ReferenceEquals來進行同一性判定,這在後面會提到。
(3)其他問題
需要特別說明,ValueType類重寫了Equals方法,其比較方式為通過比較各個欄位的值是否相等而判斷兩個ValueType是否相等,也就是說,繼承自ValueType的類型的Equals方法實際進行的就是相等性判斷。比如struct類型:
struct Point
{
public float X;
public float Y;
}
Point p1 = new Point();
Point p2 = new Point();
p1.Equals(p2); // True,p1和p2具有相等性
然而這不意味著定義為struct就不需要考慮重寫Equals方法來保證相等性判定,實際上,由於值類型往往是在有性能要求的地方使用,而ValueType的預設實現需要考慮普遍情況,但這意味著它對特定類型的實現來說實現往往也是低效的,因此依然有必要手動重寫Equals方法來避免不必要的反射操作。
(4)缺陷
實際上,在《CLR via C#》中有提到過,如果Equals能使用下麵這種預設實現:
public virtual bool Equals(object? obj)
{
if (obj == null) return false;
if (ReferenceEquals(this, obj)) return true;
if (this.GetType() != obj.GetType()) return false;
return true;
}
那麼在子類對Equals進行重寫時將會方便地多。例如幾乎所有的Equals重寫都可以按如下結構定義:
public override bool Equals(object? obj)
{
if (base.Equals(obj))
{
// 根據業務要求需要進行的相等性比較
}
return false;
}
從這個角度來說,現在的Equals的預設實現確實是有缺陷的。
4.1.2 object的Equals靜態方法
(1)基本信息
在object基類中,除了有用於實例的Equals方法外,還有一個靜態版的Equals方法,其方法聲明如下:
public static bool Equals(object? objA, object? objB);
和實例版的Equals方法一樣,Equals靜態方法也是用來進行相等性判定。該方法實際依賴於實例版的Equals方法的實現,但優點在於由於不需要實例調用,因此可以避免不必要的null異常。實際上,Equals靜態方法的實現類似如下:
public static bool Equals(object? objA, object? objB)
{
if (objA == objB)
{
return true;
}
if (objA == null || objB == null)
{
return false;
}
return objA.Equals(objB);
}
顯然,該方法可以有效避免待比較對象為null時引發的異常,同時該方法最終的判定依賴於實例版Equals的實現
(2)基本使用
在實例Equals方法中,若調用成功,則調用方一定不為null,因此我們不需要在實例Equals方法中考慮調用方為null的情況。但在Equals方法外,我們有時確實需要考慮調用方為null的情況,一種常見的做法就是在調用前對調用方進行null檢查。例如如下寫法:
if (a != null && a.Equals(b))
{
// do something
}
但使用靜態Equals方法則可以減少不必要的判空操作來簡化編碼,如下:
if (Equals(a, b))
{
// do something
}
Equals靜態方法的適用場合較少,通常用於需要對調用方判空時簡化編碼。另外需要說明的時,若傳給Equals靜態方法的兩個參數均為null,Equals也會返回true。
4.1.3 IEquatable<>泛型介面的Equals方法
(1)基本信息
IEquatable<>泛型介面用於表明實現類型可以進行類型特化的相等性比較,該介面的定義非常簡單,只約定了一個接受一個類型為其泛型參數的Equals方法。其介面定義如下:
public interface IEquatable<T>
{
bool Equals(T? other);
}
相對於object的Equals方法而言,該介面更明確地指出其實現類型可以使用介面的Equals方法進行相等性比較,同時不同於object的Equals使用了object類型的參數,IEquatable<>介面的Equals方法的參數類型為特化類型,因此可以減少類型轉換,從而獲得更好的性能。
(2)基本使用
IEquatable<>介面的Equals方法的表現應該類似於object的Equals方法,但現在不再需要考慮與類型相關的問題,因此可以按如下方式書寫。同樣的,這裡以在object的Equals中使用的Cat類為例:
class Cat : IEquatable<Cat>
{
public string? CatID { get; set; }
public bool Equals(Cat? other)
{
// 如果參數other為null,直接返回false
if (other == null)
{
return false;
}
// 如果參數other和調用方具有同一性,直接返回true
if (ReferenceEquals(this, other))
{
return true;
}
// 只要兩個對象的CatID相等,那麼就視為具有相等性
return this.CatID == other.CatID;
}
}
(3)建議
重寫object的Equals方法與實現IEquatable<>介面應當同時進行,這一工作並不難,實現一方後另一方可以通過簡單調用來實現,但是可以創造出更泛用的類型。一個可能的示例如下:
class Cat : IEquatable<Cat>
{
public string? CatID { get; set; }
public bool Equals(Cat? other)
{
if (other == null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return this.CatID == other.CatID;
}
public override bool Equals(object? obj)
{
return Equals(obj as Cat);
}
}
4.2 同一性比較
4.2.1 object的ReferenceEquals靜態方法
(1)基本信息
儘管Equals方法的預設實現為進行同一性比較,但是由於Equals方法可被重寫且從語義上來說應當用於相等性比較,因此不應當依賴Equals方法進行同一性比較(同樣的還有==運算符)。要進行可靠的同一性比較應該使用其他方式,所幸的是,C#中常用進行同一性比較的方式只有一種,即ReferenceEquals靜態方法(儘管從事實上來說,其實現依賴於==運算符),該方法原型如下:
public static bool ReferenceEquals(object? objA, object? objB);
若objA與objB引用同一對象,則返回true。
(2)基本使用
該方法使用非常簡單,只需要將需要進行同一性判定的兩個參數傳入即可,示例如下:
object a = new object();
object b = new object();
Console.WriteLine(ReferenceEquals(a, b)); // False
a = b; // 現在讓a和b指向同一對象
Console.WriteLine(ReferenceEquals(a, b)); // True
(3)原理
實際上,ReferenceEquals方法的實現非常簡單,其實現類似如下:
public static bool ReferenceEquals(object? objA, object? objB)
{
return objA == objB;
}
該方法只是簡單地返回對參數使用==運算符的結果,之所以有效,是由於該方法的兩個參數類型均為object,而object對==運算符的預設實現就是進行同一性比較。基於這個原理,也可以像下麵這樣進行同一性判定:
if ((object)a == (object)b)
{
// do something
}
當然不推薦這樣做,因為使用ReferenceEquals的語義顯然更清晰。
4.3 相等或同一性比較
4.3.1 ==運算符
(1) 基本信息
==運算符是常用的二元邏輯運算符之一,但相對於Equals方法和ReferenceEquals靜態方法這兩者有清晰的語義而言,==運算符無法簡單明確其到底是進行相等性比較還是同一性比較。雖然從實際來說,很多時候我們更傾向於用將其用於相等性比較,譬如:
1 == 1; // True
2 == 3; // False
"Cat" == "Cat" // True
實際上,對於int,double之類的數值類基元類型,==運算符的表現為相等性判定;對於class引用類型,表現為同一性判定;對於struct值類型,則依賴於定義(實際上,只能是相等性,只是如何比較相等性而已)。
不僅如此,由於C#允許進行運算符重載,因此==運算符的實際行為是可以修改,譬如下麵的定義修改了==運算符用於Cat類比較時的表現,讓其進行相等性比較(比較CatID的值)而非預設的同一性比較:
public static bool operator ==(Cat left, Cat right)
{
return left.CatID == right.CatID;
}
基於上述理由,依賴==進行相等性或者同一性判定不是完全可靠的。但它是可控的,也就是只要能確定定義,那麼==運算符的判定結果就是可預測的。==運算符可以讓程式有更良好的可讀性,規範地使用它是值得的。
(2)基本使用
前面說過,==運算符的實際表現依賴於類型性質和運算符重載,實際上它的表現如下:
- 對於int、double等數值類基元類型:相等性判定
- 對於string基元類型:相等性判定(string是被特殊對待的引用類型)
- 對於object基元類型:同一性判定
- 對於自定義class:同一性判定
- 對於自定義struct:依賴於定義
由於基元類型的定義不可修改,故可以認為==運算符對其相等性與同一性的判定是可靠穩定的,這裡不做討論。下麵主要說明自定義class與自定義struct類型中的==運算符。
1. 在自定義的class中
對於自定的class來說,==運算符預設表現為同一性判定,即表現如下:
class Cat
{
public string? CatID { get; set; }
}
Cat cat1 = new Cat();
Cat cat2 = new Cat();
cat1 == cat2; // False,因為==運算符預設比較同一性
cat1 = cat2; // 現在讓cat1和cat2指向同一對象
Cat2 == cat2; // True
只要在本類型定義中沒有重載==運算符,那麼該類型使用==的比較結果都將有以上表現。但有時候我們可能希望==運算符可以提供相等性判定,那麼可以通過對其進行運算符重載來修改比較行為,例如我們希望只要兩個Cat對象的CatID相同就具有相等性,則可以:
class Cat
{
public string? CatID { get; set; }
public bool operator ==(Cat left, Cat right)
{
return left.CatID == right.CatID;
}
}
Cat cat1 = new Cat();
Cat cat2 = new Cat();
Cat2 == cat2; // True,此時==運算符的結果只依賴於比較CatID的值了
2. 在自定義struct中
若沒有手動對==進行運算符重載,則編譯器會顯示無法找到運算符定義,struct將無法使用==運算符,例如下麵的代碼會報錯:
struct Cat
{
public string? CatID { get; set; }
}
Cat cat1 = new Cat();
Cat cat2 = new Cat();
Cat2 == cat2; // 報錯,沒有定義==運算符
因此若希望Cat類型可以使用==運算符進行比較操作,請重載==運算符:
struct Cat
{
public string? CatID { get; set; }
public bool operator ==(Cat left, Cat right)
{
return left.CatID == right.CatID;
}
}
(4):建議
個人建議,除非有有足夠說服力的理由(一個例子便是string類型),否則如果要對class類型進行相等性判定,應首選使用Equals方法(包括IEquatable<>的Equals方法)。不應當重載class類型的==運算符,應該讓==保持預設行為,即同一性判定。
而對於值類型,應當重載==運算符,同時實現IEquatable<>介面,以為其提供更好的相等性判定支持。(同樣建議重寫object的Equals方法,但請儘可能避免手動對值類型使用object的Equals方法進行相等性判定,否則會產生額外的裝箱拆箱成本。重寫它的主要目的,是儘可能避開其基類ValueType中重寫的Equals中的反射操作。)
4.3.2 !=運算符
(1)基本信息
!=是==運算符的逆運算,故可以參考==運算符一欄進行理解,此處不再贅述。這裡只說一點,就是==運算符必須和!=運算符成對重載,即重載了兩者之一就必須同時重載另一方。所幸的是,通常只要重載了==運算符,就可以方便地重載!=運算符了,如下:
class Cat
{
public string? CatID { get; set; }
public bool operator ==(Cat left, Cat right)
{
return left.CatID == right.CatID;
}
public bool operator !=(Cat left, Cat right)
{
return !(left == right);
}
}
4.4 特殊比較
4.4.1 is運算符 - 判空
(1)基本信息
is運算符最早的作用是用於類型判定,即判定類型是否為目標類型或存在繼承關係,如下:
class A {}
class B : A {}
object a = new A();
object b = new B();
a is A; // True
b is A; // True
但現在,is運算符也可用於判空處理,如下:
if (a is null) { ... } // 類似於 a == null
你可能會好奇為何不直接使用==進行判空,就像以下:
if (a == null) { ... }
這是因為,你無法在不確定類型定義的情況下說出上述代碼的判空結果。這是由於==運算符可以被重載。例如現在考慮如下代碼:
class Cat
{
public static bool operator ==(Cat? left, Cat? right)
{
return false;
}
}
Cat? cat = null;
if (cat == null)
{
// do something
}
稍加思索你就能意識到,由於==被重載,上式中a == null的值永遠為false。而如果將上式的==修改為is運算符則不會出現此問題。
(2)原理
is是語法糖,上述中a is null的實際行為等效於:
(object)a == null
此外,除了可以使用is進行判空,也可以使用is not進行非空判定
if (a is not null) { } // 等效於 (object)a != null
4.2.1 GetHashCode - 不相等比較
(1)基本信息
GetHashCode是object類中定義的虛方法,其方法聲明如下:
public virtual int GetHashCode();
該方法實際作用在於獲取對象的散列值。
(2)基本使用
儘管GetHashCode方法是用與獲取對象散列值而非進行相等性或同一性的判斷,但請考慮一般散列值的要求:
- 若兩個對象具有相等性,則其散列值有應當相同
- 反之,散列值相同的兩個對象不一定具有相等性
基於上述得出:如果可以兩個對象的散列值不同,則至少可以確定他們不具有相等性。因此在某些時候可以通過判定散列值是否不同來快速判定兩個對象是否具有相等性,例如:
if (a.GetHashCode() != b.GetHashCode())
{
// a 和 b 不具有相等性
}
當然,這一判斷方法的可靠性取決於散列函數的實現,僅在可以確定後果且有必要的情況下才推薦使用。
5. 總結
由於C#提供了多種比較判定方法,因此要正確實現可靠的比較判斷需要付出一定的努力。這裡簡單結合編碼規範和實踐來給出一些總結性的建議。
1. 若要進行相等性比較,請使用Equals方法(與其靜態版本)
a.Equals(b);
Equals(a, b);
2. 若要進行同一性比較,請使用ReferenceEquals靜態方法
ReferenceEquals(a, b);
3. 若要進行判空,請使用is運算符
a is null; // 等效(object)a == null
a is not null; // 等效(object)a != null
4. 若可以確定==與!=運算符的行為,則可以加以使用以增強可讀性
1 == 1;
"Cat" == "Cat";
5. 如果重寫了object的Equals方法,則應當同時重寫GetHashCode方法
class Cat
{
public override bool Equals(object? obj) { ... }
public override int GetHashCode() { ... }
}
6. 如果重載了==運算符,則應當重載!=運算符,並重寫Equals方法和GetHashCode方法
class Cat
{
public static bool operator ==(Cat left, Cat right) { ... }
public static bool operator !=(Cat left, Cat right) { ... }
public override bool Equals(object? obj) { ... }
public override int GetHashCode() { ... }
}
7. 如果類型可以進行相等性比較,則重寫Equals方法的同時,實現IEquatable<>介面
class Cat : IEquatable<Cat>
{
public bool Equals(Cat? other) { ... }
public override bool Equals(object? obj) { ... }
public override int GetHashCode() { ... }
}
8. 不要對class類型重載==與!=運算符,讓其保持預設行為進行同一性判斷
9. 對於struct類型,確保重載==與!=運算符並實現IEquatable<>介面。換句話說,struct應該完備地實現相等性比較
struct Cat : IEquatable<Cat>
{
public static bool operator ==(Cat left, Cat right) { ... }
public static bool operator !=(Cat left, Cat right) { ... }
public bool Equals(Cat? other) { ... }
public override bool Equals(object? obj) { ... }
public override int GetHashCode() { ... }
}
(如果你認為你的struct類型不需要進行相等性比較,請考慮是否真的需要使用struct類型)