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型別)
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/487789.html
標籤:C#
