为什么System.String对象不能缓存其哈希代码?

时间:2010-06-16 13:37:38

标签: .net string immutability hashcode gethashcode

使用string.GetHashCode查看Reflector的源代码会显示以下内容(对于mscorlib.dll版本4.0):

public override unsafe int GetHashCode()
{
    fixed (char* str = ((char*) this))
    {
        char* chPtr = str;
        int num = 0x15051505;
        int num2 = num;
        int* numPtr = (int*) chPtr;
        for (int i = this.Length; i > 0; i -= 4)
        {
            num = (((num << 5) + num) + (num >> 0x1b)) ^ numPtr[0];
            if (i <= 2)
            {
                break;
            }
            num2 = (((num2 << 5) + num2) + (num2 >> 0x1b)) ^ numPtr[1];
            numPtr += 2;
        }
        return (num + (num2 * 0x5d588b65));
    }
}

现在,我意识到the implementation of GetHashCode is not specified and is implementation-dependent,所以问题“是GetHashCode以X或Y的形式实现了吗?”真的不负责任。我只是对一些事情感到好奇:

  1. 如果Reflector已正确反汇编DLL并且此 GetHashCode的实现(在我的环境中),我是否正确解释此代码以指示string对象,基于这个特定的实现,不会缓存其哈希码?
  2. 假设答案是肯定的,为什么会这样?在我看来,内存成本将是最小的(一个32位整数,池中的一个下降与字符串本身的大小相比),而节省将是显着的,尤其是在例如使用字符串的情况下作为基于散列表的集合中的键,如Dictionary<string, [...]>。由于string类是不可变的,因此GetHashCode返回的值永远不会改变。
  3. 我能错过什么?


    更新:回应Andras Zoltan的结束语:

      

    Tim的观点也有   回答(+1那里)。如果他是对的,我   认为他是,那么就没有保证   字符串实际上是不可变的   施工后,因此要缓存   结果是错误的。

    哇,那里!这是一个有趣的观点(和yes it's very true),但我真的怀疑GetHashCode的实现中考虑了这一点。声明“因此缓存结果将是错误的”对我来说意味着框架对字符串的态度是“嗯,它们应该是不可变的,但实际上如果开发人员想偷偷摸摸它们”可变,所以我们会这样对待它们。“ 这绝对不是框架查看字符串的方式。它完全依赖于它们在很多方面的不变性(字符串文字的实习,将所有零长度字符串赋值给string.Empty等),基本上,如果你改变一个字符串,你就会编写其行为的代码是完全不明确和不可预测的。

    我想我的观点是,对于这个实现的作者来说,担心“如果在调用之间修改了这个字符串实例怎么办,即使公开公开的类是不可变的?”对于那些计划休闲户外烧烤的人来说,想想他/她自己,“如果有人带来原子弹到聚会怎么办?”看,如果有人带来原子弹,派对结束了。

6 个答案:

答案 0 :(得分:28)

显而易见的潜在答案:因为这将耗费记忆。

这里有成本/收益分析:

成本:每个字符串4个字节(以及每次调用GetHashCode的快速测试)。也使字符串对象变得可变,这显然意味着你需要小心 实现 - 除非你总是预先计算哈希代码,这是为每个字符串计算一次的成本,无论你是否曾经哈希它。< / p>

好处:避免重新计算散列值,以便对字符串值进行多次哈希

我建议在很多情况下,有许多很多字符串对象,而且很少有字符串对象被多次散列 - 导致净成本。在某些情况下,显然情况并非如此。

我不认为我能够更好地判断哪些更频繁出现...我希望MS已经为各种真实的应用程序提供了工具。 (我也希望Sun对Java做同样的事情, 缓存哈希...)

编辑:我刚刚和Eric Lippert谈过这个问题(NDC很棒:)基本上它 关于额外的内存命中率和有限的收益。

答案 1 :(得分:13)

首先 - 不知道缓存此结果是否会真正改善Dictionary<string, ...>等,因为它们不一定使用String.GetHashCode,因为它使用IComparer来获取字符串的哈希码。

如果您遵循StringComparer类的可能调用链,它最终会进入System.Globalization.CompareInfo类,该类最终以此方法终止:

[SecurityCritical, SuppressUnmanagedCodeSecurity, DllImport("QCall",
   CharSet=CharSet.Unicode)]
private static extern int InternalGetGlobalizedHashCode(IntPtr handle, string
   localeName, string source, int length, int dwFlags);

不知道该库(看起来是本机方法)是否使用某种形式的内部缓存,这种内部缓存基于我们在.Net运行时内无法立即获取的底层.Net对象数据结构。

但是,需要注意的重要一点是,根据您选择解释字符的方式,一个字符串可以包含许多不同的哈希码。当然,这种实现具有文化特异性 - 这就是为什么它不适合这些比较器。

所以,虽然额外的内存可能是一个因素,但实际上我认为这是因为存储哈希码以及字符串实例误导了调用者,实际上是.Net内部开发团队(!),认为字符串只有一个哈希码,实际上它完全取决于你将如何解释它 - 作为一系列字节(我们大多数人没有),或作为一个系列可印刷字符。

从性能的角度来看,如果我们也接受Dictionary<,>等使用的这些比较器不能使用内部实现,则不缓存此结果可能没有太大的影响,因为坦率地说,这种方法在现实世界中实际调用的频率是多少:因为大多数情况下字符串的哈希码很可能是通过其他机制计算的。

修改

蒂姆的回答中也提出了一点(+1那里)。如果他是对的,我认为他是,那么在构造之后不能保证字符串实际上是不可变的,因此缓存结果将是错误的。

其他编辑(!)

Dan指出字符串在Net球体中是不可变的,因此该字符串应该可以自由地根据它来缓存它自己的哈希码。这里的问题是.Net框架还提供了一种 合法的方式来改变不涉及特权反射或其他任何东西的所谓不可变字符串 。这是字符串的基本问题,它是指向无法控制的缓冲区的指针。不用担心在C#世界中,在C ++中,向量和修改内存缓冲区是常见的。仅仅因为你理想情况下不应该这样做并不意味着框架应该指望你不要这样做。

.Net碰巧提供了这种功能,因此,如果这是由.Net团队做出的设计决定,以回应蒂姆建议的那种二元犯罪,那么他们考虑到这一点是非常明智的。他们是否这样做,或者是否是侥幸,完全是另一回事! :)

答案 2 :(得分:12)

我可能在这里得出了一个错误的结论,但是当字符串在.NET String对象的上下文中不可变时,它仍然可以更改值吗?

例如,如果你倾向于这样做......

String example = "Hello World";

unsafe
{
    fixed (char* strPointer = myString) {
        strPointer[1] = 'a';
    }
} 

...不会example仍然代表相同的String对象,但现在有一个值可以计算GetHashCode()的不同值?我可能在这里偏离基地,但既然你可以轻松(如果不是毫无意义)这样做,那也会引起一些问题。

答案 3 :(得分:1)

另一个可能的原因是,实习字符串(特别是那些由编译器添加为共享只读数据的字符串)可以具有与任何其他字符串完全相同的格式。将这些字符串加载到只读内存中的事实意味着可以在进程间轻松共享这些数据页,但是也不可能让它们缓存哈希码。

但正如其他人所提到的,不缓存该值的主要原因是额外的内存使用量可能远远超过哈希码缓存的潜在节省。 GetHashCode的执行时间对于字符串的长度是O(N),因此重复散列的最坏情况是有限的。

答案 4 :(得分:0)

任何int值都是有效的HashCode。这意味着没有像-1或0这样的默认int值,我们可以用它来表示我们还没有计算HashCode。因此,如果字符串要缓存其HashCode,则需要执行以下操作之一:

  • 有一个HashCode的int字段,加上一个bool字段作为HashCode是否已被计算的标志,然后仅在第一次请求时计算HashCode(延迟评估), 或
  • 为HashCode创建一个int字段,始终在构造字符串时计算HashCode。

两种选择都有缺点;第一个需要更多的额外内存,第二个需要计算可能永远不需要的HashCodes的性能成本。

现在考虑Dictionary<TKey,TValue>的情况。 Dictionary使用的HashCode取决于使用哪个比较器。默认比较器将使用对象的普通GetHashCode()方法。但是你可以创建一个使用不区分大小写的比较器的Dictionary,并且Dictionary使用的HashCode将由该比较器生成,这可能产生与String.GetHashCode()完全不同的HashCode。那么哪个HashCode执行字符串缓存?字符串可能位于两个字典中,每个字典使用不同的比较器,两者都不使用普通字符串GetHashCode。所以字符串可以缓存一个HashCode甚至没有使用Dictionaries。

Dictionary<TKey,TValue>的情况下,有一个更重要的原因,即让字符串缓存其HashCodes可能不会带来任何性能优势。添加新条目时,Dictionary的内部实现会执行以下操作:

  • 使用构造时提供的相等比较器的GetHashCode()方法计算密钥的HashCode,如果没有指定则使用默认比较器。
  • 从HashCode中删除符号位
  • 存储新条目,其中包含上面修改的HashCode,映射到同一存储桶的条目列表中下一个条目的键,值和索引。

当Dictionary执行Key查找时,它会计算被搜索的密钥的已修改(即正)HashCode,获取HashCode映射到的存储桶,然后查看该存储桶中的条目列表。要检查条目是否匹配,它首先检查修改的HashCodes是否匹配(如果密钥相等,HashCodes也必须相等),如果它们相等,则检查两个密钥是否相等。在字符串的情况下,该算法实现了两个方面;首先,它通过首先使用简单的整数比较来避免许多字符串比较,以查看是否值得进行字符串比较,其次,它会缓存字典中每个键的HashCodes。 当键/值对添加到词典时,词典中每个键的HashCode只计算一次。

(如果你想知道为什么Dictionary从HashCode中剥离符号位,那是因为它在hashCode字段中使用-1作为标记标志值,用于当前为空的入口槽。)

答案 5 :(得分:0)

是的,它会消耗内存,但是更重要的是,即使您不使用此功能,也会消耗内存。

在框架中具有经过哈希码优化的string实现可能是有益的。

无论如何,实现自己的应该很简单:

public sealed class InternedString : IEquatable<InternedString>
{
    public InternedString(string s) => String = string.Intern(s);

    public string String { get; }

    public override bool Equals(object obj) => String.Equals(obj);

    public bool Equals(InternedString other) => String.Equals(other?.String);

    public override int GetHashCode() => RuntimeHelpers.GetHashCode(String);

    public static bool operator ==(InternedString l, InternedString r) =>
        l?.String == r?.String;

    public static bool operator !=(InternedString l, InternedString r) => !(l == r);
}

这里的想法是确保每个包裹的string都被嵌入,因此我们可以始终依靠string中相同strings的{​​{1}}引用相同。这种方法可以优化InternedStringGetHashCode调用,从而使此类成为Equals键的理想选择。

缺点是实习费用。到处使用它是一个过大的杀伤力。典型的使用场景是Dictionary,其中包含几个但很长的字符串键。

UPD:

顺便说一句,我有packaged it,并添加了一个基准测试check it out