英文原文:https://thomaslevesque.com/2020/05/15/things-every-csharp-developer-should-know-1-hash-codes/
译者总结:HashCode是为了尽量给这个对象一个独一无二的id。
让对象在哈希表内均匀的分布,使查找时,速度更快
作为一名 C# 开发人员,显然需要掌握很多技能才能发挥作用:语言语法、框架类、第三方库、数据库、正则表达式、HTTP 协议等。 我认为这非常基础,而且我经常看到 C# 开发人员,即使是经验丰富的开发人员,也无法掌握它们。 所以,我正在做一个关于这些事情的系列! 今天:哈希码。
GetHashCode 方法
好吧,我意识到大多数开发人员不需要实现自己的哈希表,甚至不需要经常实现 GetHashCode,但是了解这一点仍然很重要。 每个 C# 类都(直接或间接)继承自 Object,后者仅公开 3 个虚拟方法。 ToString 和 Equals 的目的很明显,但是第三个 GetHashCode 呢?
当我在面试中向候选人询问有关 GetHashCode 的问题时,我通常会得到以下答案:
- “我不知道”(至少没有什么可以忘记的!)
- “它返回对象的 id”
- “它返回一个唯一的值,我可以将其用作Key”
- “它可以用来测试两个对象是否相等”
不! 哈希码不是 ID,它不会返回唯一的值。 当你考虑一下时,这是很明显的:GetHashCode 返回一个 Int32,它“仅”有大约 42 亿个可能的值,并且可能存在无限多个不同的对象,因此其中一些对象必然具有相同的哈希码。
不,它不能用于测试两个对象是否相等。 出于上述原因,具有相同哈希码的两个对象不一定相等。 但它的工作方式却相反:两个相等的对象具有相同的哈希码(或者至少它们应该具有相同的哈希码,如果 Equals 和 GetHashCode 正确实现的话)。
Hash tables(哈希表)
那么,如果 GetHashCode 不返回 id 并且不能用于测试相等性,那么它有什么用呢?
GetHashCode 的存在主要有一个目的:当对象用作哈希表中的键时,用作哈希函数。 好的,但是什么是哈希表? 也许这个术语对您来说不太熟悉,但如果您已经使用 C# 编程了几个小时以上,您可能已经使用过一个:Dictionary<TKey, TValue> 类是最常用的哈希表 执行。 顾名思义,HashSet<T> 也是基于哈希表的。 如果你想要哈希表的完整解释,维基百科有一篇非常好的文章,但我会尝试在这里做一个简短的介绍。
哈希表是一种将值与键关联起来的数据结构。 它可以查找给定键的值,平均时间复杂度为 O(1); 换句话说,通过键查找条目的时间并不取决于哈希表中条目的数量。 总体原则是根据密钥的哈希码将条目放入固定数量的“桶”中。
我们将 B 称为桶的数量,将 H 称为键的哈希码。
向哈希表添加条目如下所示(伪代码):
// 计算密钥的哈希码
H = key.GetHashCode()
// 计算应添加条目的桶的索引
bucketIndex = H mod B
// 将条目添加到该存储桶中
buckets[bucketIndex].Add(entry)
请注意,每个存储桶可以有多个条目,因为存储桶的数量是固定的。 处理此问题的方式因实现而异,但常见的方法是使用链表。
按key搜索条目是按照以下过程完成的:
// 计算密钥的哈希码
H = key.GetHashCode()
// 计算条目所在的存储桶的索引(如果存在)
bucketIndex = H mod B
// 枚举存储桶中的条目以查找其键与我们要查找的键相同的条目
entry = buckets[bucketIndex].Find(key)
哈希表之所以如此高效,是因为查找哪个存储桶包含给定键非常快,并且不依赖于条目数量。 当您拥有存储桶时,其中通常只有很少的条目,因此搜索它们也很快。 例如,如果有 2000 个条目,哈希表有 1000 个桶,则每个桶平均只包含 2 个条目。
但要使其发挥作用,必须满足一个重要条件:条目必须均匀分布在各个存储桶中。 如果 2000 个条目最终位于同一个存储桶中,您将立即找到正确的存储桶,但是您仍然需要遍历 2000 个条目中的每一个条目才能找到正确的条目,因此与简单的列表相比,您什么也得不到 。
如何实现均匀分布
为了确保条目均匀分布在桶中,哈希码值也必须均匀分布。 这就是为什么 GetHashCode 不能只是返回任何随机或任意值,而必须努力为不同的对象返回不同的值。
这通常是通过将被认为相等的值与一些素数相结合来改善分布来完成的。 这是一个例子,灵感来自 Jon Skeet 在 StackOverflow 上的回答:
public class Point3D
{
public Point3D(double x, double y, double z) => (X, Y, Z) = (x, y, z);
public double X { get; }
public double Y { get; }
public double Z { get; }
public override bool Equals(object other)
{
return other is Point3D p
&& p.X == X
&& p.Y == Y
&& p.Z == Z;
}
public override int GetHashCode()
{
unchecked // Allow arithmetic overflow, numbers will just "wrap around"
{
int hashcode = 1430287;
hashcode = hashcode * 7302013 ^ X.GetHashCode();
hashcode = hashcode * 7302013 ^ Y.GetHashCode();
hashcode = hashcode * 7302013 ^ Z.GetHashCode();
return hashcode;
}
}
}
上面的 Point3D 类有 3 个属性。 要被视为相等,Point3D 的两个实例必须具有相同的这些属性值。 为了计算哈希码,我们使用被视为相等的相同属性(这对于确保相等的对象具有相同的哈希码很重要)。 然后使用异或和与素数相乘来组合这些值。 我不确定为什么使用素数很重要,但确实如此; 如果你的数学比我好,也许你可以猜猜! 当然,您可以使用与上面显示的值不同的值,只需确保它们是素数即可。
更简单的方法
上面显示的实现很好,但是在不查看参考实现的情况下要正确执行有点棘手。 另外,您需要小心空值。
幸运的是,有一个非常简单的替代方案:元组! 使用 C# 7 中引入的值元组,通过利用元组的 GetHashCode 方法可以使 GetHashCode 实现变得更加简单:
public override int GetHashCode() => (X, Y, Z).GetHashCode();
请注意,它还可以用于稍微简化 Equals:
public override bool Equals(object other) =>
other is Point3D p && (p.X, p.Y, p.Z).Equals((X, Y, Z));
结尾
嗯,这篇文章比我预期的要长一些。 希望你能从中学到一些东西! 根据我的灵感,我可能会再写几篇这样的文章。 我已经有了一些想法(引用类型与值类型、内存管理……),但请随意建议其他主题。 我不能保证我会涵盖它们,但至少我会考虑它们!