Effective C# Item 9 : Understand the Relationships Among ReferenceEquals(),static Equals(),instance Equals,and operator==
当我们创建一个类型(不论是类或者结构)时,我们就为其定义了如何判断“相等”的含义。C#提供了四种不同的方法来判断两个对象是否相等:
在C#中我们可以为类型创建这四种方法,但是这并不意味着它就是合理的。我们经常会为类型提供Equals()方法,或者重写==运算符,但是这些只是单单在运算结果上的表现。他们四种方法是后关联的,改变其中一个就有可能影响到其他几个。
C#中允许我们创建值类型和引用类型。两个引用类型是否相等取决于它们是否指向同一个对象,这种方法只检查对象的地址,而不关心对象的内容。这也就意味着如果我们对值类型来使用ReferenceEquals()的话那结果永远都是false,即便是同它自身进行比较。这原因涉及到装箱(boxing)。
我们永远不要重定义Object. ReferenceEquals(),因为它所做的正是它被要求的工作,检测两个对象的地址是否相等。
第二种我们不要重定义的是静态的Object.Equals()。这个方法检测在运行时两个变量是否相等。Object是C#中所有对象的基类型,任何进行比较的变量都是System.Object的一个实例。那么在不知道变量类型的情况下如何能够对两个变量进行比较呢?答案很简单:它将其中一个转变成另一个的类型再进行比较。静态的Object.Equals()方法使用类似于这样的方法来判断相等:
在这个示例中涉及到了两个我们还没有提到的方法,操作符==和实例的Equals()方法。在后面还会对它们进行详细的介绍,这里我们可以看到静态的Equals()方法使用左边对象的Equals()方法来判断它们是否相等。
同ReferenceEquals()一样,我们不应当重定义静态的Object.Equals()方法,因为它已经做了它应当做的工作:在不清楚运行时的类型的情况下,检查两个对象是否相同。由于静态的Object.Equals()会使用到左边类型实例的Equals()方法,所得的结果也取决于这个类型。
现在我们清楚了为什么不要重定义Object. ReferenceEquals()和静态的Object.Equals()方法的原因。重写它们会消耗大量的时间。我们可以简单的说明一下在数学上“相等”这个含义所代表的关系。我们必须确保我们重定义的“相等”满足这些条件,否则对于其他开发者而言,这种相等是意料之外的。相等应当具有自身性,对称性和传递性。自身性就是说一个对象应当等于它自己,无论任何对象的实例,a == a应当总是正确的。对称性的意思是如果a == b是true的话,那么b == a也应当是true。传递性是如果a == b且b == c为true的话,那么a == c也为true。
我们下面来说一下实例的Object.Equals(),还有何时我们应当如何重写这个方法。当这个方法与我们所希望得到的结果不一致时,我们就需要重写它。Object.Equals()使用检测两个对象地址是否相同的方法来判断它们是否相等。默认状态下的Object.Equals()方法的行为等同于Object.ReferenceEquals()。但是对于值类型来说,二者就有区别了。我们所创建的所有值类型都是基于System.ValueType类的。两个值类型相等需要它们具有相同的结构和相同的值。这种判断行为是ValueType定义的。不过ValueType的判断效率并不高,因为它针对的是所有的值类型。为了进行正确的比较,它必须对类型中所有成员进行比较,包括它们的继承类型。由于我们并不清楚这些对象在运行时的类型,所以这里用到了反射(reflection)机制。反射有一些缺点,尤其表现在效率上。由于“相等”在程序中出现的频率非常高,它的效率会影响到整个程序的运行效率。因此我们应当为自己定义的值类型重写Equals()方法来提高运行的速度。
对于引用类型来说,只有在我们想改变它的“相等”含义的时候才需要重写Equals()方法。.Net Framework中一些类就使用了对象的值而不是引用来进行比较。例如两个string对象的比较就取决于它们是否有同样的内容。两个DataRowView对象的比较取决于它们是否有同样的DataRow。如果我们需要我们的引用类型在“相等”上表现值类型的特征的话,我们就应当重写对象的Object.Equals()方法。
在知道了何时重写它之后,我们还要知道如何重写它。对于值类型来说,它的等于关系包含了很多装箱(boxing)操作。对于引用类型来说,重写的方法必须要同类型重写前的行为一致,以免为类型的使用者造成意外的麻烦。下面是一个例子:
首先,Equals()不需要抛出异常,这根本没有必要,我们只关系它们相不相等。对于所有可能出现的错误,例如空引用之类,只要返回false就好了。通过这个实例我们就可以了解为什么我们要使用这几个判断条件而不用其他的判断条件。首先我们先检查等式右边的对象是否为null。我们并不检查左边的(即对象自身)是否为null,因为在C#中,this永远不会为null。如果为null,CLR(公共语言运行库)在调用这个方法之前就会抛出空引用异常。然后检查这两个对象的引用相同,即对象的地址。这是个很高效的检验,同样的地址代表了同样的内容。第三步检验这两个对象的类型是否相同。这个检查很重要。首先我们要注意到的是它并没有使用Foo类型,而是this.GetType()。因为有的类型可能是Foo的派生类。其次,它还检查了等式右边的对象的类型,如果相等再进行判断。这样做的原因在于我们并不能完全保证可以将右边的对象转化为左边的类型,否则的话会有两个小bug。下面这个例子中我们在派生类中使用它:
我们期望它能够在不同的条件下返回true或者false,但是这其中有一些问题。第二个判断永远不会返回true,因为基类B永远不能被转化为派生类D。至于第一个判断,在某些条件下是可以成立的。如果右边对象中的成员的值同基类对象中成员的值相同,那么就视为相等。这就意味着即便它们是不同类型的对象,这个方法仍然会把它们认为相等。这就破坏了相等的对称性。
如果你写出入下例代码,则将会把派生类对象转换为基类对象:
如果baseObject.Equals()发现它们的成员变量都相等,则认为这两个对象是相等的。从另一个角度来说,如果我们使用如下的代码:
因为基类对象不能被转化为派生类,derivedObject.Equals()方法永远返回false。如果我们不仔细考虑对象类型,就可能被带入比较顺序不同得到的结果不同的麻烦之中(b == d true但是d == b false)。
在重写Equals()方法时还应该注意一点,只有当我们的基类不是System.Object或System.ValueType时,我们应当调用基类的Equals()。在上面的例子中,D调用了基类中的Equals()方法,而B调用的是它的基类System.Object中的Equals()版本。
对于==(),它比较类似于值类型的Equals()。默认下是使用反射机制来比较两个值类型是否相等。对于我们自定义的值类型来说,应当重写它来提高效率。
应当注意的是,只有在我们的类型为值类型时才需要重写==(),对于引用类型来说,很少会重写它,因为它比较的是两个引用类型的地址。
C#为我们提供了四种表示“相等”的方法,其中在我们自己的类型中需要重写的只有两个。我们不应当重写Object. ReferenceEquals()和静态的Object.Equals()方法,因为它们的检测是绝对正确的。我们应当为值类型重写实例的Equals()方法和==()来提供更高的效率。当我们希望引用类型在“等于”上能够表现出值类型的特征时,我们也应当重写Equals()方法。