C# 中相等性的故事 - 第 6 部分






4.80/5 (16投票s)
本文解释了 Equals 方法和 == 运算符对于 String 类是如何表现不同的
引言
在本篇文章中,我们将重点关注 String
类型,以及相等性是如何为其工作的。您可能知道,对于 string
,相等运算符比较的是值而不是引用,这一点我们在本系列的第一篇文章中已经讨论过。这是因为 String
类重载了 ==
运算符,并且还为 Equals
方法提供了重载实现,使其具有这种行为。
我们将深入探讨 ==
运算符和 Object.Equals
方法在相等性检查方面的行为。
背景
本文是对 C# 中相等性工作原理系列文章的延续,目的是让开发人员更清楚地了解 C# 如何处理不同类型的相等性。
到目前为止我们学到的
以下是我们到目前为止从前面的部分学到的要点
- C# 在语法上不区分值相等和引用相等,这意味着有时很难预测相等运算符在特定情况下的行为。
- 通常有多种合法的方式来比较值。 .NET 通过允许类型指定它们首选的自然相等比较方式来解决这个问题,同时也提供了一种编写相等比较器(equality comparers)的机制,允许您为每种类型设置默认的相等性。
- 不建议测试浮点值是否相等,因为舍入误差会使其不可靠。
- 在实现相等性、类型安全和良好的面向对象实践之间存在固有的冲突。
- .NET 开箱即用地提供了类型的相等性实现,.NET 框架在
Object
类上定义了几个方法,这些方法可供所有类型使用。 - 默认情况下,
virtual
Object.Equals
方法对于引用类型执行引用相等性,对于值类型执行值相等性,但对于值类型,它使用反射,这会增加值类型的性能开销。任何类型都可以override Object.Equals
方法来更改其检查相等性的逻辑,例如,String, Delegate
和Tuple
类就是这样做的,以提供值相等性,尽管它们是引用类型。 Object
类还提供了一个static Equals
方法,当参数可能为null
时可以使用它,除此之外,它的行为与virtual Object.Equals
方法相同。- 还有一个
static ReferenceEquals
方法,它提供了一种保证检查引用相等性的方法。 IEquatable<T>
interface
可以实现一个类型,以提供一个强类型的Equals
方法,该方法还可以避免值类型的装箱。它已为原始数字类型实现,但不幸的是,Microsoft 在 FCL (Framework Class Library) 中并未积极实现其他值类型。- 对于 **值类型**,使用
==
运算符会得到与调用Object.Equals
相同的结果,但==
运算符在 IL (Intermediate Language) 中的底层机制与Object.Equals
不同,因此不会调用为该原始类型提供的Object.Equals
实现,而是会调用一个 IL 指令<a href="https://msdn.microsoft.com/en-us/library/system.reflection.emit.opcodes.ceq(v=vs.110).aspx">ceq </a>
,该指令表示比较当前加载到堆栈上的两个值,并使用 CPU 寄存器执行相等性比较。 - 对于 **引用类型**,
==
运算符和Object.Equals
方法在后台的调用方式不同,这可以通过检查生成的IL
代码来验证。它还使用ceq
指令,该指令执行内存地址的比较。
如果您想阅读到目前为止发布的其他部分,可以在此处阅读
- NET 中相等性的故事 – 第一部分
- NET 中相等性的故事(虚拟 Object.Equals、静态 Object.Equals 和 Reference.Equals)– 第二部分
- NET 中相等性的故事(IEquatable<T> 接口简介)– 第三部分
- NET 中相等性的故事(C# 中的 == 运算符和原始类型)– 第四部分
- C# 中相等性的故事(== 运算符和 C# 中的引用类型) – 第 5 部分
- C# 中相等性的故事(== 运算符和 C# 中的 String 类型) – 第 6 部分
- C# 中相等性的故事(C# 中的相等运算符 (==) 和值类型) – 第 7 部分
- C# 中相等性的故事(C# 中相等运算符 (==) 的继承和泛型问题) – 第 8 部分
- 在 C# 中为值类型实现相等性 (第 9 部分)
- 在 C# 中为引用类型实现相等性 - 第十部分
相等运算符和 String
考虑以下代码片段
class Program
{
static void Main(String[] args)
{
string s1 = "Ehsan Sajjad";
string s2 = String.Copy(s1);
Console.WriteLine(ReferenceEquals(s1, s2));
Console.WriteLine(s1 == s2);
Console.WriteLine(s1.Equals(s2));
Console.ReadKey();
}
}
上面的代码与我们之前看过的非常相似,但这次我们使用的是 String
类型变量。我们创建了一个 string
并将其引用保存在 s1
变量中,下一行,我们创建了该 string
的副本,并将其引用保存在另一个名为 s2
的变量中。
然后我们检查这两个变量是否指向同一个内存位置,即引用相等性,然后在接下来的两行中,我们检查相等运算符和 Equals
重载方法的结果。
现在我们将构建并运行项目,看看它在控制台上的输出。控制台打印的输出如下:
您可以看到 ReferenceEquals
返回了 false
,这意味着这两个 string
是不同的实例,但 ==
运算符和 Equals
方法都返回了 true
,因此很清楚,对于 String
,相等运算符确实测试的是值相等性,而不是引用相等性,这与 Object.Equals
的行为完全一致。
String 相等运算符的幕后
让我们看看相等运算符是如何做到这一点的。现在,让我们检查为此示例生成的 IL 代码。要做到这一点,请打开 **Visual Studio Developer Command Prompt**,要打开它,请转到 **开始菜单** >> **所有程序** >> **Microsoft Visual Studio** >> **Visual Studio Tools** >> **Developer Command Prompt**。
在命令提示符下键入 ildasm
,这将启动 IL 反汇编器,用于查看程序集中包含的 IL 代码。它会在安装 **Visual Studio** 时自动安装,因此您无需执行任何安装操作。
点击 File
菜单打开菜单,然后点击 Open
菜单项,这将弹出窗口以浏览我们要反汇编的可执行文件。
现在导航到您的应用程序可执行文件所在的位置并打开它。
这将以分层形式显示程序集的代码,由于我们有多个类写在程序集中,因此它列出了所有类。
现在,我们要探索的代码位于 Program
类的 Main
方法中,因此导航到 Main
方法并双击它以显示其 IL 代码。
main 的 IL 代码如下所示
从 IL 代码中,我们可以看到它正在调用接受 String
类型参数作为输入的 Equals
方法实现。如果我们深入研究 String.cs 文件中 Equals
重载的 源代码,可以看到其实现如下:
public bool Equals(String value)
{
if (this == null)
throw new NullReferenceException();
if (value == null)
return false;
if (Object.ReferenceEquals(this, value))
return true;
if (this.Length != value.Length)
return false;
return EqualsHelper(this, value);
}
String
类中还有一个 Equals
方法的 重载实现,其实现如下:
public override bool Equals(Object obj)
{
if (this == null)
throw new NullReferenceException();
String str = obj as String;
if (str == null)
return false;
if (Object.ReferenceEquals(this, obj))
return true;
if (this.Length != str.Length)
return false;
return EqualsHelper(this, str);
}
因此,从上面的代码可以看出,这两个方法都包含相同的实现,并且在确定两个 String
类型对象是否相等时执行相同的逻辑。
String 的 Equals 方法重载的 IL 代码
首先,让我们看一下为 s1.Equals(s2)
生成的 IL 代码,没有任何惊喜,因为它正在调用 Equals
方法。但这次它调用的是 IEquatable<string>
的方法实现,该方法接受一个 string
作为参数,而不是调用 Object.Equals
重载,因为编译器为提供的 string
参数找到了更好的匹配。请看下图:
String 相等运算符的 IL 代码
现在让我们检查一下使用相等运算符进行的 string
相等性检查生成的 IL 代码。我们可以看到,现在没有调用 ceq
指令,我们在之前的文章中看到,对于值类型和引用类型,当我们使用 ==
运算符检查相等性时会执行该指令。但对于 String
,我们调用了一个名为 op_equality(string, string)
的新方法,该方法接受两个 string
参数。我们以前从未见过这种方法,那么它到底是什么呢?
答案是,它是 String
类提供的 C# 相等运算符 (==
) 的重载。在 C# 中,当我们定义一个类型时,我们可以选择重载该类型的相等运算符。例如,我们一直在前几个示例中看到的 Person
类,如果为其重载 ==
运算符,其代码将如下所示:
public class Person
{
public int Id { get; set; }
public string Name { get; set; }
public static bool operator == (Person p1, Person p2)
{
bool areEqual = false;
if (Object.Equals(p1, null) && Object.Equals(p2, null)) // note, if use == here it will cause
areEqual = true; // stackoverflowexception due to
else if (Object.Equals(p1,null) || Object.Equals(p2,null)) // infinite recursion
areEqual = false;
else if (p1.Id == p2.Id)
areEqual = true;
else
areEqual = false;
return areEqual;
}
}
因此,上面的代码非常简单。我们声明了一个运算符重载,它将是一个 static
方法,但值得注意的是,方法名是 operator ==
。声明运算符重载与 static
方法的相似之处并非巧合,实际上,编译器将其编译为 static
方法,因为我们知道并且之前已经讨论过,IL (Intermediate Language)
没有运算符、事件等概念,它只理解字段和方法,因此运算符重载只能作为方法存在,正如我们在上面的 IL 代码中所观察到的。编译器将重载的运算符代码转换为一个特殊的 static
方法,称为 op_Equality()
。
首先,它检查传递的实例中是否有任何一个为 null
,那么它们不相等。然后,我们看到如果两者都为 null
,那么显然这两个引用相等,它将返回 true
。接下来,它检查两个引用的 Id
属性是否相等,如果相等,则它们相等,否则不相等。
通过这种方式,我们可以根据业务需求为自定义类型定义自己的实现。正如我们之前讨论的,两个对象的相等性完全取决于应用程序的业务流程,因此两个对象在一个人看来可能相等,而在另一个人看来则不相等,这取决于他们的业务逻辑。
这使得一件事变得清晰:**Microsoft** 为 String
类提供了 ==
**运算符重载**,我们甚至可以通过使用 **Go to Definition** 在 Visual Studio 中查看 String
类的源代码,它看起来是这样的:
在上图快照中,我们可以看到有两个运算符重载,一个是相等运算符,另一个是不相等运算符,它们的工作方式完全相同,但输出是相等运算符的否定。需要注意的一点是,如果您为某个类型重载 ==
运算符的实现,您还需要重载 !=
运算符的实现才能使您的代码编译通过。
摘要
- 我们现在对 C# 中 **引用类型** 的相等性运算符的作用有了足够的了解。我们需要牢记以下几点:
- 如果被比较的类型有相等运算符的重载,它就使用该运算符作为
static
方法。 - 如果引用类型没有运算符重载,相等运算符将使用
ceq
指令比较内存地址。
- 如果被比较的类型有相等运算符的重载,它就使用该运算符作为
- 需要注意的一点是,**Microsoft** 确保
==
运算符重载和Object.Equals
重载始终产生相同的结果,尽管它们实际上是不同的方法。因此,这一点在我们开始实现自己的Equals
重载时非常重要。我们还应该注意相等运算符,否则我们的类型在使用Equals
重载和相等运算符时将产生不同的结果,这对类型的消费者来说将是个问题。我们将在另一篇文章中介绍如何正确地重载Equals
方法。 - 如果我们更改了某个类型的相等性工作方式,我们需要确保同时为
Equals
重载和==
运算符重载提供实现,以便它们都产生相同的结果,这是显而易见的,否则对于使用我们实现的类型的其他开发人员来说,这会很令人困惑。
您可能还想阅读
- NET 中相等性的故事 – 第一部分
- NET 中相等性的故事(虚拟 Object.Equals、静态 Object.Equals 和 Reference.Equals)– 第二部分
- NET 中相等性的故事(IEquatable<T> 接口简介)– 第三部分
- NET 中相等性的故事(C# 中的 == 运算符和原始类型)– 第四部分
- C# 中相等性的故事(== 运算符和 C# 中的引用类型) – 第 5 部分
- C# 中相等性的故事(== 运算符和 C# 中的 String 类型) – 第 6 部分
- C# 中相等性的故事(C# 中的相等运算符 (==) 和值类型) – 第 7 部分
- C# 中相等性的故事(C# 中相等运算符 (==) 的继承和泛型问题) – 第 8 部分
- 在 C# 中为值类型实现相等性 (第 9 部分)
- 在 C# 中为引用类型实现相等性 - 第十部分