65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (16投票s)

2016年10月2日

CPOL

11分钟阅读

viewsIcon

38061

本文解释了 Equals 方法和 == 运算符对于 String 类是如何表现不同的

引言

在本篇文章中,我们将重点关注 String 类型,以及相等性是如何为其工作的。您可能知道,对于 string,相等运算符比较的是值而不是引用,这一点我们在本系列的第一篇文章中已经讨论过。这是因为 String 类重载了 == 运算符,并且还为 Equals 方法提供了重载实现,使其具有这种行为。

我们将深入探讨 == 运算符和 Object.Equals 方法在相等性检查方面的行为。

背景

本文是对 C# 中相等性工作原理系列文章的延续,目的是让开发人员更清楚地了解 C# 如何处理不同类型的相等性。

到目前为止我们学到的

以下是我们到目前为止从前面的部分学到的要点

  • C# 在语法上不区分值相等和引用相等,这意味着有时很难预测相等运算符在特定情况下的行为。
  • 通常有多种合法的方式来比较值。 .NET 通过允许类型指定它们首选的自然相等比较方式来解决这个问题,同时也提供了一种编写相等比较器(equality comparers)的机制,允许您为每种类型设置默认的相等性。
  • 不建议测试浮点值是否相等,因为舍入误差会使其不可靠。
  • 在实现相等性、类型安全和良好的面向对象实践之间存在固有的冲突。
  • .NET 开箱即用地提供了类型的相等性实现,.NET 框架在 Object 类上定义了几个方法,这些方法可供所有类型使用。
  • 默认情况下,virtual Object.Equals 方法对于引用类型执行引用相等性,对于值类型执行值相等性,但对于值类型,它使用反射,这会增加值类型的性能开销。任何类型都可以 override Object.Equals 方法来更改其检查相等性的逻辑,例如,String, DelegateTuple 类就是这样做的,以提供值相等性,尽管它们是引用类型。
  • 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 指令,该指令执行内存地址的比较。

如果您想阅读到目前为止发布的其他部分,可以在此处阅读

相等运算符和 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 重载和 == 运算符重载提供实现,以便它们都产生相同的结果,这是显而易见的,否则对于使用我们实现的类型的其他开发人员来说,这会很令人困惑。

您可能还想阅读

© . All rights reserved.