在 C# 中实现值类型的相等性






3.76/5 (11投票s)
本文档是为初学者提供的关于如何实现值类型的相等性以及为什么应该实现它的指南。
引言
本文档将重点介绍值类型的相等性实现,即为我们自己实现的值类型重写相等性行为,这基本上是定义如何判断一个类型的两个对象是否相等。
背景
我们现在知道值类型和引用类型的相等性检查技术是不同的,最好将它们分开讨论。这就是为什么我们将分别关注它们,以便清楚地了解如何重写这两者的相等性。
本文档将特别关注值类型,而引用类型将在未来的某篇文章中讨论。
我们为什么需要它?
第一个可能浮现在脑海中的问题是“我们为什么要重写值类型的相等性”。我们将通过一个简单的例子来展示,它将帮助您理解重写值类型相等性行为确实是一个好主意。我们将在稍后定义一个struct
并实现它的相等性。
之前关于此主题的文章
如果您想阅读本系列的上一篇文章,如果您愿意,以下是与此相关的上一篇内容的链接:
- NET 中相等性的故事 - 第一部分
- C# 中的相等性和 Object 类(第 2 部分)
- C# 中的相等性和 IEquatable<T> 接口(第 3 部分)
- C# 中的 == 运算符和原始类型(第 4 部分)
- C# 中的 == 运算符和引用类型(第 5 部分)
- C# 中的 == 运算符和 String 类(第 6 部分)
- C# 中的 == 运算符和值类型(第 7 部分)
- 继承和泛型中相等运算符 (==) 的问题 (第 8 部分)
- 在 C# 中为值类型实现相等性 (第 9 部分)
- 在 C# 中为引用类型实现相等性 - 第十部分
实现值类型相等性的步骤
要重写值类型的相等性,需要执行一些必要的步骤,如下所述。
我们将需要
override
Object
的virtual Equals
方法- 实现
IEquatable<T> 接口
并提供其Equals()
方法的实现 - 提供
<span style="font-size: small">==</span>
和!=
运算符方法的重载实现 - 重写
Object
类的GetHashCode
方法
实现相等性的可能原因
因此,在我们开始讨论如何为值类型实现相等性之前,让我们先思考一下,为什么我们会想要为值类型定义自己的相等性行为,而不是使用框架已经提供的默认行为。
那么,你为什么要重写它?主要原因如下:
- 我们希望能够使用 == 运算符来比较我们值类型的对象。如果您还记得,我们之前讨论过 == 运算符对于值类型不起作用,要使其工作,我们需要在我们特定的值类型中进行一些实现(即重载 == 运算符)。
- 我们之前也看到过,框架提供的实现使用反射来检查该类型每个字段的值,这显然会影响性能,因为反射是慢的,导致代码性能低下。
- 我们有时也需要对特定类型的比较有不同的行为,虽然这通常不需要,但在某些情况下是需要的,因为值类型的默认行为认为两个对象相等,如果它们的所有字段内容也相同,这在大多数情况下都是可以的。
建议对您自己定义的值类型进行相等性实现,这些类型将在您的代码库中进一步使用。使用您定义的值类型的其他开发人员在尝试使用 == 运算符比较它们的两个对象时,不会感到惊讶或沮丧,并发现他们无法做到。
我们可以跳过那些我们知道将在代码库内部使用且不会暴露给整个代码库使用,并且我们知道我们不会频繁需要比较对象的值类型的相等性实现,因此为这些类型实现相等性不是那么必需,最终会浪费时间。
示例
我们将创建一个名为 Employee
的示例 struct
来说明如何为值类型实现相等性。我们的 Employee struct
现在看起来是这样的:
public struct Employee
{
public string Name { get; }
public Gender Gender { get; set; }
public Department Department { get; set; }
public Employee(string name, Gender gender, Department department)
{
Name = name;
Gender = gender;
Department = department;
}
public override string ToString()
{
return Name;
}
}
public enum Department
{
HumanRecource,
QualityAssurance,
SoftwareDevelopment,
ProjectManagement,
ITOperations
}
public enum Gender
{
Male,
Female
我们有一个非常简单的 Employee
类型作为示例,它有一个 String
属性,用于存储员工姓名,一个枚举属性用于保存员工性别,以及另一个枚举用于部门,用于跟踪员工的部门。我们稍后将定义自己的实现,以指示如何判断两个员工实例是否相等。
在 Employee
类型中,有许多事情需要我们注意并实现,以便实现相等性。我们需要做的第一件事是重写 Object.Equals()
方法,以便不再调用默认实现,而默认实现由于涉及反射而显然很慢。通过为此方法提供实现,它将提高类型的性能,因此重写 Equals
方法将消除使用反射进行的相等性检查,使其高效,我们将看到显著的性能提升。
但是等等,Object.Equals()
方法的参数类型是 Object
,这意味着会发生装箱,我们知道这也会损害我们代码的性能,因为装箱和拆箱都有其自身的成本,所以我们也希望以某种方式避免这种情况。
为了避免反射和装箱,我们将需要为我们的类型 Employee
实现 IEquatable<Employee> 接口
。现在,与框架提供的实现相比,我们将拥有高效的 Equals()
方法实现,另一个优点是现在我们有了更好的实现,而且它是类型安全的。
我们将需要为 ==
和 !=
运算符提供重载实现,因为在重写相等性时执行所有这些操作通常被认为是良好实践。
- 重写
Object.Equals
方法 - 为该类型实现
IEquatable<T> 接口
- 实现
==
和!=
重载方法 - 重写
Object.GetHashCode
方法
这样做很重要,因为它将确保比较该类型的两个对象的相等性将产生相同的结果,否则可能会使使用我们类型的调用代码感到困惑,并可能导致未来出现问题。
例如,如果我们只为 ==
和 !=
运算符提供重载,而没有为 Object.Equals
方法提供重写,那么 Equals
方法的结果可能会与 ==
运算符的结果不同,这将使使用我们类型的代码变得麻烦。
Object
类中还有一个名为 GetHashCode
的方法,每当我们为类型重写 Object.Equals
方法时,还必须做的另一件事是重写 GetHashCode
方法。
步骤 1 - 实现 IEquatable<T> 接口
现在,让我们进入 Visual Studio,开始为我们的 Person
类型实现 IEquatable<Employee> 接口
。我们将需要继承我们的 struct
,使其实现 IEquatable<Employee>
,并利用 Visual Studio 的代码重构功能。
它将添加 Equals
方法,但没有任何实现,供 IEquatable<Employee>
使用,看起来会像这样:
public bool Equals(Employee other)
{
throw new NotImplementedException();
}
现在,我们只需要提供逻辑,用于确定两个 Employee
对象是否相等,对于这个特定的例子,我们将通过检查name
和department
是否相同来做到这一点,或者我们也可以根据需要添加Gender
,因为这只是为了理解我们如何实现它,所以差别不大。
实现 Equals
方法后,该方法看起来如下:
public bool Equals(Employee other)
{
var IsEqual = this.Name == other.Name && this.Department == other.Department;
return IsEqual;
}
因此,如果 Name
字段和 Department
字段具有相同的值,则两个 Employee
对象将被视为相等
。我们的一个字段是String
类型,它对== 运算符执行值相等性检查,另一个是Enum
,它是 C# 中的基本类型,所以我们知道在基本类型的情况下,== 运算符也检查值相等性,所以这两个操作都将检查值相等性,这正是我们在这里要做的。
步骤 2 - 重写 Object.Equals 方法
既然我们已经完成了 IEquatable<Employee>
部分,现在让我们重写
Equals
方法,使其也返回与通过 IEquatable<Employee>
检查相等性时相同的结果。其实现将是:
public override bool Equals(object obj)
{
var IsEqual = false;
if(obj is Employee)
{
IsEqual = Equals((Employee)obj);
}
return IsEqual;
}
我们在这里所做的是,首先我们需要确保作为参数传递的对象是 Employee
类型,这是完全有意义的,因为 Object.Equals
不是类型安全的,并且有可能传递 Person
类型而不是 Employee
对象,编译器也不会给出编译时错误,但代码将在运行时失败,然而,添加该if
块将使我们免于在运行时代码崩溃,并且该方法将简单地返回false
,这是正确的结果,因为 Person
类型和 Employee
类型的对象永远不会相等。
需要注意的是,这个 Object.Equals
的重写效率不如 IEquatable<T>
的 Equals
方法实现,因为前者在被调用时会产生装箱的成本,然后我们将其拆箱回 Employee
,而 IEquatable<Employee>
的实现使我们避免了这些。
如果我们关心性能和内存成本,我们可以尝试始终使用 IEquatable<T>
方法进行调用。
步骤 3 - 重载 == 和 != 运算符
现在,我们也为 Employee
实现 ==
和 !=
运算符,这将确保使用这些运算符进行的相等性检查将返回与我们使用 Object.Equals
或 IEquatable<Employee> Equals
方法获得的结果一致。所以,让我们先为 ==
运算符添加实现,即:
public static bool operator ==(Employee employee,Employee otherEmployee)
{
var IsEqual = employee.Equals(otherEmployee);
return IsEqual;
}
该方法非常简单,它也重用了我们为 IEquatable<Employee>
所做的 Equals
方法实现。如果我们构建代码,它将无法构建,并会给出错误,提示找不到 !=
运算符的实现。在 C# 中,如果我们为某个类型重载了 ==
运算符,则必须同时提供其逆运算的实现,即 !=
运算符的实现,否则我们将收到以下编译时错误:
错误 CS0216: 运算符 'Employee.operator ==(Employee, Employee)' 需要一个匹配的运算符 '!=' 也要被定义
如果我们重载了其中一个运算符,无论是 ==
还是 !=
,我们都需要实现另一个,所以让我们也实现另一个:
public static bool operator !=(Employee employee, Employee otherEmployee)
{
var IsNotEqual = !employee.Equals(otherEmployee);
return IsNotEqual;
}
我们可以看到,它只是反转了 IEquatable<Employee>
的 Equals
方法返回的结果,然后将其传递回调用者。现在,如果我们再次构建我们的解决方案,我们将能够成功构建而没有错误。
步骤 4 - 实现 GetHashCode
这是一个惯例,并且被认为是强制性的,即如果为某个类型重写了 Equals
方法,那么我们还必须为 GetHashCode()
提供重写实现。
如果我们查看 Object
的实现,我们会看到其中有一个名为 GetHashCode
的virtual
方法。它的作用是返回对象本身所包含的值的32位哈希。
您可能想知道这段代码的目的是什么。GetHashCode
方法用于内部使用哈希表
来存储对象的类型,这些对象通常是集合
。它们会消耗此方法并获取对象的哈希值,用于哈希表,因为哈希表
会利用对象的哈希码。在框架类库中,Dictionary<TKey,TValue>
也会在内部使用哈希表
。
哈希码是一个完整的课题,可以写一篇完整的文章来涵盖其方面,我们不会详细讨论它。哈希表的工作原理是,如果两个对象调用 Equals
方法时返回true
,那么当我们在两者上调用 GetHashCode
方法时,两个对象的哈希码也应该返回相同的值,在我们的情况下,这意味着以下行也应该返回true
。
所以,它实际上意味着:如果 <span style="color: #444444"><span style="color: black">employee.Equals(OtherEmployee</span>)</span>
的结果为true
,那么 employee.GetHashCode() == otherEmployee.GetHasCode()
的结果也应该为true
。如果 GetHashCode
方法没有按照要求实现,那么使用我们类型的Dictionary
将无法正常工作并可能导致问题。
现在,让我们为我们的 Employee
类型 GetHashCode
方法添加实现,这将是:
public override int GetHashCode()
{
return Name.GetHashCode() ^ Department.GetHashCode();
}
所以,很容易理解我们上面所做的事情:我们只是取了我们String
字段 Name
和 Department
枚举的HashCode
,并使用XOR将它们组合起来。由于这两个都是框架提供的类型,因此已经实现了生成这些类型的哈希码的方法,所以我们只是重用了框架已经提供和可用的哈希码。
现在,让我们在 Main
方法中添加一些代码来验证我们所做的实现是否按预期工作。在 Main
中添加以下代码并运行它:
static void Main(string[] args)
{
Employee ehsan = new Employee("Ehsan Sajjad", Gender.Male, Department.SoftwareDevelopment);
Employee ehsan2 = new Employee("Ehsan Sajjad", Gender.Female, Department.SoftwareDevelopment);
Employee bilal = new Employee("Bilal Asghar", Gender.Male, Department.QualityAssurance);
object ehsanObj = ehsan;
Console.WriteLine(ehsan.Equals(ehsan2));
Console.WriteLine(ehsanObj.Equals(ehsan2));
Console.WriteLine(ehsan == ehsan2);
Console.WriteLine(ehsan.Equals(bilal));
Console.WriteLine(ehsanObj.Equals(bilal));
Console.WriteLine(ehsan == bilal);
Console.ReadKey();
}
以下是打印在控制台上的结果:
我们可以观察到,对于 ehsan
和 ehsan2
对象,所有三个条件都评估为true
,这当然意味着两者相等,正如预期的那样,这就是我们定义类型中相等性检查的方式。虽然我们在两个对象中定义了不同的Gender
字段,但它评估为两者相等,因为我们在决定两个对象是否相等时不考虑Gender
字段。
摘要
以下是我们在这篇文章中学到的要点:
- 我们学习了如何为值类型提供相等性检查的自定义实现
- 为值类型实现相等性通常是好的,因为它可以通过消除装箱/拆箱和反射成本来提高性能,这会使我们的代码效率低下。
- 在重写值类型的相等性时,有许多事情需要注意,包括:
- 实现该值类型的
IEquatable<T> 接口
,它将有一个类型安全的Equals
方法,用于检查类型T
的两个对象 - 重写
Object.Equals
方法,该方法又调用我们为IEquatable<T>
实现的Equals
方法 - 实现
==
和!=
运算符重载,这些运算符也调用IEquatable<T>
上的Equals
方法 - 为我们的值类型
T
实现Object
的GetHashCode
- 实现该值类型的
- 这是为值类型实现相等性的推荐方法,而对于引用类型,这不是推荐的方法。