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

igualdad.NET 故事 - 第二部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (20投票s)

2016年7月2日

CPOL

15分钟阅读

viewsIcon

24937

在本文中,我们将了解 .NET 如何巧妙地处理值类型和引用类型的相等性和比较。

引言

在本文中,我们将了解 .NET 如何开箱即用地处理相等性和比较。这意味着您将能够理解 .NET 如何处理我们在上一篇文章中讨论的一些问题。

如果您还记得我上一篇文章,.NET Framework 在 Object 类中提供了 4 种用于相等性检查的方法。每种方法都针对不同的场景,但它们的目的都是检查两个对象的相等性。在本文中,我们将重点关注四种方法中的以下三种:

  1. 虚拟 Object.Equals
  2. 静态 Object.Equals
  3. 静态 Object.ReferenceEquals

我们将从详细了解虚拟的 Object.Equals 方法开始。这是 .NET 中最重要的相等性检查机制,因为它是任何类型都能说明自身相等性的方式。我们将看到此方法如何开箱即用地为大多数引用类型提供引用相等性,为所有值类型提供值相等性。

我们还将将其与名称相同的 静态方法Object.Equals() 进行比较,该方法在要检查相等性的实例可能为 null 时更健壮。

我们还将了解如何使用 静态方法Object.ReferenceEquals 来确保相等性检查是基于实例的引用而不是实例的值进行的。

阅读完本文后,我希望您能对 .NET 中的相等性有一个很好的理解。

虚拟 Object.Equals() 方法

正如我们在上一篇文章中讨论过的,在 .NET 中,有多种比较相等性的方法,但 .NET 为此提供的最基本的方法是定义在 System.Object 类型中的虚拟 Object.Equals() 方法。

为了看到这个方法在实际中的应用,我们将创建一个代表某种人的类。我们的类只包含一个 string 字段,用于存储人的姓名,以及一个强制设置姓名字段值的构造函数。

    public class Person
    { 
        private string _name;
 
        public string Name 
        { 
            get 
            { 
                return _name; 
            } 
        }
 
        public Person(string name)
        {
            _name = name;
        }
 
        public override string ToString()
        {
            return _name;
        } 
    }

现在,我们将编写 Main 方法来使用此类型。

    static void Main(String[] args)
    {
        Person p1 = new Person("Ehsan Sajjad");
        Person p2 = new Person("Ahsan Sajjad");
            
        Console.WriteLine(p1.Equals(p2));
    }  

正如您在 Main 中看到的,我们创建了 Person 类的两个实例,并将不同的值作为参数传递给构造函数,然后在下一行使用Object.Equals 方法检查相等性。equal 方法返回一个布尔值,如果两个项相等则返回 true,如果不相等则返回 false。我们将相等性比较的结果写入控制台以查看输出。

您可能会从代码中期望这两个实例不相等,因为 Ehsan Sajjad Ahsan Sajjad 不相等。它们的字符顺序不同,当然,如果我们运行代码,将在控制台看到 false 作为输出。因此,Equals() 在此处似乎工作正常,如果您注意到,我们不必在 Person 类定义中做任何事情来实现这一点。Equals 方法由 System.Object 提供,因此它对所有类型都可用,因为所有类型最终都派生自 System.Object

顺便说一句,我怀疑有些人可能会看着这段代码并认为 p1.Equals(p2) 这行代码并不是我们通常为了检查相等性而编写的代码,如果我们想检查相等性,我们只需要写 p1 == p2,但重点是我们需要了解 .NET 中的相等性是如何工作的,而起点就是 Equals 方法。

== 运算符和 Equals 方法

如果您在代码中写 == ,您正在使用 C# 相等运算符,这是 C# 语言提供的一个很好的语法便利,可以使代码更具可读性,但它实际上并不是 .NET Framework 的一部分。.NET 没有运算符的概念,它使用方法。如果您想了解 .NET 中的相等性是如何工作的,那么我们需要从 .NET Framework 理解的事物开始。因此,在这篇文章中,我们的大部分代码将只使用 .NET 方法,这样您就可以看到它是如何工作的,这意味着您看到的一些代码 可能看起来不自然 ,但我们将在另一篇文章中讨论 ==(C# 相等运算符)。

现在,让我们回到有趣的部分,即代码,我们将在 Main 程序中声明另一个 Person 实例。

这个新实例 p3 在构造函数中传递的值与 p1 相同,即 Ehsan Sajjad,那么如果您尝试使用 Equals 方法比较 p1p3,您认为会发生什么?让我们尝试一下看看会发生什么。

    static void Main(String[] args)
    {
        Person p1 = new Person("Ehsan Sajjad");
        Person p2 = new Person("Ahsan Sajjad");
        Person p3 = new Person("Ehsan Sajjad");
            
        Console.WriteLine(p1.Equals(p3));
    } 

这也返回 false,这两个实例 p1p3 不相等,原因是基础 Object.Equals 方法评估引用相等性,其实现测试两个变量是否引用同一个实例。在这种情况下,这对我们来说显而易见,p1p3 具有完全相同的值,两个实例都包含相同的数据,但 Equals 方法并不关心这一点,它只关心它们是相同的实例还是不同的实例,因此它返回 false 告诉我们它们不相等。

正如我们在本文和上一篇文章中所讨论的,EqualsObject 类型中的一个 虚拟方法,这意味着我们可以覆盖它。如果我们希望 Equals 方法比较 Person 实例的值,我们可以覆盖 Equals 方法 并为如何比较两个 Person 实例以判断其相等性定义自己的实现。这没有什么不寻常的,Equals 是一个正常的 虚拟方法。我们暂时不覆盖它。如果您想坚持良好的编码实践,当您覆盖 Object.Equals 方法时,您需要做一些其他事情。我们稍后将看到如何做到这一点。对于这篇文章,我们将只坚持 Microsoft 开箱即用的功能

字符串的 Equals 方法实现

有几个 Microsoft 已经重写了 Object.Equals 方法的引用类型,以便比较值而不是引用。其中最著名的可能是,当然也是最重要的是 String。我们将用一个小程序来演示这一点。

    static void Main(String[] args)
    {
        string s1 = "Ehsan Sajjad";
        string s2 = string.Copy(s1);
            
        Console.WriteLine(s1.Equals((object)s2));
    }

在此程序中,我们初始化一个 string 并将其引用存储在 s1 中。然后,我们创建另一个变量 s2,它包含相同的值,但我们通过创建 s1 值的副本来初始化 s2string.Copy 方法的名称非常具有描述性,它创建并返回 string 的副本,然后我们使用 Equals 方法来比较它们。

您可以看到我们正在显式地将 Equals 方法的参数类型转换为 object 类型,这显然您在生产代码中不希望这样做。我们之所以这样做是因为要确保调用 重写的 Object.Equals() 的实现,因为 string 定义了多个 Equals 方法其中一个是强类型为 string,即它 接受 string 作为参数。如果我们不将其转换为 object,那么编译器在解析重载时会认为强类型方法是更好的参数,并会调用该方法。这显然 在我们正常编程时更好,而且两种方法实际上都会做同样的事情。 但是我们明确地想展示 object.Equals 重写是如何工作的,所以我们需要将参数转换为 object,以告知编译器避免强类型重载并使用 object 类型重写。

如果我们运行上面的代码,我们将看到它返回 true。Microsoft 为 string 类型提供的重写会比较两个 string 实例的内容,以检查它们是否包含完全相同的字符,顺序是否相同,如果它们是,则返回 true,否则返回 false,即使它们不是同一个实例。

Microsoft 为其定义的引用类型中,重写了 Equals 方法以比较值的类型并不多,除了 String 类型之外,您必须了解的另外两种类型是 Delegate Tuple。调用这些类型的 Equals 也会比较值。这些是特例。所有其他引用类型的 Equals 始终会执行引用相等性检查。

Equals 方法和值类型

现在我们将看看 Equals 方法如何处理值类型。我们将使用与文章开头相同的示例(Person 类),但将其从类更改为 struct ,以查看值类型的情况。

    public struct Person
    { 
        private string _name;
 
        public string Name 
        { 
            get 
            { 
                return _name; 
            } 
        }
 
        public Person(string name)
        {
            _name = name;
        }
 
        public override string ToString()
        {
            return _name;
        } 
    }

现在,如果我们再次运行相同的程序,您认为会发生什么?正如我们所知,struct 存储在堆栈上,它们通常没有引用,除非我们对它们进行装箱,这就是为什么它们被称为值类型而不是引用类型。

    static void Main(String[] args)
    {
        Person p1 = new Person("Ehsan Sajjad");
        Person p2 = new Person("Ahsan Sajjad");
        Person p3 = new Person("Ehsan Sajjad");
            
        Console.WriteLine(p1.Equals(p2));
        Console.WriteLine(p1.Equals(p3));
    }

因此,正如我们所知,Object.Equals 的实现对于引用类型执行的是引用比较,但在这种情况下,您可能会认为比较引用没有意义,因为 struct 是值类型。

所以,让我们运行程序看看它在控制台上打印什么。

您可以看到这次结果不同了,对于第二种情况,它说两个实例是相等的。这正是您在比较 Personp1p3 两个实例的值以查看它们是否相等时所期望的结果,而且这确实发生了。但是,如果我们查看 Person 类型定义,我们没有为重写 Object 类的 Equals 方法添加任何代码,这意味着在此类型中没有编写任何内容来告诉框架如何比较 Person 类型实例的值以判断它们是否相等。

.NET 已经知道所有这些了,它知道如何做到这一点。.NET Framework 在我们无需任何努力的情况下就弄清楚了如何判断 p1p3 是否具有相等的值。这是如何发生的?实际上发生的是,正如您可能已经知道的,所有 struct 类型都继承自 System.ValueType,而 System.Object 最终派生自 System.Object

System.ValueType 本身重写了 System.Object Equals 方法,重写的内容是它遍历值类型中的所有字段,并对每个字段调用 Equals,直到找到任何字段值不同或所有字段都遍历完毕。如果所有字段都相等,那么它就认为这两个值类型实例是相等的。换句话说,值类型重写了 Equals 方法实现,并表示如果它们的所有字段都具有相同的值,那么这两个实例就相等,这是非常合理的。在上面的例子中,我们的 Person 类型只有一个字段,即 Name 属性的后备字段,它的类型是 string,我们已经知道调用 stringEquals 会比较值,并且我们程序的上述结果证明了我们在这里的陈述。这就是 .NET 如何很好地为值类型提供 Equals 方法的行为。

值类型的性能开销

不幸的是,.NET Framework 提供的这种便利是有代价的。System.ValueType Equals 实现的工作方式是使用反射。当然,如果仔细想想,它不得不这样做。因为 System.ValueType 是一个基类型,它不知道您将如何从中派生,因此找到我们定义的类型(在此例中为 Person)中有哪些字段的唯一方法是使用反射,这意味着性能会很差。

值类型的推荐方法

推荐的方法是为自己的值类型重写 Equals 方法。我们稍后将看到如何提供一种方法,使其运行得更快。事实上,您将看到 Microsoft 已经为框架附带的许多内置值类型(我们每天在代码中使用)这样做了。

静态 Equals 方法

使用 虚拟 Equals 方法检查相等性时有一个问题。如果您要比较的两个引用之一或两者都为 null,会发生什么?让我们看看当我们将 null 作为参数调用 Equals 方法时会发生什么。让我们为此修改现有示例。

    static void Main(String[] args)
    {
        Person p1 = new Person("Ehsan Sajjad");
                        
        Console.WriteLine(p1.Equals(null));
    }

如果我们编译并运行,它返回 false,而且应该是这样,也完全有道理,因为很明显 null 不等于非 null 实例,这是 .NET 中相等性的原则,即 Null 永远不应该评估为等于非 Null 值。

现在,让我们反过来看看如果 p1 变量为 null 会发生什么,那么我们就遇到了问题。假设我们没有这段硬编码的实例创建代码。相反,这个变量是从使用该程序集的某个客户端代码传递的参数,我们不知道其中任何一个值是否为 null
如果 p1 被传递为 null,执行 Equals 方法调用将抛出 NullReferenceException,因为您不能针对 null 实例调用实例方法。
静态 Equals 方法就是为了解决这个问题而设计的,所以当你不确定一个对象是否可能为 null 时,我们可以使用这个方法,我们可以这样使用它。

    Console.WriteLine(object.Equals(p1,null));

现在我们可以放心地使用,而无需担心任何一个实例引用是否为 null。您可以通过在两种情况下运行来测试它,您会发现它工作正常,当然,如果其中一个引用变量为 null,它将返回 false

有些人可能会想,如果传递给 静态 Equals 方法的两个参数都为 null,会发生什么?如果您添加以下行来测试它。

    Console.WriteLine(object.Equals(null,null));

您将看到在这种情况下它返回 true。在 .NET 中,null 始终等于 null,因此测试 null 是否等于 null 应该始终评估为 true

如果我们深入研究 静态 Equals 方法的实现,我们会发现它的实现非常简单。如果您查看 Object 类型的源代码,其逻辑如下。

public static bool Equals(object x, object y)
{
    if (x == y) // Reference equality only; overloaded operators are ignored
    {
        return true;
    }
    if (x == null || y == null) // Again, reference checks
    {
        return false;
    }
    return x.Equals(y); // Safe as we know x != null.
}

If 首先检查两个参数是否引用同一个实例,即 == 运算符会做什么。此检查将评估为 true,导致方法在两个参数都为 null 时返回 true。下一个 if 块将在其中一个参数为 null 而另一个不是时返回 false。最后,如果 if 控制到达 else 块,那么我们就知道两个参数都指向某个实例,因此我们将调用 虚拟 Equals 方法。

这意味着 静态 Equals 方法的结果将始终与 虚拟方法相同,除了它首先检查 null。因为 静态方法调用 虚拟方法,如果我们重写了 虚拟 Equals 方法,我们的重写将自动被 静态方法调用,这一点很重要,因为我们希望 静态虚拟方法都表现一致。

ReferenceEquals 方法

ReferenceEquals 的用途与我们上面讨论的两个 Equals 方法略有不同。它存在于我们特别想确定两个变量是否引用同一个实例的情况下。您可能会想,Equals 方法也检查引用相等性,那么为什么需要一个单独的方法呢?

这两个方法确实会检查引用相等性,但不能保证它们这样做,因为 虚拟 Equals 方法可以被重写以比较实例的值而不是引用。

因此,对于未重写 Equals 方法的类型,ReferenceEquals 将与 Equals 产生相同的结果。例如,以我们上面使用的 Person 类示例为例。但是,对于重写了 Equals 方法的类型,可能会产生不同的结果。例如,String 类。

让我们修改我们之前在本帖中使用的 string 类示例来演示这一点。

    static void Main(String[] args)
    {
        string s1 = "Ehsan Sajjad";
        string s2 = string.Copy(s1);
            
        Console.WriteLine(s1.Equals((object)s2));
        Console.WriteLine(ReferenceEquals(s1,s2));
    }

如果我们运行这个示例,我们将看到第一个 Equals 调用返回 true,正如之前一样,但是 ReferenceEquals 方法调用返回 false,这是为什么?

它表明两个 string 变量是不同的实例,即使它们包含相同的数据。如果您回想一下我们上一篇文章中的讨论,String 类型重写了 Equals 方法以比较两个实例的值,而不是引用。

您知道在 C# 中,静态方法不能被重写,这意味着您永远无法改变 ReferenceEquals 方法的行为,这很有意义,因为它总是需要进行引用比较。

您可能还想阅读

摘要

  • 我们了解了 .NET 如何开箱即用地为类型提供相等性实现。
  • 我们看到 .NET Framework 在 Object 类上定义了一些方法,这些方法对所有类型都可用。
  • 默认情况下,虚拟 Object.Equals 方法通过使用反射对引用类型执行引用相等性,对值类型执行值相等性,这对于值类型来说是性能开销。
  • 任何类型都可以重写 Object.Equals 方法来更改其检查相等性的逻辑。例如,StringDelegateTuple 通过提供值相等性来做到这一点,尽管它们是引用类型。
  • Object 提供了一个 静态 Equals 方法,当有可能一个或两个参数可能为 null 时可以使用它,除此之外,它的行为与 虚拟 Object.Equals 方法相同。
  • 还有一个 静态 ReferenceEquals 方法,它提供了一种保证检查引用相等性的方法。
© . All rights reserved.