简介
最近正在看《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
属于值类型,其变量 v1
和 v2
均为3。从输出的结果可以看到,二者确实是相等的。但是对于 object
这种引用类型来说,即使是同一个 int
型数据转换而来(由int
型数据装箱),其二者也不是同一个引用,因而并不相等(即第6行)。但是对于 r3
来说,均是引用 r1
所指的对象,因而 r3
和 r1
相等。
虽然说值类型比较按照值比较,引用类型按照是否引用同一个数据比较。然而,也有一些特别的情况。典型的例子就是字符串 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
可以看到,这两个数据类型打破了之前给出的规则。虽然说 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("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)
方法。这样就避免了值类型的装箱操作。
自定义比较方法
在有时候,为了更好模拟现实中的场景,我们需要自定义两个个体之间的比较。为了实现这样的比较方法,通常有三步需要完成:
- 重写
Equals(object obj)
和GetHashCode()
方法; - 重载操作符
==
和!=
; - 实现
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#中的相等比较参照的是这样一条规律:值类型比较的是值是否相等,而引用类型比较的则是二者是否引用同一个对象。此外,本文还介绍了一些和相等判断有关的函数和接口,这些函数和接口的作用在于构建了一个相等比较的框架。通过这些函数和接口,不仅可以使用默认的比较规则,而且我们还可以自定义比较规则。在本文的最后,我们还给出了一个例子来模拟自定义比较规则的用途。通过该例子,我们可以清楚地看到自定义比较的实现。