深入探察相等操作符

阅读数:558 2008 年 6 月 2 日

重写相等操作符是非常容易出错的。不仅因为相等操作符有许多内涵,而且目前有很多指导文档有瑕疵,甚至在 MSDN 网站上有些指导文档也有瑕疵。我们将分别对支持相等操作的引用类型和值类型给出系统的分析,来澄清事实。

为了清晰起见,这里将类称作引用类型而结构称作值类型。

通常在结构中操作符重载比在类中有意义,所以我们先来展示在结构中的情况。类和结构的主要区别是,类需要检查空值,而在结构中你需要意识到可能存在的类型装箱。这一点将在后面说明。

类签名

结构的签名是直接了当的。你仅仅需要用 System.IEquatable 接口来标识该结构。请注意这个接口没有非泛型的版本,泛型的版本的角色由基础类 Object 承担。

C#

struct PointStruct : System.IEquatable

VB



Public Structure PointStruct

Implements IEquatable(Of PointStruct)

类的签名本质上和结构签名是一样的。类的继承会破坏相等性,这会造成问题。如果 a 是一个基类而 b 是一个重写了 Equals 方法的子类,那么 a.Equals(b) 会返回与 b.Equals(a) 不同的返回值。后面我们通过封闭(sealing)的 Equals 方法来解决这个问题。

C#

class PointClass : System.IEquatable

VB



Public Class PointClass Implements IEquatable(Of PointClass)

成员变量和属性

任何用于相等性比较的成员变量必须是不可变的。通常,这意味着类中所有的属性是只读的或者类有一个类似于数据库主键的唯一标识符。

在使用任何依赖哈希的东西的时候这条规则都是至关重要的。这样的例子包括 Hashtable、Dictionary、HashSet 和 KeyedCollection。这些类都使用哈希码作查找和存储。如果对象的哈希码变化了,它会被放在错误的槽中而且集合不能再正确的工作。最常见的故障是不能找到以前放在集合中的对象。

为了确保成员变量是不可变的,它们被标记为只读的。由于成员变量可以在构造器中设置,所以改变成员变量有点象写错名字。但是一旦初始化完成了,没有方法可以直接改变成员变量的值。

C#

readonly int _X;

readonly int _Y;

public PointStruct (int x, int y)



{

_X = x;

_Y = y;

}

int X



{

get { return _X; }

}

int Y



{

get { return _Y; }

}

VB

Private ReadOnly m_X As Integer

Private ReadOnly m_Y As Integer

Public Sub New(ByVal x As Integer, ByVal y As Integer)



m_X = x

m_Y = y

End Sub

Public ReadOnly Property

X() As Integer

GetReturn m_X



End Get

End Property

Public ReadOnly Property

Y() As Integer

GetReturn m_Y



End Get

End Property

由于类版本的代码与上面的代码几乎是相同的,且在 VB 中是完全相同的,所以这里不给出类版本代码。

类型安全的相等方法

我们实现的第一个方法是类型安全的相等方法,在 IEquatable 接口中使用。

C#

public bool Equals(PointStruct other)

{

return (this._X == other._X) && (this._Y == other._Y);

}

VB Public Overloads Function Equals(ByVal other As PointStruct) As Boolean _



Implements System.IEquatable(Of PointStruct).Equals

Return m_X = other.m_X AndAlso m_Y = other.m_Y

End Function

对于类,需要额外检查空值。按照惯例,所有非空值被认为与空值不相等。

你会注意到我们没有使用地道的 C# 代码来检查空值。这是由于 C# 和 VB 处理相等性的方式有一处不同。

Visual Basic 在处理引用相等和值相等上有明确的区别。前者使用 Is 擦作符,后者使用 = 操作符。

C# 缺乏这种区别,对两者都使用 == 操作符。由于我们会重写 == 操作符,所以不想使用 ==,不得不转而使用一个后门。这个后门是 Object.ReferenceEquals 方法。

由于类总是与自己相等,所以在进行潜在的更昂贵的相等性检查之前,我们首先作这个检查。在下面代码中我们比较了私有成员变量,也可以使用属性来作比较。

 C#

public bool Equals(PointClass other)

{

if (Object.ReferenceEquals(other, null))

{

return false; }



if (Object.ReferenceEquals(other, this))

{

return true; }



return (this._X == other._X) && (this._Y == other._Y);

}

VB



Public Overloads Function Equals(ByVal other As PointClass) As Boolean

Implements
System.IEquatable(Of PointClass).Equals

If other Is Nothing Then Return False

If other Is Me Then Return True

Return m_X = other.m_X AndAlso m_Y = other.m_Y

End Function

哈希码

下一步是产生哈希码。最简单的方法是将所有用于相等性比较的成员变量的哈希码作异或运算。

C#

public override int GetHashCode()

{

return _X.GetHashCode() ^ _Y.GetHashCode();

}

VB



Public Overrides Function GetHashCode() As Integer

Return m_X.GetHashCode Xor m_Y.GetHashCode

End Function

如果你确实决定要从头写自己的哈希码,你必须确保对于一套给定的值,你总是能返回相同的哈希码。换言之,如果 a 等于 b,那么它们的哈希码也相等。

哈希码不必是唯一的,不同的值可以有相同的哈希码。但是它们应该有一个良好的分布。对于每一个哈希码都返回 42 在技术上是合法的,但是任何使用该算法的应用在性能上都会很糟糕。

哈希码应该以非常快的速度计算出来。由于计算哈希值可能成为瓶颈,所以宁可选用一个快速的有合理良好分布的哈希码算法,而不选择一个慢的,复杂的有着完美的均匀分布的算法。

相等(对象)

重写基类的 Equals 方法是一个基础工作,该方法被 Object.Equals(Object, Object) 函数和其他方法调用。

你应该注意到由于类型转换做了两次,所以可能存在一点性能问题:一次是看它是否有效,第二次是真正的执行它。不幸的是,在结构中是无法避免这样作的。

C#

public override bool Equals(object obj)

{

if (obj is PointStruct)

{

return this.Equals((PointStruct)obj);

}

return false;

}

VB



Public Overrides Function Equals(ByVal obj As Object) As Boolean

If TypeOf obj Is PointStruct Then Return CType(obj, PointStruct) = Me

End Function

对于类可以只用一次类型转换。在处理步骤中,我们可以早点检测空值然后跳过对 Equals(PointClass) 方法的调用。C# 必须用 ReferenceEquals 函数来检查空值。

为了防止子类破坏相等性,我们封闭(Seal)了方法。

C#

public sealed override bool Equals(object obj)

{

var temp = obj as PointClass;

if (!Object.ReferenceEquals(temp, null))

{

return this.Equals(temp);

}

return false;

}

VB



Public NotOverridable Overrides Function Equals(ByVal obj As Object) As Boolean

Dim temp = TryCast(obj, PointClass)

If temp IsNot Nothing Then Return Me.Equals(temp)

End Function

操作符重载

所有的难题都被攻克了,我们现在可以进行操作符的重写了。这里和调用类型安全的 Equlas 方法一样简单。

C#

public static bool operator ==(PointStruct point1 PointStruct point2)

{

return point1.Equals(point2);

}

public static bool operator !=(PointStruct point1, PointStruct point2)



{

return !(point1 == point2);

}

VB

Public Shared Operator =(ByVal point1 As PointStruct, ByVal point2 As PointStruct) As Boolean

Return point1.Equals(point2)

End OperatorPublic Shared Operator <>(ByVal point1 As PointStruct,



ByVal point2 As PointStruct) As Boolean

Return Not (point1 = point2)

End Operator

对于类,需要检查空值。幸运的是,Object.Equals(object, object) 为你处理了这种情况。然后调用已经被重写的 Object.Equals(Object) 方法。

C#

public static bool operator ==(PointClass point1, PointClass point2)

{

return Object.Equals(point1, point2);

}

public static bool operator !=(PointClass point1, PointClass point2)



{

return !(point1 == point2);

}

VB



Public Shared Operator =(ByVal point1 As PointClass, ByVal point2 As PointClass) As Boolean

Return Object.Equals(point1 ,point2)

End OperatorPublic Shared Operator <>(ByVal point1 As PointClass, ByVal point2 As PointClass) As Boolean



Return Not (point1 = point2)

End Operator

性能

你会注意到每个调用链都有点长,尤其是不相等操作符。如果需要考虑性能问题,你可以分别在每个方法中实现比较逻辑来提高速度。这样很容易出错而且使得维护工作比较辣手,所以仅仅当你使用性能检测工具证明了必须这样作之后,才应该这样作。

测试

本文使用了下面的测试。它使用了列表的形式使得你可以方便的将它们翻译到你最喜欢的单元测试框架中。因为相等性很容易被破坏,所以这是单元测试的首要的测试目标。

请注意测试不是全面的。你应该测试左右值部分相等的情况,例如 PointStruct(1, 2) and PointStruct(1, 5)。

Variable Type Value
A PointStruct new PointStruct(1, 2)
a2 PointStruct new PointStruct(1, 2)
B PointStruct new PointStruct(3, 4)
nullValue Object Null


Expression Expected Value

Equal values

a == a2 True
a != a2 False
a.Equals(a2) True
object.Equals(a, a2) True

Unequal values, a on left

b == a False
b != a True
b.Equals(a) False
object.Equals(b, a) False

Unequal values, a on right

a == b False
a != b True
a.Equals(b) False
object.Equals(a, b) False

nulls, a on left

a.Equals(nullValue) False
object.Equals(a, nullValue) False

nulls, a on right

object.Equals(nullValue, a) False

Hash codes

a.GetHashCode() == a2.GetHashCode() True
a.GetHashCode() == b.GetHashCode() Indeterminate
Variable Type Value
a PointClass new PointClass (1, 2)
a2 PointClass new PointClass (1, 2)
b PointClass new PointClass (3,4)
nullValue PointClass Null
nullValue2 PointClass Null


Expression Expected Value
Same Object
a == a True
a != a False
a.Equals(a) True
object.Equals(a, a) True

Equal values

a == a2 True
a != a2 False
a.Equals(a2) True
object.Equals(a, a2) True

Unequal values, a on left

b == a False
b != a True
b.Equals(a) False
object.Equals(b, a) False

Unequal values, a on right

a == b False
a != b True
a.Equals(b) False
object.Equals(a, b) False

nulls, a on left

a == nullValue False
a != nullValue True
a.Equals(nullValue) False
object.Equals(a, nullValue) False

nulls, a on right

nullValue == a False
nullValue != a True
object.Equals(nullValue, a) False

both null

nullValue == nullValue2 True
object.Equals(nullValue, nullValue2) True

Hash codes

a.GetHashCode() == a2.GetHashCode() True
a.GetHashCode() == b.GetHashCode() Indeterminate

阅读英文原文A Detailed look at Overriding the Equality Operator

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论