当我们创建自己的类型时(无论是class还是struct),都应为类型定义“等同性”的含义。
C#提供了4种不同的函数来判断两个对象是否“相等”。
public static bool ReferenceEquals(object left, object right);
public static bool Equals(object left, object right);
public virtual bool Equals(object obj);
public static bool operator == (MyClass left, MyClass right);
当然,这4个方法并不是等同性比较的唯一选择。覆写Equals()方法的类型也应该实现 IEquatable<T>。实现值语义(value semantics)的类型同时还应该实现IStructralEquatable 接口。这就意味着一共有6种方法来表达等同性。
C#允许我们创建两种类型:值类型和引用类型。如果两个引用类型的变量指向的是同一个对象,它们将被认为是“引用相等”。如果两个值类型的变量类型相同,而且包含同样的内容,它们被认为是“值相等”。这也正是等同性判断需要如此多方法的原因。
虽然看起来做等同性判断有些过于复杂,但是不要担心,我们可以简化这个问题。
不应该覆写的方法
对于前两个静态函数,我们永远都不应该去重新定义。
1. Object.ReferenceEquals()
如果两个变量指向同一个对象,也就是它们拥有同样的对象标识,那么Object.ReferenceEquals() 方法将返回true。无论比较的是引用类型还是值类型,该方法判断的依据都是对象标识,而不是对象内容。这也意味着,如果我们使用ReferenceEquals() 来比较两个值类型,其结果将永远返回false,即使我们将一个值类型和它自身进行比较,ReferenceEquals() 的返回值仍是false,其原因在于装箱。
我们永远都不应该去重新定义Object.ReferenceEquals() 方法,因为它已经完美地完成了所需要完成的工作——判断两个不同变量的对象标识是否相等。
2. Object.Equals()
当你不知道两个变量的运行时类型时,可以使用该方法来判断两个变量是否相等。任何时候我们比较两个变量都是System.Object 的实例,值类型变量和引用类型变量都是如此。那么该方法又将如何判断两个变量是否相等呢?因为该方法并不知道它们的类型,而等同性判断又是依赖类型的。答案很简单,该方法会将判断的具体操作委托给其中一个类型来做。
public static bool Equals(object left, object right)
{
// check object identity
if (Object.ReferenceEquals(left, right))
return true;
// both null reference handled above
if (Object.ReferenceEquals(left, null) ||
Object.RefernceEquals(right, null))
return false;
return left.Equals(right);
}
从上面的代码可以看出,在静态 Equals() 方法的内部,实际上是通过调用left参数的实例Equals() 方法来判断两个对象是否相等的。
和 ReferenceEquals() 方法一样,你永远不要去重新定义或覆写静态的 Object.Equals() 方法,因为它也已经很好地完成了其应该做的动作,即在不知道对象的运行时类型时,判断二者是否相等。
可以覆写的方法
下面我们讨论那些可以被覆写的方法,但是在此之前,我们先来简要谈谈等同性关系在数学上的定义。对于等同性判断,需要保证我们给出的定义是满足等同性在数学方面的几个要点:自反(reflexive)、对称(symmetric)和可传递(transitive)。
- 自反表示任何对象都和其自身相等。无论a是什么类型,a==a都应该返回true。
- 对称意味着等同性判断时的顺序是无关紧要的,也就是说若a=b返回true,那么b=a也必然返回true;若a=b返回false,那么b=a也必然返回false。
- 可传递的含义是,若a=b且b=c都是返回true,那么a=c也必然返回true。
1. Equals()
当 Equals() 方法的默认行为与我们的类型要求不一致时,自然要覆写。
对于引用类型,只有当我们希望更改其预定义语义时,才应该覆写Equals() 实例方法。.NET Framework类库中的许多类都使用值语义而不是引用语义来做等同性判断。例如,如果两个string对象包含相同的内容,它们将被认为相等。
这样,如果某个类型需要遵循值语义(比较内容),而不是引用语义(比较对象标识)的话,我们就应该覆写 Object.Equals() 实例方法。当你覆写 Equals() 方法时,也要同时为该类型实现 IEquatable<T>。
public class Foo : IEquatable<Foo>
{
public override bool Equals(object obj)
{
// check null
// this pointer is nerver null in C# method.
if (object.ReferenceEquals(obj, null))
return false;
if (object.ReferenceEquals(this, obj))
return true;
if (this.GetType() != obj.GetType())
return false;
// Compare this type’s contents here
return this.Equals(obj as Foo);
}
#region IEquatable<Foo> Members
public bool Equals(Foo other)
{
// elided.
return true;
}
#endregion
}
首先,Equals() 绝对不应该抛出异常,这样做没有什么意义。两个变量要么相等,要么不相等,不存在其他失败的情况。对于所有诸如空引用或错误的参数类型等条件,都应该直接返回false。
现在我们仔细研究一下这个方法。
第一个检查判断右边的对象是否为null,对于this指针则不需要这一步。在C#中,this指针永远都不可能为null。若是通过null引用来调用任何实例方法,那么CLR在调用进入方法之前就会抛出一个异常。
接下来的检查将要判断两个对象引用是否为同一个对象,即比较对象标识。这个比较非常高效,若对象引用相同,则对象内容一定相等。
第三个检查用来判断两个对象的类型是否相同。这里使用精确的比较是非常重要的。
首先注意,其中没有假设this指针的类型为Foo,而是再次调用this.GetType() 来获取,因为实际的类型可能继承自Foo。
其次,代码检查的是所比较对象的精确类型。仅仅判断obj参数能否转换为当前类型时不够的,那样的话会导致两个诡异的bug。下面举个例子来演示这种问题:
public class B : IEquatable<B>
{
public override bool Equals(object obj)
{
if (object.ReferenceEquals(obj, null))
return false;
if (object.ReferenceEquals(this, obj))
return true;
// Problem here
B objAsB = obj as B;
if (objAsB == null)
return false;
return this.Equals(objAsB)
}
#region IEquatable<B> Members
public bool Equals(B other)
{
//elided
return true;
}
#endregion
}
public class D : B, IEquatable<D>
{
public override bool Equals(object obj)
{
if (object.ReferenceEquals(obj, null))
return false;
if (object.ReferenceEquals(this, obj))
return true;
D objAsD = obj as D;
if (objAsD == null)
return false;
return base.Equals(objAsD) && this.Equals(objAsD);
}
#region IEquatable<D> Members
public bool Equals(D other)
{
//elided
return true;
}
#endregion
}
//Test.
B baseObject = new B();
D derivedObject = new D();
// Comparison 1.
if (baseObject.Equals(derivedObject))
Console.WriteLine(“Equals”);
else
Console.WriteLine(“Not Equals”);
// Comparison 2.
if (derivedObject.Equals(baseObject))
Console.WriteLine(“Equals”);
else
Console.WriteLine(“Not Equals”);
按照设想,上面的代码应该都输出Equals,或者都输出 Not Equals,但是输出却并非如此。
第二个比较永远返回false,因为基类B的对象不可能被转换为D,但是第一个比较却有可能返回true,因为派生类D的对象可以被隐式地转换为类型B。这样也就破坏了等同性的对称性。之所以会发生这样的问题,是因为比较的对象在继承层次中发生了自动转型。
因此,若是不检查对象的精确类型,便会很容易陷入这种情况,即比较的顺序会影响比较的结果。
上面的例子中,同时也演示了覆写 Equals() 时的另一个重要的原则。如果基类的 Equals() 方法不是由 System.Object 或 System.ValueType 提供的话,我们也应该同时调用基类的 Equals() 方法。D类调用了B类中定义的 Equals() 方法,但是B类却没有调用 System.Object 中定义的 Equals() 方法。
2. GetHasCode()
覆写 Equals() 方法的同时也要覆写 GetHashCode() 方法。
GetHasCode() 方法仅会在集中中使用,例如:为基于散列(hash)的集合定义键的散列值,此类集合包括HashSet 和 Dictionary<K, V>容器等;在GroupBy,OrderBy等方法中提供首先比较等。若是我们创建的类型需要使用散列值进行比较时(在散列表中的键,排序等)或者覆写 Equals() 时,那么就需要自己实现 GetHashCode() 方法。
基于散列的容器使用散列码来优化查询。每一个对象都会生产一个整数的散列码,根据该散列码,对象会被装进一些“桶”(bucket)中。若想查找一个对象,我们会首先得到它的散列码,并在其对应的“桶”中搜索。
在.NET中,每一个对象的散列码都是由 GetHashCode() 方法提供。任何 GetHasCode() 方法的重载版本都需要遵循三条规则:
- 如果两个对象相等,那么它们必须生成相同的散列码。
- 对于任何一个对象A,A.GetHasCode() 必须保持不变。无论在A上调用什么方法,A.GetHashCode()都必然总是返回同一个值。
- 对于所有的输入,GetHasCode() 方法应该在所有整数中按照随机分布生成散列码。
我们来检查 Object.GetHashCode() 方法是否满足上述三条规则。
- 在没有覆写等同性的情况下,如果两个对象相等,因为判断的是对象标识,GetHasCode() 会返回内部的对象标识,所以这样做没有任何问题,满足第一条规则,Object.GetHashCode() 会返回同样的散列值。
- 在对象创建后,内部的对象标识不会变动,所以 Object.GetHashCode() 也满足第二条规则,其散列码永远不会改变。
- Object.GetHashCode() 方法并不满足第三条规则,Object.GetHashCode() 返回的散列码会集中在整数范围的低端,除非我们创建非常多的对象,否则一个递增的序列在所有整数的范围内并不是一个随机的分布。
若想要创建一个好的散列码,就需要为类型添加一些约束。最理想的情况是使用不可变的值类型作为键值。
首先,任何用来产生散列码的属性或者数值,都必须参与到类型的等同性判断中。很明显,这就意味着会有相同的属性同时用于等同性判断和散列码的计算中。但是并非所有参与等同性判断的属性,都会用于进行散列码计算,这通常又意味着会违反第三条规则。
其次,让散列码函数根据对象中的常量属性(一个或多个只读)返回一个值。否则,将有可能会破坏散列表。例如
public class Customer
{
public Customer(string name)
{
this.Name = name;
}
public string Name { get; set; }
public decimal Revenue { get; set; }
public override int GetHashCode()
{
return this.Name.GetHashCode();
}
}
// Test.
Customer c1 = new Customer(“Products”);
myDictionary.Add(c1, orders);
c1.Name = “Software”;
Order o = myDictionary[c1];
在上面代码中,c1会在myDictionary中某个地方丢失。当我们将c1放入myDictionary中时,散列码是根据字符串“Products”产生的,但在将Name修改之后,散列码也随之改变,新的散列码是根据字符串“Software”产生的。因此最后一行代码将会报错。
通过把要用于计算散列码的属性定义为只读属性,我们即可确保正确地行为。这样需要的代码量跟大,但是必须让开发人员编写正确地代码,而这是唯一正确的方式。
最后,第三条规则并没有万能的公式,如果有,那么 System.Object 就会给出实现了,可惜事实上并没有。一个常用且成功的算法是:对一个类型中的所有不可变字段调用 GetHashCode() 方法返回的值进行异或运算。
3. operator ==
operator == 运算符相对比较简单,只要创建的是值类型,都应该重定义operator ==运算符。因为 ValueType 提供的版本是使用反射方式来比较两个值类型实例的内容,其效率远远低于我们自己编写版本的效率。
ValueType 是所有值类型的基类,为了提供正确地行为,它必须能够在不知道对象运行时类型的情况下,比较其派生类型中的所有成员变量,这就意味着必须使用反射。反射有许多缺点,特别是当程序性能非常重要的时候。而等同性判断又是一个在程序中被频繁调用的基础性功能,所以在这里追求性能是个很合理的做法。
4. IStructuralEquatable
最后要说的是实现在 System.Array 和 Turple<> 泛型类中的 IStructuralEuqatable 接口。该接口在让类型实现值语义比较的同时,也不强迫每次比较时都使用值语义。你基本上不会创建需要实现 IStructuralEquatable 接口的类型,它仅用于那些轻量级的类型。实现 IStructuralEquatable 接口就意味着该类型可以被组合至更大的、且实现值语义的对象中。
小结
C#为我们提供了很多种等同性判断的方式,但是其实只需要考虑为其中的两种提供自行实现。
- 你永远都不应该覆写Object.ReferenceEquals()静态方法和Object.Equals()静态方法,因为它们已经提供了正确地判断。
- 对于值类型,我们应该总是覆写Object.Equals()实例方法和operator == 运算符,以便为其提供效率更高的等同性判断。
- 对于引用类型,仅当你认为相等的含义并非时对象标识相同时,才需要覆写Object.Equals()实例方法。
- 在覆写Equals()方法时,还需要覆写Object.GetHashCode()实例方法,以及实现IEquatable接口。
很简单,不是吗?