分享

条款9:理解几个相等判断之间的关系

 兰亭文艺 2018-01-17

当我们创建自己的类型时(无论是类还是结构),可以为类型定义“相等判断”的含义。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, MyClassright );
但是“我们可以这么做”并不意味着“我们应该这么做”。对于前两个静态函数,我们永远都不应该去重新定义。我们通常需要创建自己的Equals()实例方法,来为类型定义“相等语义”。偶尔需要重写operator==[16],主要是考虑值类型的性能。另外,这四个函数之间也存在一定的关系。当我们改变其中一个时,有可能影响其他几个的行为。是的,用四个函数来做“相等判断”是过于复杂了。但是不要担心,我们可以简化这个问题。
就像C#中许多复杂的元素一样,这里也考虑到了同样的事实——C#允许我们创建两种类型:值类型和引用类型。如果两个引用类型的变量指向同一个对象,它们将被认为是“引用相等”。如果两个值类型的变量类型相同,而且包含同样的内容,它们被认为是“值相等”。这便是“相等判断”需要那么多方法的原因。
让我们首先从两个“永远都不必重新定义”的静态函数开始。如果两个变量指向同一个对象——也就是它们拥有同样的对象标识(objectidentity),那么Object.ReferenceEquals()方法会返回true。不管比较的是引用类型还是值类型,该方法都判断的是“引用相等”,而非“值相等”。这就意味着如果我们使用ReferenceEquals()来比较两个值类型,其结果永远返回false。即使我们将一个值类型和自身进行比较,ReferenceEquals()的返回值仍是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()方法,因为它已经把它应该做的工作——判断两个不同变量的对象标识(object identity)是否相等——做得很好了。
我们永远都不应该去重新定义的第二个静态函数是 Object.Equals()。当我们不知道两个变量的运行时类型(runtime type)时,可以使用该方法来判断两个变量是否相等。注意,System.Object是C#中所有类型的最终基类。因此,任何时候我们比较的两个变量都是System.Object的实例。值类型变量和引用类型变量都是如此。那么该方法是如何判断两个变量是否相等的呢?因为该方法并不知道它们的类型,而“相等判断”又是依赖类型的。答案很简单:该方法会将判断的责任交给其中一个类型来做。事实上,静态Object.Equals()方法的实现如下:
public static bool Equals( object left, object right )
{
  // 检查是否引用相等。
  if (left == right ) //System.object类型的==实现的是引用相等
    return true;
  // 两者同时为null引用的情况在上面已经处理。
  if ((left == null) || (right == null))
    return false;
  return left.Equals(right);
}
上面代码中引入的两个方法我们还没有讨论: operator==()和Equals()实例方法。下面我们会详细讨论这两个方法,但是静态Equals()方法的讨论还没有结束。目前来讲,我希望大家能够理解在静态Equals()方法的内部,实际上是通过调用left参数的实例Equals()方法来实现的。
和ReferenceEquals ()方法一样,我们永远都不要去重新定义静态的Object.Equals()方法,因为它也已经将它应该做的工作——当不知道两个对象的运行时类型时,判断它们是否相等——做得很好了。由于静态的Equals()方法会将判断的工作交给left参数的实例Equals()方法来做,因此它会使用left 参数的类型所定义的规则来进行相等判断。
现在,大家已经理解了为什么我们永远都不需要重新定义静态ReferenceEquals()和Equals()方法。下面我们来讨论那些可以重写的方法。 但是在这之前,让我们先来简要谈谈相等关系的数学属性。对于“相等判断”,我们需要确保我们的定义和实现与其他程序员的期望一致。这意味着我们需要牢记相等的数学属性:自反(reflexive)、对称(symmetric)和可传递(transitive)。自反属性意味着任何对象都和其自身相等。不管是什么类型,a==a都应该返回true。对称属性意味着相等判断时的顺序是无关紧要的:也就是说如果a==b返回true,那么b==a也返回true。如果a==b返回false,那么b==a也返回false。最后一个属性可传递性含义如下:如果a==b并且b==c都返回true,那么a ==c也应该返回true。
下面我们来看Object.Equals()实例函数,谈谈我们应该何时以及如何重写它。当Equals()方法的默认行为与我们的类型要求不一致时,我们就要重写它。Object.Equals()实例方法默认判断的是“引用相等”,其行为和Object.ReferenceEquals()完全一致。但是值类型例外。
System.ValueType重写了Object.Equals()方法。记住,ValueType是所有值类型(我们使用struct关键 字创建的类型)的基类型。如果两个值类型变量的类型相同,并且内容一致,这两个变量才被认为相等。ValueType为Equals()方法实现了这种行为。但是,ValueType为Equals()方法提供的重写实现效率并不高。由于ValueType.Equals()[17]是所有值类型的基类,为了提供正确的行为,它必须能够在不知道对象运行时类型的情况下,比较其派生类型中的所有成员变量。在C#中,这意味着要使用反射。如本书条款44所述,反射有许多缺点,特别是当性能是我们的目标时更是如此。“相等判断”是一个在程序中被频繁调用的基础性构造,因此性能是一个值得我们考虑的目标。几乎在所有的情况下,我们都应该为自己的值类型提供一个更快的Equals()重写版本。我们对值类型Equals()实例方法的推荐也相当简单:无论何时创建一个值类型,我们都要重写ValueType.Equals()方法。

对于引用类型,只有当我们希望更改其预定义的语义时,才应该重写Equals()实例方法。.NET框架类库中的许多类都使用“值语义”而非“引用语义”来做相等判断。如果两个string对象包含相同的内容,它们将被认为相等。两个DataRowView对象则在它们都引用同样的DataRow时,才被认为相等。如果我们的类型遵循“值语义”(比较内容),而非“引用语义”(比较对象标识),我们就应该重写Object.Equals()实例方法。
前面我们已经知道了应该在何时重写 Object.Equals()实例方法,下面我们就必须理解如何来实现它。值类型的相等关系中有许多隐含的装箱操作,本书条款17对此有讨论。对于引用类型,我们的实例方法需要遵循预定义行为,避免向用户返回奇怪的结果。下面是一种标准的实现模式:
public class Foo
{
  public override bool Equals( object right )
  {
    // 检查是否为null:
    // 在C#方法中,this指针永远都不可能为null。
    if (right== null)  //空判断
      return false;
    if (object.ReferenceEquals(this, right ))  //引用判断
      return true;
    // 下面将对此进行讨论。
    if (this.GetType()!= right.GetType())  //类型判断
      return false;
    // 比较两个实例的内容:
    return CompareFooMembers(this,right as Foo );  //内容判断
  }
}
首先,Equals()绝对不应该抛出异常——那没有什么意义。两个变量要么相等,要么不相等,不存在其他失败的情况。对于所有失败的条件,我们都应该返回false,例如空引用,或者错误的参数类型。 现在,让我们仔细浏览一下上面的方法,以理解为什么某些检查是必要的,而某些检测则是可以省去的。
第一个检查判断右边的对象是否为null。对于this 引用,则不需要检查。在C#中,this指针永远都不可能为null。如果是通过null引用来调用任何实例方法,那么CLR在调用进入方法之前就会抛出一个异常。
第二个检查会判断两个对象引用是否为同一个对象,即比较对象标识。这是一个非常高效的测试,如果对象引用相等,则对象内容一定相等。
第三个检查判断的是两个对象的类型是否一致。这种使用GetType()方法进行的精确比较是非常重要的。首先,注意它没有假设this指针的类型为Foo,相反是通过调用this.GetType() 来获取其类型的,因为实际的类型可能继承自Foo。其次,代码检查的是所比较对象的精确类型。仅仅确保将right参数转换为当前类型是不够的。那样的话会导致两个非常诡异的bug。下面的例子演示了这种问题:
public class B
{
  public override bool Equals( object right )
  {
    // 检查是否为null:
    if (right == null)
      return false;
    // 检查是否引用相等:
    if (object.ReferenceEquals( this,right ))
      return true;
    // 这里存在问题,下面将会讨论。
    B rightAsB = right as B;
    if (rightAsB == null)
      return false;
    return CompareBMembers( this,rightAsB );
  }
}
public class D : B
{
  // 忽略其他细节。
  public override bool Equals( object right )
  {
    // 检查是否为null:
    if (right == null)
      return false;
    if (object.ReferenceEquals( this,right ))
      return true;
    // 这里存在问题。
    D rightAsD = right as D;
    if (rightAsD == null)
      return false;
    if (base.Equals( rightAsD ) ==false)
      return false;
    return CompareDMembers( this,rightAsD );
  }
}
//测试:
B baseObject = new B();
D derivedObject = new D();
// 比较1。
if (baseObject.Equals(derivedObject))
  Console.WriteLine( "Equals" );
else
  Console.WriteLine( "Not Equal" );
// 比较2。
if (derivedObject.Equals(baseObject))
  Console.WriteLine( "Equals" );
else
  Console.WriteLine( "Not Equal" );
不管怎么样,上面的代码应该要么打印两次 Equals,要么打印两次Not Equal。但是由于某些错误,上面的代码输出并非如此。其中第二个比较永远返回false,因为基类B的对象不可能被转换为D。但是第一个比较却可能返回true。因为派生类D的对象可以被隐式地转换为类型B。如果右边参数中属于B类型的那一部分成员,正好与左边对象中B类型的成员相等,那么 B.Equals()将认为两个对象相等,即时这时候两个对象的类型不同。这实际上已经破坏了Equals的对称性。这种构造之所以能够破坏Equals 的对称性,是由于在继承层次中发生了自动转型。
在下面的代码中,D对象会被显式转型为一个B对象:
baseObject.Equals( derived )
如果baseObject.Equals()判定它们的字段匹配,那么它会认为两个对象相等。另一方面,在下面的代码中,B对象却不能转化成一个D对象:
derivedObject.Equals( base )
由于B对象不能被转化为D对象,因此derivedObject.Equals()方法将总是返回false。如果我们不检查对象的精确类型,便会很容易陷入这种情况:即比较的顺序会影响比较的结果。
当我们重写Equals()方法时,还有一种实践需要遵循:即如果基类的Equals()方法不是由System.Object或 System.ValueType提供的话,我们也应该调用基类的Equals()方法。前面的代码就是一个例子。D类中的Equals()方法调用了基类B中定义的Equals()方法,但B类中的Equals()方法却没有调用baseObject.Equals()方法[18],因为那会导致调用System.Object中定义的Equals()方法,而该方法只有在两个参数指向同一个对象时,才会返回true。但这并不是我们想要的结果,否则我们自己就不用写了。
综上所述,Equals()实例方法的重写规则如下:对于所有的值类型,我们都应该重写其Equals()方法;对于引用类型,当System.Object提供的“引用语义”不能满足我们的需要时,我们才应该去重写Equals()方法。在实现我们自己的Equals()方法时,应该遵循上面介绍的标准模式。 另外,重写Equals()方法的同时,也要重写GetHashCode()方法(相关细节,参见条款10)。
三种“相等判断”已经讨论完了,下面我们来讨论最后一种“相等判断”:operator==()。只要我们创建的是值类型,都需要重定义operator==()[19]。其理由和重写ValueType.Equals()实例函数的理由完全一样。因为系统默认提供的版本是通过使用反射来比较两个值类型实例的内容,其效率要远低于我们自己编写的效率,所以我们需要自己来实现该操作符。另外,在比较值类型时,可以参考条款17中的推荐以避免装箱操作。
注意,并没有说在任何重写Equals()实例方法的时候,都应该提供operator==()。我的意思是当我们创建值类型时,才应该这么做。创建引用类型时,应该很少需要重写operator==()。.NET框架中的类期望所有引用类型上应用的operator==()都遵循“引用语义”。
C#为我们提供了4种“相等判断”的方式,但是我们只需要考虑为其中两种提供自己的定义。我们永远都不应该重写Object.ReferenceEquals()静态方法和Object.Equals()静态方法[20],因为它们已经提供了正确的判断,且该判断与具体的运行时类型无关。对于值类型,我们应该总是重写Object.Equals()实例方法和 operator==(),从而为它们提供效率较好的“相等判断”。对于引用类型,当我们认为相等的含义并非是对象标识相同时,才需要重写 Object.Equals()实例方法。很简单,不是么?

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多