C#中的等值判斷1

  • 2019 年 10 月 16 日
  • 筆記

簡介

最近正在看《C# in a nutshell》這本書,可以看到雖然 .NET 框架有一些不足和缺憾,但是整體上來說其設計還是比較優秀的。這裡,本文打算從C#語言對兩個對象之間的比較進行相關闡述。

值類型和引用類型的相等比較

在C#中,我們知道對於不同的數據類型,其比較的方式不同。最典型的就是,值類型比較的是二者的值是否相等,而引用類型則比較的是二者是否引用了同一個對象。下面這個例子就可以看到其二者的區別。

int v1 = 3, v2 = 3;  object r1 = v1;  object r2 = v1;  object r3 = r1;  Console.WriteLine($"v1 is equal to v2: {v1 == v2}");    // true  Console.WriteLine($"r1 is equal to r2: {r1 == r2}");    // false  Console.WriteLine($"r1 is equal to r3: {r1 == r3}");    // true

在這個例子中,類型 int 屬於值類型,其變數 v1v2 均為3。從輸出的結果可以看到,二者確實是相等的。但是對於 object 這種引用類型來說,即使是同一個 int 型數據轉換而來(由int型數據裝箱),其二者也不是同一個引用,因而並不相等(即第6行)。但是對於 r3 來說,均是引用 r1 所指的對象,因而 r3r1 相等。

雖然說值類型比較按照值比較,引用類型按照是否引用同一個數據比較。然而,也有一些特別的情況。典型的例子就是字元串 string 以及 System.Uri 。這兩類數據類型雖然是引用類型(本質上都是類),但其在相等判斷上所表現的結果卻和值類型類似。

string s1 = "test";  string s2 = "test";  Uri u1 = new Uri("https://www.bing.com");  Uri u2 = new Uri("https://www.bing.com");  Console.WriteLine($"s1 is equal to s2: {s1 == s2}");    // true  Console.WriteLine($"u1 is equal to u2: {u1 == u2}");    // true  

可以看到,這兩個數據類型打破了之前給出的規則。雖然說 stringSystem.Uri 兩個類的比較結果相似,但二者具體實現的行為並不相同。那麼不同的數據類型比較具體是怎麼樣的流程,以及如何自定義比較方式將會在後續部分進行討論。但我們首先來看下在C#中相等邏輯是如何進行處理的。

和相等比較相關的函數

在C#的語言體系中,可以知道類 Object 是整個所有數據類型的根類。從 .NET Core 3.0 中的 Object 可以看到,與等值判斷相關的函數有4個,其中2個為類成員方法,2個為類靜態成員方法,如下所示:

public virtual bool Equals(object? obj);  public virtual int GetHashCode();  public static bool ReferenceEquals(object? objA, object? objB);  public static bool Equals(object? objA, object? objB);

可以注意到一點,這裡和其他資料裡面並不完全一樣,唯一一點區別就是傳入的參數類型是 object? 而不是 object。這主要是C#在8.0版本中引入的可空引用類型。這裡可空引用類型並不是本文的重點,這裡完全可以當作是 object 來處理。

這裡我們對這4個函數一一介紹:

  1. 類成員方法 Equals 。該方法的作用是將當前使用的對象和傳入的對象進行比較,如果一致則認為是相等。該方法被設置為virtual,即在子類中可以重寫該方法。
  2. 類成員方法 GetHashCode 。該方法主要用在哈希處理中,比如哈希表和字典類中。對於這個函數,它有一個基本的要求,如果兩個對象認定為相等,則它們會返回相同的哈希值。對於不同的對象,該函數沒有要求一定要返回不同的哈希值,但是希望儘可能地返回不同地哈希值,以便在哈希處理時能夠區分不同的對象數據。和上面方法一樣,因 virtual 關鍵字修飾,同樣可以在子類中被重寫。
  3. 靜態成員方法 ReferenceEquals 。該方法主要用來判斷兩個引用是否指向同一個對象。在 源碼 中也可以看到,其本質就一句話:return objA == objB;。由於該方法是靜態方法,因此無法重寫。
  4. 靜態成員方法 Equals。對於該方法,從源碼中也可以看到,首先判斷兩個引用是否相同,在不相同的情況下,再利用對象方法 Equals 判斷二者是否相等。同樣的,由於該方法是靜態方法,也是無法重寫的。

stringSystem.Uri 的等值比較

好了,我們回到原先的問題上來,為什麼stringSystem.Uri 表現行為和其他引用類型不一樣,反而和值類型類似。其實,嚴格上來說,stringSystem.Uri 的對象比較雖然表現上類似於值類型,但是二者內部的細節並不一樣。

對於 string 來說,大部分情況下,在一個程式副本當中,一個字元串只會被保存一次,無論新建多少個字元串變數,只要其值相同,那麼均會引用到同一個記憶體地址上。所以對於字元串的比較,其依舊是比較引用,只不過值相同的大多是引用到同一個對象上。

System.Uri 不同,對於這樣的類對象來說,新建了多少個對象就會在堆上開闢相對應數目個的記憶體空間並存放數據。然而在比較時,比較方法採用的是先比較引用再比較值。即當二者並不是引用到同一個對象時再比較其值是否相等(源碼)。

string s1 = "test";  string s2 = "test";  Uri u1 = new Uri("https://www.bing.com");  Uri u2 = new Uri("https://www.bing.com");  Console.WriteLine($"s1 is equal to s2 by the reference: {Object.ReferenceEquals(s1, s2)}"); // true  Console.WriteLine($"s1 is equal to s2: {s1 == s2}");    // true  Console.WriteLine($"u1 is equal to u2 by the reference: {Object.ReferenceEquals(u1, u2)}"); // false  Console.WriteLine($"u1 is equal to u2: {u1 == u2}");    // true

以上例子可以看出,兩個字元串變數均指向了同一個數據對象(ReferenceEquals 方法是判斷兩個引用是否引用同一個對象,這裡可以看到返回值為 true)。而對於 System.Uri 來說,兩個變數並沒有指向同一個對象,然而後續相等判斷時二者依舊相等,這時候可以看出此時根據二者的值來判斷是否相等。

泛型介面 IEquatable<T>

從以上的例子中可以看到,C#中對兩個對象是否相等基本上通過 Equals 方法來判斷。然而,Equals 方法也並不是萬能的,這一點尤其體現在值類型當中。

由於 Equals 方法要求傳入的參數類型是 object。如果將該方法應用到值類型上,會導致將值類型強制轉換到 object 類型上,也就是會裝箱(boxing)一次。裝箱和拆箱一般比較耗時,容易降低效率。此外,object類型意味著該類對象可以和任意其他類對象進行相等判斷,但是一般而言,我們判斷兩個對象是否相等的前提肯定都是同一個類的對象。

C#所採用的解決辦法是使用泛型介面 IEquatable<T> 來解決。IEquatable<T> 主要包含兩個方法,如下所示:

public interface IEquatable<T>  {      bool Equals(T other);  }

Object.Equals(object? obj) 相比,其內部的函數為泛型方法,如果一個類或者結構體等數據實現了該介面,那麼當調用 Equals 方法時,根據類型最適應的原則,那麼會首先調用 IEquatable<T> 內的 Equals(T other) 方法。這樣就避免了值類型的裝箱操作。

自定義比較方法

在有時候,為了更好模擬現實中的場景,我們需要自定義兩個個體之間的比較。為了實現這樣的比較方法,通常有三步需要完成:

  1. 重寫 Equals(object obj)GetHashCode() 方法;
  2. 重載操作符 ==!=
  3. 實現 IEquatable<T> 方法;

對於第一點來說,這兩個函數是必須要重寫的。對於 Equals(object obj) 的實現的話,如果實現了泛型介面內的方法,可以考慮這裡直接調用該方法即可。GetHashCode() 用於盡可能區分不同對象,所以如果兩個對象相等的話,其哈希值也應該相等,這樣在哈希表以及字典類中會有比較好的性能。

對於第二點和第三點來說,並不是必須的,但是一般地,為了更好地使用,這兩點最好需要進行重載。

可以看到,這三點均涉及到比較的邏輯。一般而言,我們傾向於把比較的核心邏輯放在泛型介面中,對於其他方法,通過調用泛型介面內的方法即可。

舉例

這裡,我們舉一個小例子。設想這樣一個場景,目前機器學習越來越火熱,而談及機器學習離不開矩陣運算。對於矩陣,我們可以使用二維數組來保存。在數學領域中,我們判斷兩個矩陣是否相等,是判斷兩個矩陣內的每個元素是否相等,也就是值類型的判斷方式。而在C#中,由於二維數組是引用類型,直接使用相等判斷無法達到這一目的。因此,我們需要修改其判斷方式。

   public class Matrix : IEquatable<Matrix>      {          private double[,] matrix;            public Matrix(double[,] m)          {              matrix = m;          }            public bool Equals([AllowNull] Matrix other)          {              if (Object.ReferenceEquals(other, null))                  return false;              if (matrix == other.matrix)                  return true;              if (matrix.GetLength(0) != other.matrix.GetLength(0) ||                  matrix.GetLength(1) != other.matrix.GetLength(1))                  return false;              for (int row = 0; row < matrix.GetLength(0); row++)                  for (int col = 0; col < matrix.GetLength(1); col++)                      if (matrix[row,col] != other.matrix[row,col])                          return false;              return true;          }            public override bool Equals(object obj)          {              if (!(obj is Matrix)) return false;              return Equals((Matrix)obj);          }            public override int GetHashCode()          {              int hashcode = 0;              for (int row = 0; row < matrix.GetLength(0); row++)                  for (int col = 0; col < matrix.GetLength(1); col++)                      hashcode = (hashcode + matrix[row, col].GetHashCode()) % int.MaxValue;                  return hashcode;          }            public static bool operator == (Matrix m1, Matrix m2)          {              return Object.ReferenceEquals(m1, null) ? Object.ReferenceEquals(m2, null) : m1.Equals(m2);            }          public static bool operator !=(Matrix m1, Matrix m2)          {              return !(m1 == m2);            }      }    Matrix m1 = new Matrix(new double[,] { { 1, 2, 3 }, { 4, 5, 6 } });  Matrix m2 = new Matrix(new double[,] { { 1, 2, 3 }, { 4, 5, 6 } });    Console.WriteLine($"m1 is equal to m2 by the reference: {Object.ReferenceEquals(m1, m2)}");     // false  Console.WriteLine($"m1 is equal to m2: {m1 == m2}");    //true

比較的邏輯實現放在 Equals(Matrix other) 中。在該方法中,首先判斷兩個矩陣是否引用了同一個二維數組,之後判斷行列的數目是否相等,最後再按照每個元素進行判斷。整個核心邏輯就在這裡。對於 Equals(object obj) 以及 ==!= 則直接調用 Equals(Matrix other) 方法。注意一點,在重載 == 符號時,不能直接用 m1==null 來判斷第一個對象是否為空,否則的話就是無限循環調用 == 操作符重載函數。在該函數中需要需要進行引用判斷的話,可以使用 Object 類中的靜態方法ReferenceEquals 來判斷。

總結

總體而言,C#中的相等比較參照的是這樣一條規律:值類型比較的是值是否相等,而引用類型比較的則是二者是否引用同一個對象。此外,本文還介紹了一些和相等判斷有關的函數和介面,這些函數和介面的作用在於構建了一個相等比較的框架。通過這些函數和介面,不僅可以使用默認的比較規則,而且我們還可以自定義比較規則。在本文的最後,我們還給出了一個例子來模擬自定義比較規則的用途。通過該例子,我們可以清楚地看到自定義比較的實現。