一文讲透 .NET 中的GetHashCode从一段错误的去重代码说起文章目录一文讲透 .NET 中的 GetHashCode从一段错误的去重代码说起一、一段“诡异”的去重代码二、HashCode 是什么为何如此重要2.1 哈希码的定义2.2 哈希码的黄金法则三、原代码错在哪四、正确的 GetHashCode 应该怎么写4.1 代码逐行解读unchecked 关键字为什么选 17 和 31为什么每次都乘以 31obj.userid?.GetHashCode() ?? 0为什么要用三个字段4.2 简化写法.NET Core 2.1五、常见误区与最佳实践误区1只在 Equals 里写逻辑GetHashCode 随便返回一个常数误区2用可变字段参与哈希码计算误区3不处理 null 值最佳实践总结六、结语当你看到Distinct去重结果不符合预期时很可能不是因为数据有问题而是因为IEqualityComparer中的GetHashCode实现出错了。本文从一个真实代码片段出发深入剖析哈希码的原理、书写规范并给出正确且高性能的实现方式。一、一段“诡异”的去重代码先看下面这段代码它尝试对hr_data_approve对象集合按userid、begintime、endtime三个字段的组合进行去重publicclassApproveDistinctCompare:IEqualityComparerhr_data_approve{publicboolEquals(hr_data_approvex,hr_data_approvey){returnx.begintimey.begintimex.endtimey.endtime;}publicintGetHashCode([DisallowNull]hr_data_approveobj){returnobj.userid.GetHashCode();}}leaveListleaveList.Distinct(newApproveDistinctCompare()).ToList();乍一看似乎没什么问题但实际运行后去重结果完全不符合预期有时本该去重的记录留了下来有时不该去重的却被删掉了。原因就藏在GetHashCode和Equals的不一致上。二、HashCode是什么为何如此重要2.1 哈希码的定义GetHashCode返回一个int类型的数值可以理解为对象的“短指纹”。它的核心作用是为基于哈希表的集合如HashSetT、DictionaryTKey,TValue、LINQ的Distinct()等提供快速定位能力。哈希表的工作原理大致是插入元素时先调用GetHashCode得到哈希码通过hashCode % bucketCount决定将元素放入哪个“桶”。查找时同样计算哈希码直接定位到对应的桶然后在桶内使用Equals逐个比较。2.2 哈希码的黄金法则如果Equals(a, b)返回true那么a.GetHashCode()必须等于b.GetHashCode()。反之不要求不同对象可以哈希码相同这叫“碰撞”。违反这一法则哈希表的行为会变得完全不可预测——因为两个“相等”的对象可能被分配到不同的桶导致Equals永远不会被调用从而误判为不相等。三、原代码错在哪方法期望场景二原代码实现后果Equals比较userid、begintime、endtime只比较begintime和endtime不同用户只要时间相同就被误判为相等GetHashCode基于三个字段计算只基于userid计算相同用户不同时间的对象哈希码相同导致大量碰撞性能下降且与Equals逻辑割裂更严重的是由于Equals和GetHashCode用的字段完全不同违反了哈希码黄金法则两个具有相同begintime/endtime但不同userid的对象Equals返回true而GetHashCode因为userid不同而返回不同值。这会让Distinct内部判断逻辑混乱去重结果随机错误。四、正确的GetHashCode应该怎么写针对场景二基于 userid、begintime、endtime 三者组合去重正确的实现如下publicclassApproveDistinctCompare:IEqualityComparerhr_data_approve{publicboolEquals(hr_data_approvex,hr_data_approvey){if(ReferenceEquals(x,y))returntrue;if(xisnull||yisnull)returnfalse;returnx.useridy.useridx.begintimey.begintimex.endtimey.endtime;}publicintGetHashCode([DisallowNull]hr_data_approveobj){if(objisnull)thrownewArgumentNullException(nameof(obj));unchecked{inthash17;hashhash*31(obj.userid?.GetHashCode()??0);hashhash*31(obj.begintime?.GetHashCode()??0);hashhash*31(obj.endtime?.GetHashCode()??0);returnhash;}}}4.1 代码逐行解读unchecked关键字哈希计算涉及乘法int很容易溢出。unchecked允许溢出时自动回绕wrap around这是哈希算法的正常现象无需抛出异常。为什么选17和31这两个数是素数使用素数可以降低哈希碰撞的概率。31是经典的乘数Java 的Objects.hash()也用 31因为31 * i可被 JIT 优化为(i 5) - i位运算效率高。为什么每次都乘以31避免不同字段顺序产生相同的哈希值。比如(A,B)和(B,A)如果仅累加会得到相同结果而乘以质数再加新字段可以让顺序影响最终哈希值。obj.userid?.GetHashCode() ?? 0处理字段可能为null的情况当userid为null时?.阻止调用GetHashCode表达式返回null?? 0将其替换为0。这样既安全又不会抛空引用异常。为什么要用三个字段必须与Equals基于完全相同的字段组合。因为Equals判断三个字段全部相等才返回true所以只有当三个字段都相同时哈希码也必须相同。这是契约的要求。4.2 简化写法.NET Core 2.1如果你使用的框架版本支持System.HashCode可以大幅简化publicoverrideintGetHashCode()HashCode.Combine(userid,begintime,endtime);VSCode / Visual Studio 还提供了自动生成Equals和GetHashCode的功能右键 → 快速操作 → 生成 Equals/GetHashCode非常推荐使用。五、常见误区与最佳实践误区1只在Equals里写逻辑GetHashCode随便返回一个常数后果所有对象哈希码相同全部落入同一个桶哈希表退化成链表性能从 O(1) 变成 O(n)。误区2用可变字段参与哈希码计算后果对象加入HashSet后如果参与哈希码的字段被修改该对象在哈希表内的位置就会“丢失”再也无法被查找或删除。推荐仅用不可变字段如 Id、创建时间计算哈希码。误区3不处理null值后果当字段为null时调用其GetHashCode会抛出NullReferenceException。始终使用?.GetHashCode() ?? 0或显式判断。最佳实践总结Equals和GetHashCode必须基于完全相同的字段组合。使用质数如 17、31作为初始值和乘数降低碰撞率。用unchecked处理溢出。处理可能为null的字段。优先使用HashCode.Combine或 IDE 生成工具。哈希码计算中使用的字段应为只读或至少不应在对象作为哈希表键时被修改。六、结语GetHashCode看起来只是简单的整数计算但它与Equals共同构成了 .NET 中所有哈希集合的基石。一个小小的不一致就可能让Distinct、HashSet、Dictionary出现匪夷所思的错误。下次再遇到“明明数据一样为什么去重无效”的问题请第一时间检查GetHashCode和Equals是否“言行一致”。记住黄金法则// 如果以下代码输出 true那么 hash1 必须等于 hash2boolequalcomparer.Equals(a,b);inthash1comparer.GetHashCode(a);inthash2comparer.GetHashCode(b);希望这篇文章能帮你彻底理解哈希码写出健壮、高效的自定义比较器。