函数GetHashCode()的缺陷

来源:互联网 发布:淘宝新店怎么升级 编辑:程序博客网 时间:2024/05/16 08:43
本条款讨论函数GetHashCode()的缺陷,这种情况在全书中是唯一的。幸运的是,GetHashCode()函数只应用在一个地方:为一个基于散列(hash)的集合定义键的散列值,典型的集合为Hashtable或Dictionary容器。因为基类的GetHashCode()实现有很多问题。对引用类型来讲,它可以正常工作,但是效率很低。对于值类型来讲,基类中的实现通常是不正确的。更为糟糕的是,我们编写的GetHashCode()不可能既有效率又正确。没有哪个函数能比GetHashCode()产生更多的讨论和混淆。下面的内容可以帮助大家理清这些混淆。

如果我们定义的类型在容器中不会被当作键来使用,那就没什么问题。例如,表示窗口控件、Web页面控件或者数据库连接的类型就不太可能被当作键在集合中使用。在这些情况下,我们无需做任何事情。所有的引用类型都有一个正确的散列码,尽管效率非常低下。值类型应该具有常量性(参见条款7),这时候默认的实现也可以正常工作,虽然效率也不高。在我们创建的大多数类型中,最好的做法就是完全避免自己实现GetHashCode()。

如果有一天,我们创建的类型要被当作散列表(hash table)中的键使用,我们就需要自己实现GetHashCode()。基于散列的容器使用散列码来优化查询。每一个对象都会产生一个称做散列码的整数值。基于散列码的值,对象会被装进一些“散列桶(bucket)”中。要搜索一个对象,我们会请求它的键,并在其对应的“散列桶”中搜索。在.NET中,每一个对象都有一个散列码,其值由System.Object.GetHashCode()决定。任何GetHashCode()的重载[21]版本都必须遵循以下三条规则:

1. 如果两个对象相等(由operator==定义),它们必须产生相同的散列码。否则,这样的散列码不能用来查找容器中的对象[22]。

2. 对于任何一个对象A,A.GetHashCode()必须是一个实例不变式(invariant)。即不管在A上调用什么方法,A.GetHashCode()都必须总是返回相同的值。这可以确保放在“散列桶”中的对象总是位于正确的“散列桶”中。

3. 对于所有的输入,散列函数应该在所有整数中产生一个随机的分布。这样,我们才能从一个散列容器上获得效率的提升。

要编写一个正确且有效率的散列函数,我们需要对类型有充分的知识才能确保遵循第3条规则。System.Object和System.ValueType中定义的GetHashCode()没有这方面的优势,因为它们必须在对具体类型几乎一无所知的情况下,提供最合适的默认行为。Object.GetHashCode()使用System.Object中的一个内部字段来产生散列值。系统创建的每一个对象在创建时都会有一个唯一的对象键(一个整数值)。这些键从1开始,每创建一个新的对象(任何类型),便会随之增长。对象标识字段会在System.Object构造器中设置,并且之后不能被更改。对于一个给定的对象,Object.GetHashCode()会返回该值作为散列码。

现在让我们对照上述三条规则,逐一检查Object.GetHashCode()方法。在没有重写operator==的情况下,如果两个对象相等[23],那么Object.GetHashCode()会返回同样的散列值。System.Object的operator==()判断的是对象标识。GetHashCode()会返回内部的对象标识字段。这样做是可行的。但是,如果我们实现了自己的operator==,那就也必须实现GetHashCode(),以确保遵循第一条规则。有关相等判断,参见条款9。

Object.GetHashCode()默认会满足第二条规则:在对象创建之后,其散列码永远不会改变。

第三条规则:对于所有的输入,散列函数应该在所有整数中产生一个随机的分布,Object.GetHashCode()不能被满足该规则。除非我们创建了数量非常多的对象,否则一个数值序列在所有整数中不会是一个随机的分布。Object.GetHashCode()返回的散列码会集中在整数范围的低端。

这意味着Object.GetHashCode()的实现是正确的,但是效率不够好。如果我们基于自己实现的一个引用类型创建了一个散列表,System.Object的默认行为可以使我们的散列表正常工作,但是会比较慢。当我们创建的引用类型要用于散列键时,我们应该重写其GetHashCode()方法,使其产生的散列值在所有整数范围内有一个良好的分布。

在谈论如何实现我们自己的GetHashCode()方法之前,我们先来对照上述三条规则,看看ValueType.GetHashCode()方法。System.ValueType重写了GetHashCode()方法,为所有值类型提供了一种默认的实现。默认的实现会返回类型中定义的第一个字段的散列码。看下面的例子:

public struct MyStruct

{

private string _msg;

private int _id;

private DateTime _epoch;

}

MyStruct对象返回的散列码实际等同于_msg字段的散列码。下面的代码将总是返回true:

MyStruct s = new MyStruct();

return s.GetHashCode() == s._msg.GetHashCode();

第一条规则为:两个相等(由operator==()定义)的对象也必须有相同的散列码。在绝大多数情况下,我们的值类型会满足该规则。但是我们也可能会破坏该规则,就像我们在引用类型中所做的那样。默认情况下,ValueType.operator==()会比较结构中的第一个字段和其他每个字段。这将可以满足第一条规则。如果我们在自己的结构中定义的operator==使用了第一个字段,那么也可以满足第一条规则。但是,如果结构的第一个字段没有参与类型的“相等判断”,那就违背了第一条规则,从而会破坏GetHashCode()方法[24]。

第二条规则要求散列码必须是一个实例不变式。只有当结构中的第一个字段是一个常量字段时,该规则才会被满足。如果结构的第一个字段发生变化,那么结构的散列码也会随之改变。这就打破了该规则。是的,只要结构中的第一个字段在对象的生存期中发生了改变,那么GetHashCode()方法将遭到破坏。这是“尽可能实现具有常量性的值类型”的又一个理由(参见条款7)。

第三条规则依赖于第一个字段的类型,以及其使用情况。如果第一个字段会在所有的整数中产生一个随机的分布,同时第一个字段也会随机分布于结构的所有实例中,那么GetHashCode()将会为结构产生一个比较均匀的分布。但是,如果第一个字段经常都是相同的值,那就将违反第三条规则。下面对前面的代码做了一个很小的更改:

public struct MyStruct

{

private DateTime _epoch;

private string _msg;

private int _id;

}

如果_epoch字段被设置为当前的日期(不包括时间),那么在一个特定日期中创建的MyStruct对象将拥有相同的散列码。这就会破坏散列码的均匀分布。



我们来总结一下Object.GetHashCode()方法的默认行为:对于所有的引用类型,该方法会正常工作,虽然它并不必然会产生一个高效的分布。(如果我们重写了Object.operator ==(),将会破坏GetHashCode()方法。)只有在结构类型的第一个字段是只读的情况下,ValueType类型的GetHashCode()才会正常工作。只有当第一个字段包含的值分布于“其输入的一个有意义的子集上”时,ValueType.GetHashCode()才会产生一个比较高效的散列码。

如果我们要创建一个更好的散列码,就需要为类型添加一些约束。我们再来对照上述三条规则,看看如何创建一个能够良好工作的GetHashCode()实现。

首先,如果两个对象相等(由operator==()定义),它们必须返回相同的散列码。任何用来产生散列码的属性或者数据值,都必须参与到类型的相等判断中。很明显,这意味着会有相同的属性同时用于相等判断和散列码计算中。但并非所有参与相等判断的属性,都会用来做散列码计算。System.ValueType的默认行为就是这样。但是,这又通常意味着会违反第三条规则。同样的数据元素应该同时参与到两种计算中。

第二条规则是GetHashCode()的返回值必须是一个实例不变式。假设我们定义了如下的引用类型:

public class Customer

{

private string _name;

private decimal _revenue;

public Customer( string name )

{

_name = name;

}

public string Name

{

get { return _name; }

set { _name = value; }

}

public override int GetHashCode()

{

return _name.GetHashCode();

}

}

再假设我们执行了如下的代码:

Customer c1 = new Customer( "Acme Products" );

myHashMap.Add( c1, orders );

// Name错了:

c1.Name = "Acme Software";

在上面的代码中,c1会在myHashMap中的某个地方丢失。当我们将c1放入myHashMap中时,散列码会根据字符串"Acme Products"来产生。但在我们将Name更改为"Acme Software"之后,散列码也会更改。新的散列码会根据"Acme Software"来产生。这时候c1仍然存储在由"Acme Products"定义的“散列桶”中,虽然实际上它应该存储在由"Acme Software"定义的“散列桶”中。我们将会在集合中失去c1这个Customer。丢失的原因在于散列码不再是一个对象不变式。因为在存储对象之后,我们更改了它的“散列桶”。

只有在Customer是一个引用类型时,上述问题才会出现。值类型的行为有所不同,但是仍然会引起问题。如果Customer是一个值类型,将有一个c1的副本被存储在myHashMap中。最后一行对Name的改变将不会对存储在myHashMap中的副本产生任何效果。由于装箱和拆箱都会导致复制,因此“想在一个值类型对象被添加到一个集合中之后,再改变其成员”几乎是不可能的。

解决第二条规则的唯一方式就是让散列码函数根据对象中的常量属性(一个或多个)返回一个值。System.Object使用对象标识来遵循该规则,因为对象标识不可能改变。System.ValueType希望我们类型中的第一个字段不会改变。如果不将类型实现为常量类型,我们将无法很好地满足该规则。当我们定义的值类型要用作一个散列集合中的键时,它必须是一个常量类型。如果不遵循这种推荐,将类型作为键的用户将有可能破坏散列表。下面的代码对Customer类进行了修正,让我们能够在改变它的同时维持其Name属性的常量性:

public class Customer

{

private readonly string _name;

private decimal _revenue;

public Customer( string name ) :

this ( name, 0 )

{

}

public Customer( string name, decimal revenue )

{

_name = name;

_revenue = revenue;

}

public string Name

{

get { return _name; }

}

// 改变Name,返回一个新对象:

public Customer ChangeName( string newName )

{

return new Customer( newName, _revenue );

}

public override int GetHashCode()

{

return _name.GetHashCode();

}

}

将Name实现为常量属性后,我们改变Customer.Name属性的方式必须做如下的变动:

Customer c1 = new Customer( "Acme Products" );

myHashMap.Add( c1,orders );

// Name错了:

Customer c2 = c1.ChangeName( "Acme Software" );

Order o = myHashMap[ c1 ] as Order;

myHashMap.Remove( c1 );

myHashMap.Add( c2, o );

我们必须删除原来的Customer,改变其名字,然后再将新的Customer对象添加到散列表中。这看起来要比第一个版本麻烦,但是它可以正常工作。前一个版本会导致程序员编写不正确的代码。通过将“用于散列码计算的属性”强制实现为常量属性,我们可以确保正确的行为。类型的用户将不可能在这个问题上犯错。是的,这个版本需要的编码更多。我们强制让开发人员编写更多的代码,因为这是编写正确代码的唯一方式。我们要确保任何用于散列码计算的数据成员都不会改变。

第三条规则要求GetHashCode()对于所有的输入,应该在所有整数中产生一个随机的分布。满足这条规则依赖于所创建的具体类型。如果有这样的万能公式存在,那么System.Object就会实现它了,可惜事实上没有。一个常用且成功的算法是对一个类型中的所有字段调用GetHashCode()返回的值进行XOR(异或)。如果我们的类型中含有可变字段,那么应该在计算时排除它们。

GetHashCode()有非常特殊的需求:相等的对象必须产生相同的散列码;散列码必须是对象不变式;必须产生一个均匀的分布以获得比较好的效率。只有具有常量性的类型,这三个需求才可能全部被满足。对于其他类型,我们要依赖默认的行为,但是要清楚理解其缺陷。

0 0
原创粉丝点击