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





5.00/5 (4投票s)
本文概述了为什么我们要为引用类型实现相等性以及如何实现。
引言
在上一篇文章中,我们讨论了为何要在 C# 中为值类型实现相等性,以及它如何帮助编写更高效的代码。对于我们自己编写值类型时,.NET 框架的默认实现会使用反射来基于值进行相等性比较,因此,为值类型重写相等性行为总是更好的选择,这无疑会带来性能上的提升。
我们将学到什么?
我们没有讨论为引用类型实现相等性,因此在本文中,我们将只关注重写引用类型的相等性行为。因为引用类型涉及其他更复杂的因素,当为引用类型实现相等性时需要处理这些因素,包括继承。与值类型不同,引用类型具有继承特性,并且还需要处理 `null`,因为引用类型可以为 `null`,而值类型则不能。
我们还将看到哪些情况下我们应该为引用类型实现相等性,我们已经在值类型的情况下看到了需要这样做的原因。
我们将重写引用类型的相等性来演示概念,与为值类型演示的方式相同,以便清晰地理解这里的概念。我们将看到为引用类型实现相等性时所需的必要步骤,并且还将学习如何在引用类型涉及继承的情况下实现它。例如,类 `A` 为自身实现了相等性,但将来类 `B` 继承自 `A`,在这种情况下,类 `B` 如何实现自身的相等性而不破坏类 `A` 的相等性行为。
开始吧
为引用类型实现相等性的方式与为值类型实现的方式略有不同。如上所述,对于引用类型,我们还需要处理继承,而对于值类型,我们永远不需要处理继承,因为它们在 .NET 框架中默认标记为 sealed,因此为引用类型实现相等性比为值类型更复杂。
现在,让我们创建一个引用类型,我们将在本文接下来的部分中为它实现相等性。那么,让我们开始创建类,我们将在本文中为它实现相等性。
我们将以 `Car` 类为例,因为我们想了解引用类型的相等性。以下是我们 `Car` 类的实现:
public class Car
{
public int MakeYear { get; private set; }
public Decimal Price { get; private set; }
public Car(int makeYear, Decimal price)
{
MakeYear = makeYear;
Price = price;
}
public override string ToString()
{
return $"Model: {MakeYear}, Price: {Price}";
}
}
我们知道类是**引用类型**,因此我们可以在其中使用**面向对象原则**,其中重要的一部分是**继承**。我们将研究在继承也起作用时如何检查两个对象是否相等,为此我们将考虑 `Car` 类型作为基类,并用子类扩展它来理解它的行为以及我们如何实现它。
现在,我们将创建一个派生类,该类将具有**重写的实现**,这当然会与基类不同。假设我们创建一个名为 `LeaseCar` 的类,它将有一个额外的属性,用于存储租赁汽车可用的租赁选项信息,并且该对象将存储在创建对象时选择的特定选项。
我们的 `LeaseCar` 类将如下所示:
public class LeaseCar : Car
{
public LeaseOptions Options { get; set; }
public LeaseCar(int makeYear, Decimal price, LeaseOptions options) : base(makeYear, price)
{
Options = options;
}
public override string ToString()
{
return $"Model: {MakeYear}, Price: {Price}, Lease Options: {Options.ToString()}";
}
}
我们的 `LeaseCar` 包含一个新属性,用于存储租赁汽车的选项,我们还提供了 `ToString()` 方法的重写实现,它将以逗号分隔的形式返回首付信息、分期付款计划和月供的名称。
我们的 `LeaseOptions` 实现如下:
public class LeaseOptions
{
public Decimal DownPayment { get; }
public InstallmentPlan InstallmentPlan { get; }
public Decimal MonthlyInstallment { get; }
public LeaseOptions(Decimal downPayment,
InstallmentPlan installmentPlan,
Decimal monthlyInstallment)
{
DownPayment = downPayment;
InstallmentPlan = InstallmentPlan;
MonthlyInstallment = monthlyInstallment;
}
public override bool Equals(object obj)
{
if (obj == null)
return false;
if (ReferenceEquals(obj, this))
return true;
if (obj.GetType() != this.GetType())
return false;
LeaseOptions optionsToCheck = obj as LeaseOptions;
return this.DownPayment == optionsToCheck.DownPayment
&& this.InstallmentPlan == optionsToCheck.InstallmentPlan
&& this.MonthlyInstallment == optionsToCheck.MonthlyInstallment;
}
public override int GetHashCode()
{
return this.DownPayment.GetHashCode()
^ this.InstallmentPlan.GetHashCode()
^ this.MonthlyInstallment.GetHashCode();
}
public static bool operator ==(LeaseOptions optionsA, LeaseOptions optionsB)
{
return Object.Equals(optionsA, optionsB);
}
public static bool operator !=(LeaseOptions optionsA, LeaseOptions optionsB)
{
return !Object.Equals(optionsA, optionsB);
}
public override string ToString()
{
return $"{nameof(DownPayment)}: {DownPayment}, {nameof(InstallmentPlan)}:
{InstallmentPlan.ToString()}, {nameof(MonthlyInstallment)}: {MonthlyInstallment}";
}
}
首先,我们将编写一些基本代码,看看在没有专门提供如何比较两个引用的实现时,比较两个引用类型的默认行为是什么。以下是我们 `Main` 程序的代码:
class Program
{
static void Main(string[] args)
{
Car carA = new Car(2018, 100000);
Car carA2 = new Car(2018, 100000);
Car carB = new Car(2016, 2500000);
LeaseCar leasedCarA =
new LeaseCar(2014, 2500000, new LeaseOptions(1000000, InstallmentPlan.FiveYears, 10000));
LeaseCar leasedCarA2 =
new LeaseCar(2014, 2500000, new LeaseOptions(1000000, InstallmentPlan.FiveYears, 10000));
LeaseCar leasedCarB =
new LeaseCar(2016, 2500000, new LeaseOptions(1000000, InstallmentPlan.FiveYears, 10000));
Console.WriteLine(carA == carA2);
Console.WriteLine(carA == carB);
Console.WriteLine(leasedCarA == leasedCarA);
Console.WriteLine(leasedCarB == carB);
Console.Read();
}
}
我们可以看到创建了五个不同的对象,它们都是 `Car` 类型或继承自 `Car`,因此我们可以说所有对象都是 `Car` 类型,并且我们逐一比较了它们。结果并不令人意外,符合我们的预期。所有其他比较操作都返回 false,除了 `leaseCarA == leaseCarA `,这很明显,因为我们正在将 `leaseCarA ` 对象引用与其自身进行比较。这里我们清楚,它是在比较引用是否相等,而不是它们的值,因为类是引用类型。以下是上述程序的输出:
我们也可以使用 `Object.Equals()` 方法而不是上面的示例程序中的 `==` 运算符,但结果将相同,因为两者都会检查两个对象的引用是否指向同一内存位置。
还有一点需要注意,我们之前看到对于值类型,为了让 `==` 运算符正常工作进行比较,我们不得不在该值类型中定义 `==` 和 `!=` 的重载,但对于引用类型,我们无需定义这些,因为它是引用类型,并且基类 `Object ` 已经负责对除 `String` 之外的任何引用类型进行引用比较,因为它的情况略有不同,我们在之前的文章中已经见过。
重写引用类型相等性的动机
重写值类型的实现对于所有我们来说,性能动机已经非常清楚了,也就是说,代码的性能。如果我们不为值类型提供自己的实现,框架将使用反射来比较两个对象的字段和属性是否相等。但是,有些人可能在想,为什么我们需要为引用类型实现呢?
答案如下:
答案是,有时我们不希望仅仅依赖于特定引用类型的两个对象的引用相等性。我们可能希望当两个对象的某个引用类型的属性具有相同值时,就认为这两个对象相等。在这些情况下,我们需要自己为这些引用类型实现相等性,而不是依赖框架对引用类型的默认行为。
因此,我们也可以这样说:当我们希望我们的对象根据其值而不是对象的引用来检查相等性时。总的来说,与值类型相比,为引用类型实现相等性并不常用。
重写引用类型相等性的可能用例
一种这种情况的例子可能是一个名为 `Marks` 的类,它存储了学生每个科目的分数,假设我们出于任何原因需要频繁地比较学生的成绩,在这种情况下,我们可以重写 `Marks` 类的实现来比较两个对象之间每个科目的分数。另一种情况是,我们可能有一个类存储位置详细信息,如 `Latitude Longitude`,并且我们希望基于这些值相等来检查相等性。
示例
另一个很好的例子是一个名为 `User` 的模型类,或者任何包含 `string` 属性的类,并且我们希望通过比较 `string` 值来执行相等性检查。让我们以以下类为例:
public class User
{
public string FirstName {get;set;}
public string LastName {get;set;}
public string EmailAddress {get;set;}
}
让我们在 `Main` 方法中编写以下代码:
void Main()
{
User user1 = new User()
{
FirstName="ehsan",
LastName="sajjad",
EmailAddress="ehsansajjad@yahoo.com"
};
User user2 = new User()
{
FirstName="ehsan",
LastName="sajjad",
EmailAddress="ehsansajjad@yahoo.com"
};
Console.WriteLine(user1 == user2);
}
我们将看到这两个对象不被认为是相等的,尽管如果我们考虑它们属性中的值,它们都是完全相同且相等的。但由于 User 是引用类型,因此它在这里进行的是引用相等性,而不是值相等性。
重写相等性的预期后果
在引用类型中实现值相等性时,我们应仔细观察,因为大多数开发人员会期望相等性比较执行引用相等性检查,而不是基于值的检查。因此,重写引用类型的默认框架行为应该有一个充分的理由。
我们上面讨论的案例有时可能也不是重写引用类型相等性的好理由。如果我们考虑重写引用类型的相等性,我们应该首先考虑我们正在编写的类的用途,并考虑重写它的相等性是否会使它的消费者更容易使用,结果可能取决于该引用类型的实际用途以及消费者代码将如何利用它。
重写引用类型相等性的替代方法
.NET 框架还提供了另一种方法,让其他开发人员能够使用值相等性来比较您的类的对象,以检查它们是否包含相同的值,因此我们可以在不重写引用类型相等性的情况下做到这一点。
我们需要编写一个 `EqualityComparer` 类,它继承自 `IEqualityCompare<T>`,我们在其中编写逻辑来基于值比较引用类型对象。这实际上允许开发人员在需要时插入值相等性检查,否则默认行为将是引用相等性检查,这也是 .NET 框架对引用类型的默认行为。因此,使用此方法在进行相等性检查时提供了更大的灵活性,但有一点需要注意,使用此方法,我们无法使用 `==` 运算符进行值相等性检查,并且我们需要调用相等性比较器实例上的 `Equals` 方法,并将两个对象作为参数传递以比较它们。
在基类中重写 Equals()
现在,我们将首先为基类 `Car` 定义相等性行为,其中第一件事是重写 `Object.Equals` 方法,以便它通过其属性的值来比较两个 car 对象。
以下是我们基类 `Employee` 的代码,作为 `Object` 的 `Equals` 方法的重写实现:
public override bool Equals(object obj)
{
if (obj == null)
return false;
if (ReferenceEquals(obj, this))
return true;
if (obj.GetType() != this.GetType())
return false;
Car carToCheck = obj as Car;
return this.MakeYear == carToCheck.MakeYear
&& this.Price == carToCheck.Price;
}
代码非常简单。首先,它确保传入的参数不是 `null`。如果为 `null`,我们只是告知调用者两个对象不相等。我们将将其与实际调用此实例方法的对象进行比较,因此这个对象显然不会为 `null`。
然后,我们使用 `ReferenceEquals()` 方法检查两个实例是否引用同一对象,这意味着我们将对象与自身进行比较,这显然会得到两个对象相等的结论。因此,在这种情况下,我们返回 `true`。此检查有助于提供一点性能优势,因为我们后面的检查最终也会评估为 true,因此如果我们在方法调用中尽早获得结果并避免比较我们类型的多个属性值,那就好了。
在执行值相等性之前,我们还需要确保传入参数对象的类型与 `this` 的类型相同,这就是我们尝试做的。如果它们类型不同,则意味着它们不是同一个对象,因此我们只需返回 `false`,因为两个引用类型的实例通常不相等。
最后,我们有代码来检查两个对象是否具有我们正在检查的属性的相同值。如果是这样,我们可以说这两个对象是相同的,我们将返回 `true` 作为结果。
我们还需要提供 `GetHashCode()` 方法的实现,以便它与 `Equals()` 方法的实现保持一致。这与我们之前为值类型所做的非常相似,这是它的代码:
public override int GetHashCode()
{
return this.MakeYear.GetHashCode() ^ this.Price.GetHashCode();
}
我们只是获取三个属性的值,并将它们进行**异或**操作。在本篇文章中,我们不会详细介绍为什么这样做以及其他细节,我们将在后续文章中详细介绍。
我们在之前的一篇文章中也讨论过重写类型的相等性时需要执行的基本步骤。所以,我们已经实现了两个方法,我们还需要**重载** `==` **运算符**和 `!=` 方法,因为如果我们不这样做,那么使用该类型的开发人员将得到不一致和矛盾的结果。所以,让我们编写重载:
public static bool operator ==(Car carA, Car carB)
{
return Object.Equals(carA, carB);
}
public static bool operator !=(Car carA, Car carB)
{
return !Object.Equals(carA, carB);
}
我们只是调用 `static Equals()` 方法,它将首先检查两个参数以确保它们都不为 `null`,然后最终调用 `Object 类的 `virtual Equals` 方法,返回我们类型的**重写**实现将被调用,这就是我们想要的。对于 `!= operator,我们只是对 `Object.Equals()` 方法的结果取反。
因此,我们几乎完成了重写引用类型的相等性时需要实现的所有工作。
重写派生类相等性
现在,我们将实现继承自 `Car ` 类的 `LeaseCar` 类的相等性。首先,就像我们为 `Parent` 类所做的那样,我们将提供 `Equals()` 方法的重写,这是它的实现:
public override bool Equals(object obj)
{
if (!base.Equals(obj))
return false;
LeaseCar objectToCompare = (LeaseCar)obj;
return this.Options == objectToCompare.Options;
}
对于派生类型,我们需要与基引用类型以不同的方式处理。在 `Base` 中,我们检查了我们认为在比较两个 `Car` 对象时需要检查的所有属性。所以在这里,我们将重用基类方法来执行初步检查。所以,首先,我们调用 `Base` 的 `Equals` 实现,看看它返回什么。如果基类实现的调用结果表明两个实例不相等,我们就不继续检查派生类的属性,因为基类已经告诉我们这两个对象不相同,所以它们是不同的。
但是,如果基类实现表明两个对象相等,那么我们继续进行派生类的检查,其中代码从上面方法中的第 5 行开始。我们已经看到了基类 `Equals` 的作用:它确保两个实例是相同类型并且具有相同的值,然后我们检查派生类的属性值相等性来决定对象是否相等。
但是,如果我们看这里的 `Options` 属性,它也是一个引用类型,一个 `LeaseOptions` 类的对象。所以,我们需要为它实现相等性,以便我们可以在 `LeaseCar` 中更好地使用它。以下是 `LeaseOptions` 的实现:
public override bool Equals(object obj)
{
if (obj == null)
return false;
if (ReferenceEquals(obj, this))
return true;
if (obj.GetType() != this.GetType())
return false;
LeaseOptions optionsToCheck = obj as LeaseOptions;
return this.DownPayment == optionsToCheck.DownPayment
&& this.InstallmentPlan == optionsToCheck.InstallmentPlan
&& this.MonthlyInstallment == optionsToCheck.MonthlyInstallment;
}
public override int GetHashCode()
{
return this.DownPayment.GetHashCode()
^ this.InstallmentPlan.GetHashCode()
^ this.MonthlyInstallment.GetHashCode();
}
因此,现在,当我们调用 `LeaseOptions` 的相等性检查时,它将使用提供的实现来检查,并且我们将在需要为派生类编写的其他重载中使用它,并且它还将用于 `LeaseCar` 的 `GetHashCode()` 实现。
我们不需要处理 `null` 处理,因为我们首先调用 `base.Equals`,并且在其实现中,我们已经处理了 `null`。现在,让我们为 `LeaseCar` 实现 `GetHashCode() `方法:
public override int GetHashCode()
{
return base.GetHashCode() ^ this.Options.GetHashCode();
}
所以,我们在这里做的是调用基类实现来获取它的哈希码,然后将其与 `LeaseCar` 的 `Options` 字段进行**异或**。由于我们还为 `LeaseOptions` 类提供了实现,因此可以使用我们想要的所有字段以相同的方式生成 `GetHashCode() `。
现在,我们还需要提供 `==` 和 `!=` **运算符重载**,它们将与我们在基类中所做的类似,调用 `static object.Equals` 方法,该方法最终通过调用 `Object` 类的 `virtual Equals` 方法来调用 `LeaseCar` 的重写实现。实现的方法将如下所示:
public static bool operator ==(LeaseCar carA, LeaseCar carB)
{
return object.Equals(carA, carB);
}
public static bool operator !=(LeaseCar carA, LeaseCar carB)
{
return !object.Equals(carA, carB);
}
如果我们注意到,我们上面的重载与基类中的实现相同,所以如果我们想跳过实现这两个,我们可以这样做,结果将与我们使用它们得到的结果相同。但为了在本文中提供派生类的完整实现,我们在这里添加了它。
现在,我们已经完成了两个类的实现,让我们编写一些代码来测试我们相等性实现的正确性。以下是要添加到 `Main` 方法中的代码:
static void Main(string[] args)
{
Car carA = new Car(2018, 100000);
Car carA2 = new Car(2018, 100000);
Car carB = new Car(2016, 2500000);
LeaseCar leasedCarA = new LeaseCar(2014, 2500000,
new LeaseOptions(1000000,InstallmentPlan.FiveYears, 10000));
LeaseCar leasedCarA2 = new LeaseCar(2014, 2500000,
new LeaseOptions(1000000, InstallmentPlan.FiveYears, 10000));
LeaseCar leasedCarB = new LeaseCar(2016, 2500000,
new LeaseOptions(1000000, InstallmentPlan.FiveYears, 10000));
Console.WriteLine(carA == carA2);
Console.WriteLine(carA == carB);
Console.WriteLine(leasedCarA == leasedCarA);
Console.WriteLine(leasedCarB == carB);
Console.Read();
}
现在,我们可以从控制台输出来观察,我们的对象不是基于引用进行评估的,而是基于我们指示类型在比较相等性时使用的字段/属性中包含的值进行评估的。
摘要
在本文中,我们学习了以下内容:
- 我们看到了如何为引用类型实现相等性行为,以便它们在比较两个对象时可以像值类型一样。
- 由于引用类型支持继承特性,因此实现我们的相等性检查行为比值类型更复杂。
- 要记住的另一件事是,为引用类型实现相等性并非总是个好主意,因为默认情况下它们是使用对象引用进行比较的。但对于值类型,我们应该实现,因为这将带来性能上的提升,因为我们可以消除使用反射进行自定义值类型比较的默认行为。
- 要实现相等性,需要执行以下操作:
- 在引用类型中重写 `Object` 类的 `Equals()` 方法。
- 在引用类型中重写 `Object` 类的 `GetHashCode()` 方法。
- 为类型实现 `==` 和 `!=` 运算符的重载。
另请参阅
如果您想阅读本系列的上一篇文章,如果您愿意,以下是与此相关的上一篇内容的链接: