原则6:理解几个不同相等概念的关系

优质
小牛编辑
136浏览
2023-12-01

当你定义类型(类或结构体)时,你同时要定义类型的相等。 C# 提供四种不同的函数决定两个不同对象是否“相等”:

public static bool ReferenceEquals (object left, object right);
public static bool Equals (object left, object right);
public virtual bool Equals(object right); 
public static bool operator ==(MyClass left, MyClass right);

C# 语言运行你实现这四个函数的自己的版本。但不意味着你需要这么做。你不需要重定义前面两个静态函数。你经常会创建你自己实例方法 Equals() 去定义你定义类型的语义,有时也会重写操作符==() ,尤其是值类型。此外,这四个函数是有关联的,所以你改变其中一个,可能会影响其他函数的行为。所以你需要完全测试这个四个函数。但是不担心,你可以简化它。

当然,这四个方法不是判断是否相等的唯一选择。还可以通过类型去实现 IEquatable<T> 重写 Equals() 。如果类型是值类型的需要实现 IStructuralEquality 接口。这就说,总共有6中不同的方法去表达相等。

和 C# 中复杂的元素一样,这个也遵守这个事实: C# 运行你同时创建值类型和引用类型。两个引用类型变量当它们引用相同的对象时相等。好像引用它们的ID一样。两个值类型的变量当它们是相同的类型而且包含相同的内容时才相等。这就是为什么对这些方法都进行相等测试。

我们先从两个不会修改的方法开始。 Object.ReferenceEquals() 当两个变量指向相同对象——也就是说,两个变量含有相同对象的ID时返回 true 。是否比较值类型或引用类型,这个方法总是测试对象ID,而不是对象的内容。也就是说,当你使用测试两个值类型变量相等时, ReferenceEquals() 会返回 false 。即使你拿一个值类型变量和自己比较, ReferenceEquals() 也会返回 fasle 。这是因为封箱操作,你可以在原则45找到相关内容。

 int i = 5; 
int j = 5; 
if (Object.ReferenceEquals(i, j))
    Console.WriteLine("Never happens."); 
else
    Console.WriteLine("Always happens.");
if (Object.ReferenceEquals(i, i))
    Console.WriteLine("Never happens."); 
else
    Console.WriteLine("Always happens.");

你绝不需要重定义 Object.ReferenceEquals() ,因为它已经支持了它的功能了:测试两个不同变量的对象ID。

第二个你不要重新定义的是静态方法 Object.Equals() 。当你不知道两个参数的运行时参数是,用这个方法可以测试两个变量是否相等。记住 System.Object 是 C# 所有类型的最终基类。无论什么时候,你比较的两个变量都是 System.Object 的实例。值类型和引用类型都是 System.Object 的实例。来看下当不知道类型是,这个方法是如何判断两个变量是否相等的,相等是否依赖类型?答案很简单:这个方法即使把职责委托给其中一个正在比较的类。静态 Object.Equals() 方法的是像下面这样实现的:

public static new bool Equals(object left, object right) 
{
    // Check object identity 
    if (Object.ReferenceEquals(left, right) )
        return true; 
    // both null references handled above
    if (Object.ReferenceEquals(left, null) || Object.ReferenceEquals(right, null)) 
        return false;
    return left.Equals(right); 
}

上面实例代码引入一个还没有介绍的方法:即,实例的 Equals() 方法。我将会详细介绍,但是我还没打算终止对静态 Equals() 的讨论。我希望你能明白静态 Equals() 方法使用了左参数的实例 Equals() 方法来判断两个对象是否相等。

和 ReferenceEquals() 一样,你不需要重载或重定义自己版本的静态 Object.Equals() 方法因为它已经做了它需要做的事情:当我们不知道运行时类型时,决定两个对象是否相等。因为静态 Equals() 把比较委托给左边参数的实例 Equals() 方法,就是利用这个规则来处理类型的。

既然你明白了为什么不需要重定义静态 ReferenceEquals() 和静态 Equals() 方法。接下来就讨论下你需要重写的方法。但是首先,让我们简要来讨论相等的关系的数学特性。你需要保证你定义的和实现的方法要和其他程序员的期望是一致的。这几意味着你需要关心数学的相等关系:相等是自反的,对称的,可传递的。自反性就是说任何对象都和自身相等。无论类型是什么 a == a 总是 true 的。对称型即与比较的次序是没有关系的:如果 a == b 是 true ,b == a 同样也是 true 的。如果 a == b 是 false , b == a 也是 false 。最后一个性质就是如果 a == b 而且 b == c 都是 ture ,那么 a == c 必须是 true 的。这就是传递性。

现在是时候讨论实例的 Object.Equals() 函数了,包括什么时候和怎么样重写它。当你发现默认的 Equals() 的行为和你的类型不一致时,你就需要创建自己的实例 Equals() 版本。 Ojbect.Equals() 方法使用对象的ID来决定两个变量是否相等。默认的 Object.Equals() 函数和 Object.ReferenceEquals() 的表现是一样的。等等——值类型是不同的, System.ValueType 没有重写 Object.Equals() 。记住 ValueType 是所有值类型(使用 struct 关键字)的基类。两个值类型变量当它们类型相同和有相同的内容时是相等的。 ValueType.Equals() 实现就是这个行为。不好的是, ValueType.Equals() 没有一个很高效的实现。 ValueType.Equals 是所有值类型的基类。为了提供正确的行为,你必须在不知道对象的运行时类型的情况下比较子类的所有成员变量。在 C# ,会使用反射来做。你可以查看下原则43。反射有很多不足的地方,尤其当性能是目标时。 相等是在程序中会被频繁调用的集成操作之一,所以性能是值得考虑的。在大多数情况下,你可以重写一个更快的值类型 Equals() 。对于值类型的建议是很简单的:当你创建一个值类型,总是重写 ValueType.Equals() 。

只有当你想要定义引用类型的语义是,你需要重写实例 Equals() 函数。 .NET 框架的一些类都是使用值类型而不是引用类型来判断是否相等。两个 string 对象相等当它们的内容是一样的。两个 DataRowView 对象相等当它们指向同一 DataRow 。关键就是要你的类型服从值语义(比较内容)而不是引用语义(比较对象的ID),你应该重写你自己的实例 Equals() 。

既然你知道什么时候去重写你自己的 Object.Equals() ,你需要命名怎么样实现它。值类型的相等关系封箱有很多补充,在原则45会被讨论。对于引用类型,你的实例方法需要保留之前的行为,避免给使用者惊讶。当你重写 Equals() ,你的类型要实现 IEquatable<T> 。对这点,我会解释的更多一点。这里标准模式只是重写了 System.Object.Equals 。高亮的代码是改为实现 IEquatable<T>。

public class Foo : IEquatable<Foo> 
{
    public override bool Equals(object right) 
    {
        // check null: 
        // this pointer is never null in C# methods. 
        if (object.ReferenceEquals(right, null))
            return false;
        if (object.ReferenceEquals(this, right)) 
            return true;
        // Discussed below. 
        if (this.GetType() != right.GetType())
            return false;
        // Compare this type's contents here:
        return this.Equals(right as Foo); 
    }
    #region IEquatable<Foo> Members
    public bool Equals(Foo other) 
    {
        // elided. 
        return true;
    } 
    #endregion
}

首先, Equals() 不能抛出异常——这个没有任何意义。两个变量比较只有相等和不相等,没有其他结果。像 null 引用或错误参数类型的所有错误情况都应该返回 false 。现在,我们详细分析下这个方法的代码,命名为什么每一步的检查和哪些检查是可以被遗漏的。第一个检查右边蚕食是否为 null 。没有任何在 this 的引用上没有任何检查。 C# 中,它是一定不会为 null 的。 CLR 在通过 null 引用调用实例方法会抛出异常。下一个检查是否两个对象的引用是否一样,测试两个对象的ID。这是一个非常有效的测试,内容要相同对象的ID一定要相同。

在下一个检查要比较的两个对象是否是同一个类型。正确的形式是非常重要的。首先,主要是不是假定这就是 类 Foo ;而是调用 this.GetType() 。实际类可能是 Foo 的子类。第二,代码检查被比较对象的真正类型。这还不足以保证你可以把右边参数转换为当前类型。这个测试会导致两个微妙的错。考虑下面有关继承结构的例子:

public class B : IEquatable<B> 
{
    public override bool Equals(object right) 
    {
        // check null:
        if (object.ReferenceEquals(right, null)) 
            return false;
        // Check reference equality: 
        if (object.ReferenceEquals(this, right))
            return true;
        // Problems here, discussed below. 
        B rightAsB = right as B; 
        if (rightAsB == null)
            return false;
        return this.Equals(rightAsB); 
    }
    #region IEquatable<B> Members
    public bool Equals(B other) 
    {
        // elided 
        return true;
    }
    #endregion 
}
public class D : B, IEquatable<D> 
{
    // etc. 
    public override bool Equals(object right) 
    {
        // check null: 
        if (object.ReferenceEquals(right, null))
            return false;
        if (object.ReferenceEquals(this, right)) 
            return true;
        // Problems here.
        D rightAsD = right as D;
        if (rightAsD == null) 
            return false;
        if (base.Equals(rightAsD) == false) 
            return false;
        return this.Equals(rightAsD); 
    }
    #region IEquatable<D> Members 
    public bool Equals(D other) 
    {
        // elided. 
        return true; // or false, based on test
    } 
    #endregion
}
//Test:
B baseObject = new B();
D derivedObject = new D();
// Comparison 1\. 
if (baseObject.Equals(derivedObject))
    Console.WriteLine("Equals"); 
else
    Console.WriteLine("Not Equal");
// Comparison 2\. 
if (derivedObject.Equals(baseObject))
    Console.WriteLine("Equals"); 
else
    Console.WriteLine("Not Equal");

在任何可能的情况下,你都希望看到相等或不想的打印两次。因为一些错误,这已经不是前面的代码了。第二个比较不会返回 true 。基类 B 的对象,不会被转换为 D 的对象。然后第一个比较可能会评估为 true 。子类 D 可以被隐式转换为 B 。如果右边参数的 B 成员可以匹配左边参数的 B 成员, B.Equals() 认为两个对象是相等的。即使两个对象是不同的类型,你的方法还是认为他们是想的的。这就违背了相等的对称性。这是因为在类的继承结构中自动转换的发生。

如果这样写:把类型 D 对象显式转换为 B :

baseObject.Equals(derived);

derivedObject.Equals() 方法总是返回 false 。如果你不精确检查对象的类型,你会很容易进入这种情况,比较对象的次序会成为一个问题。

上面所有的例子中,重写 Equals() ,还有另外一种方法。重写 Equals() 意味着你的类型应该实现 IEquatable<T> 。IEquatable<T> 包含一个方法: Equals(T other) 。实现 IEquatable<T> 意味着你的类型要支持一个类型安全的相等比较。如果你认为 Equals() 只有左右两个参数的类型都相等才返回 true 。 IEquatable<T> 很简单地让编译器捕捉多次两个对象不相等。

还有另外一种方法重写 Equals() 。只有当基类的版本不是 System.Object 或 System.ValueType ,你就应该调用基类的方法。前面的例子,类 D调用 Equals() 就是在基类B中定义的。然而,类 B 调用的不是 baseObject.Equals() 。System.Object 的版本只有当两个参数引用同一个对象才会返回 true 。这并不是你想要的,或者你应该没有在第一个基类中重写自己的方法。

原则就是这样,如果你创建一个值类型你就要重写 Equals() ,如果是引用类型你不想遵循 System.Object 的引用语义就要重写 Equals() 。当你重写你自己的 Equals() ,你应该遵循上面列出的要点实现。重写 Equals() 意味着你要重写 GetHashCode() 查看原则7。

我们几乎完成了本原则。 操作符 ==() 是简单的。无论什么时候你创建一个值类型,重定义操作符 ==() 。原因和实例 Equals 函数一样。默认的版本使用反射区比较两个值类型的内容。这是比任何你实现的都更低效的,所以你要自己重写。遵循原则46的建议避免封箱当比较两个值类型时。

注意的是我们没有说当你重写实例Equals() 你就应该重写操作符 ==() 。我说的是你应该重写操作符 ==() 当你创建值类型的时候。你几乎不用重写操作符 ==() 当你创建引用类型是。 .NET 框架期望操作符 ==() 所有引用类型遵循引用语义。

最后,我们说下 IStructuralEquality ,System.Array 和 Tuple<> 泛型实现这个接口。它让这些类型实现值语义而不强制比较时的值类型。你会对创建一个值类型是否实现 IStructuralEquality 留有疑惑。这个只有在创建轻量级类型需要。实现 IStructuralEquality 声明一个类是可以被组合成一个基于值语义的大对象。

C# 提供了很多方式去测试是否相等。但是你需要考虑提供自定义的它们其中的两种,支持类似的接口。你不需要重写静态 Object.ReferenceEquals() 和静态Object.Equals() 因为它们能提供正确的参数,尽管不知道它们的运行时类。你总是要重写实例 Equals() 和操作符 ==() 对于值类型可以提供性能。当你想要引用类型相等而不是对象ID相等,你需要重写实例 Equals() 。当你重写 Equals() ,你就应该是实现 IEquatable<T> 。很简单,是不是?

小结:

这篇的内容是我们最熟悉的,翻译的有点匆忙,平时虽然用的很多,但是都没有深入研究过,还是有很多细节很受用的,比如值类型效率问题等。一个星期翻译六篇,从量上看,还是比较满意的,虽然每天都折腾的很晚,今天虽然是周末,我也没有出去过,不过今天的效率太高了。但质还是不尽如人意的,至少没有一点原创的感觉,以后多做回顾和修改。