Effective C# Item 10 : Understand the Pitfalls of GetHashCode()

      这是本书中唯一一项不推荐大家使用的函数。如果不是为哈希序列(例如HashTable和Dictionary)设定键(key)的话,我们应当尽量避免使用GetHashCode()。这是因为当基类调用GetHashCode时会产生一系列的问题。对于引用类型来说,它可以工作但是效率不高。对于值类型来说结果经常就是错误的。我们不能写出同时兼顾高效和正确的GetHashCode()方法。没有一个方法向它一样引起这么多的讨论和混乱。

      如果我们的类型没有在容器中被当作键来使用,那么不会有任何问题。Windows Form控件,Web控件,数据库链接之类不太可能会被当作键来使用。在这种情况下,我们什么也不做。所有的引用类型都会有自己的(哈希码)hash code。对于值类型来说是恒定值,虽然效率不高,但是还可以工作。而对于大多数我们自己创建的类型,我们应当尽量避免出现GetHashCode()方法。

      如果我们必须要创建一个用于哈希表中键的类型,那我们需要为它提供GetHashCode()方法。基于Hash的容器使用哈希码来优化搜索。每一个容器内的对象都包含一个整数值,称为哈希码。基于这些哈希码的值,对象被存储在被称为bucket(有的称其为哈希表元或桶)的列表中。当我们需要寻找对象的时候,通过哈希码就可以找到所对应的列表。在.Net中,所有对象都有其哈希码,源于System.Object.GetHashCode()。任何一个GetHashCode()方法应当遵循下面三条原则:

           1、如果两个对象相等,那么它们的哈希码应该相等。这是很显然的,不然我们就无法通过哈希码进行搜索了。

           2、对于任何对象,GetHashCode()返回的必须是一个恒量。不论被什么方法调用,他必须返回同样的值,我们必须要确保对象保存在一个正确的bucket中。

           3、对于所有的输入,GetHashCode()返回的应当是随机分配的,这有助于提高哈希表的效率。

      一个正确且高效的哈希函数需要确保以上三项条件。在System.Object和System.ValueType中定义的版本是有一些缺点的。Object.GetHashCode()使用System.Object内部字段来获得哈希码值。每当一个新的对象被创建,就会产生一个独一无二的整数型的键值。这些键值从1开始,每次有新对象创建的时候自增1。这些对象的标识是在System.Object被创建时候设置的,不能在之后对其进行修改。通过Object.GetHashCode()我们可以得到它的标识值,并将它作为对象的哈希码。

      我们现在来用这三条标准来检验一下Object.GetHashCode()是否符合要求。除非我们重写了==操作,不然对于两个相等对象,GetHashCode()会返回同样的值。==操作检查对象的地址,GetHashCode()返回对象的内部标识。如果我们重写了==,那么我们也必须相应的调整我们的GetHashCode()来确保符合第一条原则。

      对于第二条原则,由于内部字段的值是不能修改的,哈希码的值也不能修改。这一条是满足的。

      第三条原则要求在整数范围内随机取值,这点就不满足了。因为产生的数列并不是随机的,除非我们创建了非常庞大数量的对象。

      这就表示Object.GetHashCode()的结果是正确的,但是效率是不高的。如果我们创建了一个基于我们自定义的引用类型的哈希表,它将正确的工作,但是速度较慢。当我们要使用引用类型做为哈希表的键值时,我们应当重写GetHashCode()方法来获得更加离散的整数来做为哈希码值。

      对于值类型,下面有一个小例子来检验System.ValueType.GetHashCode()是否符合那三条原则。值类型的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();

      第一条原则规定如果两个对象相等,那么它们应当返回相同的值。对于值类型来说,同引用类型的情况比较类似。由于它是根据第一个成员来生成哈希码值,所以我们应当重写它的==运算符。当我们重定义的==运算符是通过比较第一个成员来进行判断的时候,它就能正常工作。如果第一个成员没有参与相等的判断,那么就违反了第一条原则。

      第二条要求生成的值必须是个恒量。只有第一个成员是不可变的类型可以满足这个原则。如果我们修改了它第一个成员的值,那么哈希码也会发生变化。这不符合第二条原则的规定。这也是为什么我们说应当在值类型中使用不可变类型成员的原因。(P.S. 关于不可变类型可以看一下 Item7: Prefer Immutable Atomic Value Types )

      第三条原则是否满足要看类型中第一个成员的类型。如果我们的类型中第一个成员都不相同,那么也许会产生比较随机的值。但是一旦我们的值类型的第一个成员的值相等,那么GetHashCode()返回的值也就相等,这条原则基本上也不满足。

public struct MyStruct
{
      
private DateTime _epoch;
      
private string _msg;
      
private int _id;
      
//属性略
}

      如果_epoch被设定为当前日期(只包括年月日),那么在同样日期创建的对象都拥有同样的哈希码值。

      总结一下我们发现,对于引用类型来说GetHashCode()虽然效率不高,但还是可以工作的。对于值类型来说只有在第一个成员是只读的时候会正常工作。只有在第一个成员的值比较随机的时候才会有较好的效率。

      如果我们希望创建一个较为优化的哈希码,我们必须为我们的类型添加一些制约。我们根据三条原则来重新实现GetHashCode()。

      首先,如果两个对象相等,那么它们应当返回相同的哈希码值。用于生成哈希码值的任何属性或数值也应当满足这个要求,这就是说我们要用在定义“相等”关系时用到的属性或数据来生成哈希码的值。有可能有的属性参与了“相等”判断,但是没有参加哈希码的生成。System.ValueType默认的情况下就是如此,这样可能会违背第三条原则。参与判断“相等”的属性和成员也应当参与到生成哈希码的运算中。

      其次,GetHashCode()返回的值必须是不可变的。下例中我们定义一个名为Customer的引用类型:

        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);
//这里出现错误
c1.Name = "Acme Software"

      c1就从原有的myHashMap中失踪了。当我们将c1加myHashMa时,哈希码是根据字符串”Acme Products”生成的。当我们将它改成”Acme Software”时,哈希码也发生了变化。这个customer数据丢失是因为生成哈希码的成员不是不可变的。

      对于值类型来说,它出的错误与引用类型不同。如果c1是值类型,c1的一个拷贝被放在myHashMap中。当我们改变c1的值的时候,对于myHashMap中的c1并没有发生任何改变。由于在boxing和unboxing时发生了拷贝,所以对于值类型来说,我们在将其添加到哈希表中之后再修改其值就没有任何意义。

      想要满足原则2就需要使用一些不可变的属性来生成哈希码值。对于引用类型来说使用的是对象的地址,这是不可变的。对于值类型来说使用的是第一个成员,我们也希望这个成员是不可变的。除了将我们的类型定义为不可变类型之外,我们没有什么更好的方法。当我们在哈希表中使用一个值类型来做为键,那么这个值类型就应当是不可变类型。一旦违反了这一点,我们在哈希表中使用这些类型做为键值的时候就会发生问题。我们重新修改Customer类,现在我们将_name设为readonly:

        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;
                }

            }

            
public Customer ChangeName(string newName)
            
{
                
return new Customer(newName,_revenue);
            }

            
public override int GetHashCode()
            
{
                
return _name.GetHashCode();
            }

        }

      将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);

      我们需要将原有的对象移除,再将新对象加入哈希表中。这样虽然比原来繁琐,但是它至少能正常工作,而前一个版本得到的结果是错误的。我们通过程序强迫使用者在修改对象内容时进行这些操作,就可以通过不可变的对象成员来生成新的哈希码的值。只有这样才能保证其正常工作。我们必须要确保用来计算哈希码值的对象成员是不可变的。

      第三条原则规定GetHashCode()返回的应当是在整数范围内的随机值。这要取决于我们创建的类型。很遗憾的是System.Object中想返回这样神奇的数并不容易。一般来说比较简易的算法是通过对类型中成员的值求异或(XOR)。我们不应当将类型中存在的一些易变成员加入到这个计算当中。

      GetHashCode()有着非常特殊的要求:相等对象返回相等的值,值必须恒定,返回的值的分布需要足够均匀。这几点只对不可变类型起作用(P.S. 返回值随机这个要求是很难达到的,第三条原则属于锦上添花类型的,虽然效率不高,但至少不会出错)。我们在使用的时候应当注意不要掉入GetHashCode()的陷阱之中。

      译自   Effective C#:50 Specific Ways to Improve Your C#                      Bill Wagner著

P.S.
      今天过生日了,先祝自己生日快乐~ 呵呵

      GetHashCode()方法和Equals()有密切的关系。如果我们重写了GetHashCode(),那么我们必须重写这个Equals(),来保证相等的对象具有相同的哈希码值。这是MSDN中说的,但是我还是有些疑惑,如果我们的类型重写了Equals(),又要用到哈希码的话,那么重写GetHashCode()理所应当,因为重写的Equals()已经改变了我们的类型的“相等”的概念,重写保证了相等对象的返回值相等。但是如果我们的类型重写了GetHashCode(),那为什么还“必须”重写Equals()呢?当然对于我们定义的类型来说,Equals()是有必要重写的,但是这个不是因为GetHashCode()吧...
      哈希码的值分布在Int32(-2,147,483,648 到 +2,147,483,647 之间的有符号整数)范围内,我们生成重写的哈希码也应当在这个范围内。对于值类型来说,如果这样写的话就会超出这个范围:

        struct MyStruct
        
{
            
private long num;

            
public long Num
            
{
                
get
                
{
                    
return num;
                }

                
set
                
{
                    num 
= value;
                }

            }

        }

      对于这个类型,默认的GetHashCode()是基于第一个成员num来生成的。对于Int32及其范围之内的类型来说,返回值等于num的值。但是上例中num的范围超出了Int32的范围,这就可能会造成一些小麻烦。比如我下面这个例子:

MyStruct myStruct1 = new MyStruct();
MyStruct myStruct2 
= new MyStruct();
myStruct1.Num 
= -2147483648;
myStruct2.Num 
= 2147483647;

Console.WriteLine(myStruct1.GetHashCode());
Console.WriteLine(myStruct2.GetHashCode());

      两个不相等的实例得到的结果却相等,都是2147483647。对应这种情况我们可以使用XOR操作来合并它的高低位来返回,但是也是不能绝对保证不发生重复的,当然这种重复不会造成hashtable出错。

      回到目录

posted on 2006-09-21 10:51  aiya  阅读(1099)  评论(1编辑  收藏  举报