NET 中相等性的故事 - 第一部分






4.87/5 (49投票s)
本文旨在概述和探讨一些使相等性操作比您预期更为复杂的问题。
引言
本文旨在概述和探讨一些使相等性操作比您预期更为复杂的问题。除其他内容外,我们将探讨值相等性和引用相等性之间的区别,以及为什么相等性和继承不能很好地协同工作。那么,让我们开始吧。
背景
几个月前,我在 Pluralsight 上参加了一个由 Simon Robinson 教授的关于 NET 中相等性和比较 的非常有趣的课程。这真是一门很棒的课程,可以帮助开发人员理解相等性和比较在 .NET 中的工作原理,因此我考虑分享我从课程中学到的见解。希望这能帮助其他人理解相等性行为,并深入了解 .NET 如何处理它。
已发布部分
- NET 中相等性的故事 – 第一部分
- NET 中相等性的故事(虚拟 Object.Equals、静态 Object.Equals 和 Reference.Equals)– 第二部分
- NET 中相等性的故事(IEquatable<T> 接口简介)– 第三部分
- NET 中相等性的故事(C# 中的 == 运算符和原始类型)– 第四部分
- NET 中相等性的故事(C# 中的 == 运算符和引用类型)– 第五部分
- NET 中相等性的故事(C# 中的 == 运算符和 String 类型)– 第六部分
- NET 中相等性的故事(C# 中的相等性运算符 (==) 和值类型)– 第七部分
- NET 中相等性的故事(C# 中相等性运算符 (==) 与继承和泛型的问题)– 第八部分
- 在 C# 中为值类型实现相等性 - 第九部分
- 在 C# 中为引用类型实现相等性 - 第十部分
开始吧
我们将从一个简单的例子开始,比较两个数字。例如,假设 3 小于 4,从概念上讲,这很简单,代码也非常容易和简单。
if(3 < 4)
{
}
如果您查看所有其他类型都继承自的 System.Object 类,您会发现以下 4 个用于相等性检查的方法:
此外,Microsoft 还提供了 9 个不同的接口用于执行相等性或类型比较:
- IEquatable<T>
- IComparable
- IComparable<T>
- IComparer
- IComparer<T>
- IEqualityComparer
- IEqualityComparer<T>
- IStructuralEquatable
- IStructuralComparable
这些方法和接口中的大多数都带有风险,如果您错误地重写它们的实现,可能会导致代码中的错误,甚至可能破坏依赖于它们的框架提供的现有集合。
我们将看看这些方法和接口的用途以及如何正确使用它们。我们还将重点介绍如何以正确的方式提供相等性和比较的自定义实现,这些实现将高效地执行并遵循最佳实践,最重要的是,不会破坏其他类型的实现。
相等性很难
有 4 个原因使相等性比您预期的要复杂
- 引用与值相等性
- 比较值的多种方法
- 准确性
- 与 OOP 冲突
引用与值相等性
存在引用与值相等性的问题,可以以两种方式对待相等性,不幸的是,C# 的设计方式无法区分这两种方式,如果您不了解这些各种运算符和方法的工作原理,有时可能会导致意外行为。
正如您所知,在 C# 中,引用类型不包含实际值,它们包含指向实际保存这些值的内存位置的指针,这意味着对于引用类型,有两种可能的衡量相等性的方法。
您可以说两个变量是否指向同一内存位置,这称为引用相等性(也称为标识),或者您可以说两个变量指向的位置是否包含相同的值,即使它们是不同的位置,这称为值相等性。
我们可以使用以下示例来说明上述几点。
class Program { static void Main(String[] args) { Person p1 = new Person(); p1.Name = "Ehsan Sajjad"; Person p2 = new Person(); p2.Name = "Ehsan Sajjad"; Console.WriteLine(p1 == p2); Console.ReadKey(); } }
如上例所示,我们实例化了两个 `Person` 类的对象,它们都为 `Name` 属性包含相同的值。显然,`Person` 类的这两个实例是相同的,因为它们包含相同的值,它们真的相等吗?当我们使用 C# 相等性运算符检查这两个实例的相等性并运行示例代码时,它在控制台输出 False
,这意味着它们不相等。
这是因为对于 `Person` 类,C# 和 .NET 框架都将相等性视为引用相等性。换句话说,相等性运算符检查这两个变量是否指向同一内存位置,因此在此示例中,它们不相等,因为尽管 `Person` 类的两个实例是相同的,但它们是独立的实例,变量 p1
和 p2
都指向不同的内存位置。
引用相等性执行起来非常快,因为您只需要检查一件事:两个变量是否持有相同的内存地址,而比较值可能要慢得多。
例如,如果 `Person` 类包含许多字段和属性,而不仅仅是一个,并且您想检查 `Person` 类的两个实例是否具有相同的值,您将不得不检查每个字段/属性,C# 中没有运算符可以检查两个 `Person` 类实例的值相等性,这是合理的,因为比较包含完全相同值的 `Person` 类的两个实例并不是您通常想要做的事情,显然,如果您出于某种原因想这样做,您需要编写自己的代码来完成。
现在以这段代码为例
class Program
{
static void Main(String[] args)
{
string s1 = "Ehsan Sajjad";
string s2 = string.Copy(s1);
Console.WriteLine(s1 == s2);
Console.ReadKey();
}
}
上面的代码与之前的示例代码非常相似,但在本例中,我们将相等性运算符应用于相同的 string
。我们实例化一个 string
并将其引用存储在一个名为 s1
的变量中,然后我们创建了一个其值的副本并将其保存在另一个名为 s2
的变量中。现在,如果我们运行此代码,我们将看到根据输出,我们可以说这两个 string
是相等的。
如果相等性运算符一直在检查引用相等性,那么对于这个程序,我们本应在控制台看到 false
的输出,但对于 strings
,== 运算符会评估操作数的值相等性。
Microsoft 这样实现是因为检查一个 string
是否包含另一个 string
是程序员非常频繁需要做的事情。
引用类型和值类型
顺便说一句,引用和值问题仅存在于引用类型中。对于未装箱的值类型(如 integer、float 等),变量直接包含值,没有引用,这意味着相等性仅表示比较值。
比较两个整数的以下代码将评估为相等,因为相等性运算符将比较变量所保存的值。
class Program
{
static void Main(String[] args)
{
int num1 = 2;
int num2 = 2;
Console.WriteLine(num1 == num2);
Console.ReadKey();
}
}
因此,在上面的代码中,相等性运算符正在比较存储在变量 num1
中的值与存储在 num2
中的值。
但是,如果我们修改此代码并将两个变量转换为 object,就像我们在以下几行代码中所做的那样
int num1 = 2;
int num2 = 2;
Console.WriteLine((object)num1 == (object)num2);
现在,如果我们运行代码,您将看到结果与我们从第一版代码中得到的结果相矛盾,即第二版代码比较返回 false
,之所以会这样,是因为 object 是引用类型,所以当我们将其转换为 object 时,它会被装箱为引用类型的 object,这意味着第二段代码正在比较引用而不是值,它返回 false
是因为两个整数都被装箱到不同的引用实例中。
这是很多开发人员不曾预料到的,通常我们不会将值类型转换为 object,但我们经常看到的另一个常见场景是我们可能需要将值类型转换为接口。
Console.WriteLine((IComparable<int>)num1 == (IComparable<int>)num2);
为了说明我们上面的观点,让我们修改示例代码,将整数变量转换为 ICompareable<int>
。这是 .NET Framework 提供的接口,整数类型继承或实现了该接口。我们将在其他帖子中讨论它。
在 .NET 中,接口始终是引用类型,因此上面的代码行涉及装箱,如果我们运行此代码,我们将看到这个相等性检查也返回 false
,这是因为这再次检查引用相等性。
因此,您在将值类型转换为接口时需要小心,如果您进行相等性检查,它将始终导致引用相等性。
== 运算符
如果 C# 为值类型和引用类型相等性提供了不同的运算符,那么所有这些代码可能都不会成为问题,但它没有,这让一些开发人员认为这是一个问题。C# 只有一个相等性运算符,并且没有明显的方法可以预先知道该运算符对于给定类型的实际作用。
例如,考虑这段代码
Console.WriteLine(var1 == var2)
我们无法确定上面相等性运算符的作用,因为您只需要知道相等性运算符对某个类型的行为,别无他法,C# 就是这样设计的。
在本帖中,我们将详细介绍相等性运算符的作用以及它在底层是如何工作的,因此在阅读完整个帖子后,我希望您对当您编写相等性检查条件时实际发生的情况会有比其他开发人员更好的理解,并且您将能够更好地确定两个对象之间的相等性是如何计算的,并且在遇到比较两个对象相等性的代码时能够正确回答。
比较值的不同方法
相等性复杂性存在的另一个问题是,对于给定的类型,通常有多种比较值的方法。String
类型是最好的例子。假设我们有两个 string
变量,它们包含相同的值。
string s1 = "Equality";
string s2 = " Equality";
现在,如果我们比较 s1
和 s2
,我们是否应该期望相等性检查的结果为 true
?这意味着我们是否应该认为这两个变量相等?
我敢肯定,您会看到这两个 string
变量包含完全相同的值,那么认为它们相等是有意义的,事实上 C# 也是这样做的,但如果我更改其中一个的大小写使它们不同,例如
string s1 = "EQUALITY";
string s2 = "equality";
现在,这两个 string
应该被视为相等吗?在 C# 中,相等性运算符将评估为 false
,表示这两个 string
不相等,但如果我们问的不是 C# 相等性运算符,而是在原则上,我们应该认为这两个 string
相等,那么我们无法真正回答,因为这完全取决于上下文,我们是否应该考虑或忽略大小写。假设我有一个食物项数据库,我们正在从中查询一个食物项,那么我们很可能希望忽略大小写并将这两个 string
视为相等,但如果用户正在输入密码登录应用程序,并且您必须检查用户输入的密码是否正确,那么您肯定不应该认为小写和首字母大写的 string
相等。
C# 中 string
的相等性运算符始终区分大小写,因此您不能将其用于比较并忽略大小写。如果您想忽略大小写,可以这样做,但您必须调用 String
类型中定义的特殊方法。例如
string s1 = "EQUALITY";
string s2 = "equality";
if(s1.Equals(s2,StringComparison.OrdinalIgnoreCase))
上面的例子将评估 if
语句为 true
,因为我们在进行 s1
和 s2
之间的相等性比较时告诉忽略大小写。
现在我敢肯定,这些都不会让您感到惊讶。大小写敏感性是几乎所有人在编程早期都会遇到的问题。从上面的例子中,我们可以说明一个更广泛的平等性一般性观点,即平等性在编程中不是绝对的,它通常是上下文相关的(例如,string
的大小写敏感性)。
一个例子是,用户在一个购物车 Web 应用程序上搜索一个商品,用户输入的商品名称带有额外的空格,但当我们将其与数据库中的商品进行比较时,我们是否应该认为数据库中的商品与用户输入的带有空格的商品相等。通常,我们将它们视为相等,并将结果显示给用户作为搜索结果,这再次说明了相等性是上下文敏感的。
让我们再举一个例子,考虑以下两个数据库记录。
它们相等吗?从某种意义上说,是的。显然,这些是相同的记录,它们指向相同的饮料项,并且它们具有相同的主键,但有几列的值不同,很明显,第二条记录中的项是在记录更新之后的数据,而第一条是更新之前的数据,所以这说明了相等性带来的另一个概念性问题,当您更新数据时就会出现。您是否关心记录的确切值,还是关心它是否是同一条记录,显然对此没有一个正确的答案。所以再次强调,这取决于您想做什么的上下文!
相等性与比较
NET 处理相等性多种含义的方式非常巧妙。NET 允许每种类型指定其自身测量该类型相等性的单一自然方式。因此,例如,String
类型将其自然相等性定义为两个 string
是否包含完全相同的字符序列,这就是为什么比较两个大小写不同的 string
会返回 false
,因为它们包含不同的字符。这是因为“equality” 不等于 “EQUALITY” ,因为小写和大写是不同的字符。
类型通过一个称为 IEquatable<T> 的泛型接口公开其确定相等性的自然方式是很常见的。String
也实现了这个接口用于相等性。但是,另外,NET 也提供了一个机制,如果您不喜欢类型的自身定义或它不能满足您的需求,您可以使用该机制来插入不同的相等性实现。
这个机制基于所谓的相等性比较器。相等性比较器是一个对象,其目的是使用比较器提供的定义来测试类型的实例是否相等。
相等性比较器实现一个称为 IEqualityComparer<T> 的接口。因此,例如,如果您想在忽略额外空格的情况下比较 string
,您可以编写一个知道如何做到这一点的相等性比较器,然后根据需要使用该相等性比较器而不是相等性运算符。
对于排序比较,事情基本相同。主要区别在于您将使用不同的接口。NET 还提供了一个接口,用于为类型提供进行小于或大于比较的机制,这称为 ICompareable<T>,另外,您可以编写称为比较器的内容,即 IComparer<T>。这可以用来为排序比较定义一个替代实现,我们将在其他帖子中介绍如何实现这些接口。
浮点数的相等性
一些数据类型本质上是近似的。在 .NET 中,您会遇到浮点类型(如 float
、double
或 decimal
)或包含浮点类型作为成员字段的任何类型时会遇到这个问题。让我们看一个例子。
float num1 = 2.000000f;
float num2 = 2.000001f;
Console.WriteLine(num1 == num2);
我们有两个几乎相等的浮点数。那么它们相等吗?它们在最后一位数字上有所不同,这看起来很明显它们不相等,我们在控制台打印相等性结果,所以当我们运行代码时,程序显示 true
。
这个程序得出它们都相等的结论,这与我们通过查看数字的评估结果完全相反,您可能已经猜到了问题所在。计算机只能以一定的精度存储数字,而 float
类型无法存储足够多的有效数字来区分这两个特定数字,反之亦然。请看这个例子。
float num1 = 1.05f;
float num2 = 0.95f;
var sum = num1 + num2;
Console.WriteLine(sum);
Console.WriteLine(sum == 2.0f);
这是一个简单的计算,我们将 1.05
添加到 0.95
。显而易见,当您将这两个数字相加时,您将得到 2.0
的结果,因此我们为此编写了一个小程序,它将这两个数字相加,然后我们检查两个数字的总和是否等于 2.0
。如果我们运行程序,输出结果与我们设想的相反,即总和不等于 2.0
,原因是浮点运算中发生了舍入误差,导致存储的结果非常接近 2
的数字,非常接近以至于 string
表示形式在 Console.WriteLine
中甚至将其显示为 2
,但它仍然不完全等于 2
。
浮点运算中的这些舍入误差导致程序给出的答案与常识推理告诉您的相反。现在,这是浮点数的一个固有难题。舍入误差意味着相等性测试通常会给您错误的结果,而 .NET 对此没有解决方案。建议您不要尝试比较浮点数以确定其相等性,因为结果可能与您预测的不符。这仅适用于相等性,这个问题通常不会影响小于和大于比较。在大多数情况下,比较浮点数以查看一个是否大于或小于另一个没有问题,是相等性带来了问题。
相等性与面向对象原则的冲突
这一点经常让经验丰富的开发人员也感到惊讶,平等性比较、类型安全和良好的面向对象实践之间实际上存在根本冲突。这三者不能很好地协同工作,这通常使得即使在解决了其他问题后,也很难使相等性正确且无错误。
我们不会详细讨论这一点,因为一旦我们开始认真编码,您将很容易理解,我将在单独的帖子中演示,然后您将能够看到问题在您编写的代码中是如何自然产生的。
现在,我们先粗略地给您一个冲突的 ধারণা(概念)。假设我们有一个基类 Animal
,它代表不同的动物,并且有一个派生类,例如 Dog
,它添加了特定于 Dog
的信息。
public class Animal
{
}
public class Dog : Animal
{
}
如果我们希望 Animal
类声明 Animal
实例知道如何检查它们是否与其他 Animal
实例相等,您可能会尝试让它实现 IEquatable<Animal>
。这要求它实现一个接受 Animal
实例作为参数的 Equals()
方法。
public class Animal : IEquatable<animal>
{
public virtual bool Equals(Animal other)
{
throw new NotImplementedException();
}
}
<dp>如果我们希望 Dog
类也声明 Dog
实例知道如何检查它们是否与其他 Dog
实例相等,我们可能必须实现 IEquatable<Dog>
,这意味着它还将实现一个接受 Dog
实例作为参数的类似 Equals()
的方法。
public class Dog : Animal, IEquatable<Dog>
{
public virtual bool Equals(Dog other)
{
throw new NotImplementedException();
}
}
问题就在这里。您可能可以猜到,在设计良好的 OOP 代码中,您会期望 Dog
类覆盖 Animal
类的 Equals()
方法,但麻烦在于 Dog
equals
方法的参数类型与 Animal Equals
方法不同,这意味着它不会覆盖它,如果您不非常小心,这可能会导致一些微妙的错误,您最终会调用错误的 equals
方法,从而返回错误的结果。
通常,唯一的解决方法是牺牲类型安全性,而这正是您在 Object
类型 Equals
方法中所看到的,这是大多数类型实现相等性的最基本方式。
class Object
{
public virtual bool Equals(object obj)
{
}
}
此方法接受 object
类型的一个实例作为参数,这意味着它不是类型安全的,但它可以与继承正确工作。这是一个不太为人所知的问题。有一些博客提供了关于如何实现相等性的错误建议,因为它们没有考虑到这个问题,但这是个问题。我们应该非常小心地设计我们的代码以避免它。
摘要
- C# 在语法上不区分值相等性和引用相等性,这意味着有时很难预测相等性运算符在特定情况下的行为。
- 通常有多种合法比较值的方法。NET 通过允许类型指定它们首选的自然比较相等性方式来解决此问题,还提供了一种编写相等性比较器的机制,该机制允许您为每种类型设置默认相等性。
- 不建议测试浮点值的相等性,因为舍入误差可能会使其不可靠。
- 实现相等性、类型安全和良好的面向对象实践之间存在固有的冲突。
您可能还想阅读
- NET 中相等性的故事(虚拟 Object.Equals、静态 Object.Equals 和 Reference.Equals)– 第二部分
- NET 中相等性的故事(IEquatable<T> 接口简介)– 第三部分
- NET 中相等性的故事(C# 中的 == 运算符和原始类型)– 第四部分
- NET 中相等性的故事(C# 中的 == 运算符和引用类型)– 第五部分
- NET 中相等性的故事(C# 中的 == 运算符和 String 类型)– 第六部分
- NET 中相等性的故事(C# 中的相等性运算符 (==) 和值类型)– 第七部分
- NET 中相等性的故事(C# 中相等性运算符 (==) 与继承和泛型的问题)– 第八部分
- 在 C# 中为值类型实现相等性 - 第九部分
- 在 C# 中为引用类型实现相等性 - 第十部分