1. 程式人生 > 其它 >C#中的等值判斷進階篇

C#中的等值判斷進階篇

  簡介

  最近正在看《C# in a nutshell》這本書,可以學到c#中很多精美的設計思想,但是整體上來說其設計還是比較優秀的。這裡,本文打算從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 屬於值型別,其變數 v1 和 v2 均為3。從輸出的結果可以看到,二者確實是相等的。但是對於 object 這種引用型別來說,即使是同一個 int 型資料轉換而來(由int型資料裝箱),其二者也不是同一個引用,因而並不相等(即第6行)。但是對於 r3 來說,均是引用 r1 所指的物件,因而 r3 和 r1 相等。

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

  string s1="test";

  string s2="test";

  Uri u1=new Uri("toutiao-com");

  Uri u2=new Uri("toutiao-com");

  Console.WriteLine($"s1 is equal to s2: {s1==s2}"); // true

  Console.WriteLine($"u1 is equal to u2: {u1==u2}"); // true

  可以看到,這兩個資料型別打破了之前給出的規則。雖然說 string 和 System.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個函式一一介紹:

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

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

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

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

  string s1="test";

  string s2="test";

  Uri u1=new Uri("toutiao-com");

  Uri u2=new Uri("toutiao-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

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

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

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

  public interface IEquatable

  {

  bool Equals(T other);

  }

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

  自定義比較方法

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

  重寫 Equals(object obj) 和 GetHashCode() 方法;過載操作符==和 !=;實現 IEquatable 方法;

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

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

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

  舉例

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

  public class Matrix : IEquatable

  {

  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#中的相等比較參照的是這樣一條規律:值型別比較的是值是否相等,而引用型別比較的則是二者是否引用同一個物件。此外,本文還介紹了一些和相等判斷有關的函式和介面,這些函式和介面的作用在於構建了一個相等比較的框架。通過這些函式和介面,不僅可以使用預設的比較規則,而且我們還可以自定義比較規則。在本文的最後,我們還給出了一個例子來模擬自定義比較規則的用途。通過該例子,我們可以清楚地看到自定義比較的實現。