三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

一文讲透 .NET 中的 `GetHashCode`:从一段错误的去重代码说起

一文讲透 .NET 中的 `GetHashCode`:从一段错误的去重代码说起

一文讲透 .NET 中的GetHashCode:从一段错误的去重代码说起

文章目录

  • 一文讲透 .NET 中的 `GetHashCode`:从一段错误的去重代码说起
    • 一、一段“诡异”的去重代码
    • 二、`HashCode` 是什么?为何如此重要?
      • 2.1 哈希码的定义
      • 2.2 哈希码的黄金法则
    • 三、原代码错在哪?
    • 四、正确的 `GetHashCode` 应该怎么写?
      • 4.1 代码逐行解读
        • `unchecked` 关键字
        • 为什么选 `17` 和 `31`?
        • 为什么每次都乘以 `31`?
        • `obj.userid?.GetHashCode() ?? 0`
        • 为什么要用三个字段?
      • 4.2 简化写法(.NET Core 2.1+)
    • 五、常见误区与最佳实践
      • 误区1:只在 `Equals` 里写逻辑,`GetHashCode` 随便返回一个常数
      • 误区2:用可变字段参与哈希码计算
      • 误区3:不处理 `null` 值
      • 最佳实践总结
    • 六、结语

当你看到Distinct去重结果不符合预期时,很可能不是因为数据有问题,而是因为IEqualityComparer中的GetHashCode实现出错了。本文从一个真实代码片段出发,深入剖析哈希码的原理、书写规范,并给出正确且高性能的实现方式。

一、一段“诡异”的去重代码

先看下面这段代码,它尝试对hr_data_approve对象集合按useridbegintimeendtime三个字段的组合进行去重:

publicclassApproveDistinctCompare:IEqualityComparer<hr_data_approve>{publicboolEquals(hr_data_approvex,hr_data_approvey){returnx.begintime==y.begintime&&x.endtime==y.endtime;}publicintGetHashCode([DisallowNull]hr_data_approveobj){returnobj.userid.GetHashCode();}}leaveList=leaveList.Distinct(newApproveDistinctCompare()).ToList();

乍一看似乎没什么问题,但实际运行后去重结果完全不符合预期:有时本该去重的记录留了下来,有时不该去重的却被删掉了。原因就藏在GetHashCodeEquals不一致上。

二、HashCode是什么?为何如此重要?

2.1 哈希码的定义

GetHashCode返回一个int类型的数值,可以理解为对象的“短指纹”。它的核心作用是为基于哈希表的集合(如HashSet<T>Dictionary<TKey,TValue>LINQDistinct()等)提供快速定位能力

哈希表的工作原理大致是:

  1. 插入元素时,先调用GetHashCode得到哈希码,通过hashCode % bucketCount决定将元素放入哪个“桶”。
  2. 查找时,同样计算哈希码,直接定位到对应的桶,然后在桶内使用Equals逐个比较。

2.2 哈希码的黄金法则

如果Equals(a, b)返回true,那么a.GetHashCode()必须等于b.GetHashCode()
反之不要求(不同对象可以哈希码相同,这叫“碰撞”)。

违反这一法则,哈希表的行为会变得完全不可预测——因为两个“相等”的对象可能被分配到不同的桶,导致Equals永远不会被调用,从而误判为不相等。

三、原代码错在哪?

方法期望(场景二)原代码实现后果
Equals比较useridbegintimeendtime只比较begintimeendtime不同用户只要时间相同就被误判为相等
GetHashCode基于三个字段计算只基于userid计算相同用户不同时间的对象哈希码相同,导致大量碰撞,性能下降,且与Equals逻辑割裂

更严重的是,由于EqualsGetHashCode用的字段完全不同,违反了哈希码黄金法则:两个具有相同begintime/endtime但不同userid的对象,Equals返回true,而GetHashCode因为userid不同而返回不同值。这会让Distinct内部判断逻辑混乱,去重结果随机错误。

四、正确的GetHashCode应该怎么写?

针对场景二(基于 userid、begintime、endtime 三者组合去重),正确的实现如下:

publicclassApproveDistinctCompare:IEqualityComparer<hr_data_approve>{publicboolEquals(hr_data_approvex,hr_data_approvey){if(ReferenceEquals(x,y))returntrue;if(xisnull||yisnull)returnfalse;returnx.userid==y.userid&&x.begintime==y.begintime&&x.endtime==y.endtime;}publicintGetHashCode([DisallowNull]hr_data_approveobj){if(objisnull)thrownewArgumentNullException(nameof(obj));unchecked{inthash=17;hash=hash*31+(obj.userid?.GetHashCode()??0);hash=hash*31+(obj.begintime?.GetHashCode()??0);hash=hash*31+(obj.endtime?.GetHashCode()??0);returnhash;}}}

4.1 代码逐行解读

unchecked关键字
  • 哈希计算涉及乘法,int很容易溢出。unchecked允许溢出时自动回绕(wrap around),这是哈希算法的正常现象,无需抛出异常。
为什么选1731
  • 这两个数是素数,使用素数可以降低哈希碰撞的概率。
  • 31是经典的乘数(Java 的Objects.hash()也用 31),因为31 * i可被 JIT 优化为(i << 5) - i,位运算效率高。
为什么每次都乘以31
  • 避免不同字段顺序产生相同的哈希值。比如("A","B")("B","A")如果仅累加,会得到相同结果;而乘以质数再加新字段,可以让顺序影响最终哈希值。
obj.userid?.GetHashCode() ?? 0
  • 处理字段可能为null的情况:当useridnull时,?.阻止调用GetHashCode,表达式返回null?? 0将其替换为0。这样既安全又不会抛空引用异常。
为什么要用三个字段?
  • 必须与Equals基于完全相同的字段组合。因为Equals判断三个字段全部相等才返回true,所以只有当三个字段都相同时,哈希码也必须相同。这是契约的要求。

4.2 简化写法(.NET Core 2.1+)

如果你使用的框架版本支持System.HashCode,可以大幅简化:

publicoverrideintGetHashCode()=>HashCode.Combine(userid,begintime,endtime);

VSCode / Visual Studio 还提供了自动生成EqualsGetHashCode的功能(右键 → 快速操作 → 生成 Equals/GetHashCode),非常推荐使用。

五、常见误区与最佳实践

误区1:只在Equals里写逻辑,GetHashCode随便返回一个常数

  • 后果:所有对象哈希码相同,全部落入同一个桶,哈希表退化成链表,性能从 O(1) 变成 O(n)。

误区2:用可变字段参与哈希码计算

  • 后果:对象加入HashSet后,如果参与哈希码的字段被修改,该对象在哈希表内的位置就会“丢失”,再也无法被查找或删除。推荐仅用不可变字段(如 Id、创建时间)计算哈希码

误区3:不处理null

  • 后果:当字段为null时调用其GetHashCode会抛出NullReferenceException。始终使用?.GetHashCode() ?? 0或显式判断。

最佳实践总结

  1. EqualsGetHashCode必须基于完全相同的字段组合
  2. 使用质数(如 17、31)作为初始值和乘数,降低碰撞率。
  3. unchecked处理溢出。
  4. 处理可能为null的字段。
  5. 优先使用HashCode.Combine或 IDE 生成工具。
  6. 哈希码计算中使用的字段应为只读(或至少不应在对象作为哈希表键时被修改)。

六、结语

GetHashCode看起来只是简单的整数计算,但它与Equals共同构成了 .NET 中所有哈希集合的基石。一个小小的不一致,就可能让DistinctHashSetDictionary出现匪夷所思的错误。下次再遇到“明明数据一样,为什么去重无效”的问题,请第一时间检查GetHashCodeEquals是否“言行一致”。

记住黄金法则

// 如果以下代码输出 true,那么 hash1 必须等于 hash2boolequal=comparer.Equals(a,b);inthash1=comparer.GetHashCode(a);inthash2=comparer.GetHashCode(b);

希望这篇文章能帮你彻底理解哈希码,写出健壮、高效的自定义比较器。

← 返回列表