Effective C# 原则9:明白几个相等运算之间的关系

JerryXia 发表于 , 阅读 (1,813)

明白ReferenceEquals(), static Equals(), instance Equals(),
和运算行符==之间的关系。

当你创建你自己的类型时(不管是类还是结构),你要定义类型在什么情况下是相等的。C#提供了4个不同的方法来断定两个对象是否是相等的:

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 );

这种语言让你可以为上面所有的4种方法创建自己的版本。But just because you
can doesn't mean that you
should.你或许从来不用重新定义前面两个方法。你经常遇到的是创建你自己实例的Equals()方法,来为你的类型定义语义;或者你偶而重载运==运算符,但这只是为了考虑值类型的性能。幸运的是,这4个方法的关系,当你改变其中一个时,会影响到其它的几个。是的,须要4个方法来完整的测试对象是否完全相等。但你不用担心,你可以简单的搞定它们。

和C#里其它大多数复杂元素一样,这个(对相等的比较运算)也遵守这样的一个事实:C#充许你同时创建值类型和引用类型。两个引用类型的变量在引用同一个对象时,它们是相等的,就像引用到对象的ID一样。两个值类型的变量在它们的类型和内容都是相同时,它们应该是相等的。这就是为什么相等测试要这么多方法了。

我们先从两个你可能从来不会修改的方法开始。Object.ReferenceEquals()在两个变量引用到同一个对象时返回true,也就是两个变量具有相同的对象ID。不管比较的类型是引用类型还是值类型的,这个方法总是检测对象ID,而不是对象内容。是的,这就是说当你测试两个值类型是否相等时,ReferenceEquals()总会返回false,即使你是比较同一个值类型对象,它也会返回false。这里有两个装箱,会在原则16中讨论。(译注:因为参数要求两个引用对象,所以用两个值类型来调用该方法,会先使两个参数都装箱,这样一来,两个引用
对象自然就不相等了。)

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()。这个方法在你不清楚两个参数的运行类型时什么时,检测它们是否相等。记住:C#里System.Object是一切内容的最终基类。任何时候你在比较两个变量时,它们都是System.Object的实例。因此,在不知道它们的类型时,而等式的改变又是依懒于类型的,这个方法是怎样来比较两个变量是否相等的呢?答案很简单:这个方法把比较的职责委交给了其中一个正在比较的类型。静态的Object.Equals()方法是像下面这样实现的:

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

这个示例代码展示的两个方法是我还没有讨论的:操作符==()和实例的Equals()方法。我会详细的解释这两个,但我还没有准备结束对静态的Equals()的讨论。现在,我希望你明白,静态的Equals()是使用左边参数实例的Equals()方法来断定两个对象是否相等。

与ReferenceEquals()一样,你或许从来不会重新定义静态的Object.Equals()方法,因为它已经确实的完成了它应该完成的事:在你不知道两个对象的确切类型时断定它们是否是一样的。因为静态的Equals()方法把比较委托给左边参数实例的Equals(),它就是用这一原则来处理另一个类型的。

现在你应该明白为什么你从来不必重新定义静态的ReferenceEquals()以及静态的Equals()方法了吧。现在来讨论你须要重载的方法。但首先,让我们先来讨论一下这样的一个与相等相关的数学性质。你必须确保你重新定义的方法的实现要与其它程序员所期望的实现是一致的。这就是说你必须确保这样的一个数学相等性质:相等的自反性,对称性和传递性。自反性就是说一个对象是等于它自己的,不管对于什么类型,a==a总应该返回true;对称就是说,如果有a==b为真,那么b==a也必须为真;传递性就是说,如果a==b为真,且b==c也为真,那么a==c也必须为真,这就是传递性。

现在是时候来讨论实例的Object.Equals()函数了,包括你应该在什么时候来重载它。当默认的行为与你的类型不一致时,你应该创建你自己的实例版本。Object.Equals()方法使用对象的ID来断定两个变量是否相等。这个默认的Object.Equals()函数的行为与Object.ReferenceEquals()确实是一样的。但是请注意,值类型是不一样的。System.ValueType并没有重载Object.Equals(),记住,System.ValueType是所有你所创建的值类型(使用关键字struct创建)的基类。两个值类型的变量相等,如果它们的类型和内容都是一样的。ValueType.Equals()实现了这一行为。不幸的是,ValueType.Equals()并不是一个高效的实现。ValueType.Equals()是所有值类型的基类(译注:这里是说这个方法在基类上进行比较)。为了提供正确的行为,它必须比较派生类的所有成员变量,而且是在不知道派生类的类型的情况下。在C#里,这就意味着要使用反射。正如你将会在原则44里看到的,对反射而言它们有太多的不利之处,特别是在以性能为目标的时候。

相等是在应用中经常调用的基础结构之一,因此性能应该是值得考虑的目标。在大多数情况下,你可以为你的任何值类型重载一个快得多的Equals()。简单的推荐一下:在你创建一个值类型时,总是重载ValueType.Equals()。

你应该重载实例的Equals()函数,仅当你想改变一个引用类型所定义的(Equals()的)语义时。.Net结构类库中大量的类是使用值类型的语义来代替引用类型的语义。两个字符中对象相等,如果它们包含相同的内容。两个DataRowViewc对象相等,如果它们引用到同一个DataRow。关键就是,如果你的类型须要遵从值类型的语义(比较内容)而不是引用类型的语义(比较对象ID)时,你应该自己重载实例的Object.Equals()方法。

好了,现在你知道什么时候应该重载你自己的Object.Equals(),你应该明白怎样来实现它。值类型的比较关系有很多装箱的实现,装箱在原则17中讨论。对于用户类型,你的实例方法须要遵从原先定义行为(译注:前面的数学相等性质),从而避免你的用户在使用你的类时发生一些意想不到的行为。这有一个标准的模式:

public class Foo
{
    public override bool Equals( object right )
    {
        // check null:
        // the this pointer is never null in C# methods.
        if (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 CompareFooMembers(this, right as Foo );
    }
}

首先,Equals()决不应该抛出异常,这感觉不大好。两个变量要么相等,要么不等;没有其它失败的余地。直接为所有的失败返回false,例如null引用或者错误参数。现在,让我们来深入的讨论这个方法的细节,这样你会明白为什么每个检测为什么会在那里,以及那些方法可以省略。第一个检测断定右边的对象是否为null,这样的引用上没有方法检测,在C#里,这决不可能为null。在你调用任何一个引用到null的实例的方法之前,CLR可能抛出异常。下一步的检测来断定两个对象的引用是否是一样的,检测对象ID就行了。这是一个高效的检测,并且相等的对象ID来保证相同的内容。

接下来的检测来断定两个对象是否是同样的数据类型。这个步骤是很重要的,首先,应该注意到它并不一定是Foo类型,它调用了this.GetType(),这个实际的类型可能是从Foo类派生的。其次,这里的代码在比较前检测了对象的确切类型。这并不能充分保证你可以把右边的参数转化成当前的类型。这个测试会产生两个细微的BUG。考虑下面这个简单继承层次关系的例子:

public class B
{
    public override bool Equals( object right )
    {
        // check null:
        if (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 CompareBMembers( this, rightAsB );
    }
}

public class D : B
{
    // etc.
    public override bool Equals( object right )
    {
        // check null:
        if (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 CompareDMembers( this, rightAsD );
    }
}

//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" );

在任何可能的情况下,你都希望要么看到两个Equals或者两个Not
Equal。因为一些错误,这并不是先前代码的情形。这里的第二个比较决不会返回true。这里的基类,类型B,决不可能转化为D。然而,第一个比较可能返回true。派生类,类型D,可以隐式的转化为类型B。如果右边参数以B类型展示的成员与左边参数以B类型展示的成员是同等的,B.Equals()就认为两个对象是相等的。你将破坏相等的对称性。这一架构被破坏是因为自动实现了在继承关系中隐式的上下转化。

当你这样写时,类型D被隐式的转化为B类型:baseObject.Equals( derived
)如果baseObject.Equals()在它自己所定义的成员相等时,就断定两个对象是相等的。另一方面,当你这样写时,类型B不能转化为D类型,derivedObject.Equals(
base
)B对象不能转化为D对象,derivedObject.Equals()方法总是返回false。如果你不确切的检测对象的类型,你可能一不小心就陷入这样的窘境,比较对象的顺序成为一个问题。

当你重载Equals()时,这里还有另外一个可行的方法。你应该调用基类的System.Object或者System.ValueType的比较方法,除非基类没有实现它。前面的代码提供了一个示例。类型D调用基类,类型B,定义的Equals()方法,然而,类B没有调用baseObject.Equals()。它调用了Systme.Object里定义的那个版本,就是当两个参数引用到同一个对象时它返回true。这并不是你想要的,或者你是还没有在第一个类里的写你自己的方法。

原则是不管什么时候,在创建一个值类型时重载Equals()方法,并且你不想让引用类型遵从默认引用类型的语义时也重载Equals(),就像System.Object定义的那样。当你写你自己的Equals()时,遵从要点里实现的内容。重载Equals()就意味着你应该重写GetHashCode(),详情参见原则10。

解决了三个,最后一个:操作符==(),任何时候你创建一个值类型,重新定义操作符==()。原因和实例的Equals()是完全一样的。默认的版本使用的是引用的比较来比较两个值类型。效率远不及你自己任意实现的一个,所以,你自己写。当你比较两个值类型时,遵从原则17里的建议来避免装箱。

注意,我并不是说不管你是否重载了实例的Equals(),都还要必须重载操作符==()。我是说在你创建值类型时才重载操作符==()。.Net框架里的类还是期望引用类型的==操作符还是保留引用类型的语义。

C#给了你4种方法来检测相等性,但你只须要考虑为其中两个提供你自己的方法。你决不应该重载静态的Object.ReferenceEquals()和静态的Object.Equals(),因为它们提供了正确的检测,忽略运行时类型。你应该为了更好的性能而总是为值类型实例提供重载的Equals()方法和操作符==()。当你希望引用类型的相等与对象ID的相等不同时,你应该重载引用类型实例的Equals()。简单,不是吗?

添加新评论