【问题标题】:C# Why can equal decimals produce unequal hash values?C# 为什么相等的小数会产生不相等的哈希值?
【发布时间】:2011-12-16 11:19:40
【问题描述】:

我们遇到了一个神奇的十进制数,它破坏了我们的哈希表。我将其归结为以下最小情况:

decimal d0 = 295.50000000000000000000000000m;
decimal d1 = 295.5m;

Console.WriteLine("{0} == {1} : {2}", d0, d1, (d0 == d1));
Console.WriteLine("0x{0:X8} == 0x{1:X8} : {2}", d0.GetHashCode(), d1.GetHashCode()
                  , (d0.GetHashCode() == d1.GetHashCode()));

给出以下输出:

295.50000000000000000000000000 == 295.5 : True
0xBF8D880F == 0x40727800 : False

真正奇怪的是:更改、添加或删除 d0 中的任何数字,问题就消失了。甚至添加或删除尾随零之一!不过,这个标志似乎并不重要。

我们的解决方法是将值除以去除尾随零,如下所示:

decimal d0 = 295.50000000000000000000000000m / 1.000000000000000000000000000000000m;

但我的问题是,C# 怎么做错了?

编辑: 刚刚注意到这已在 .NET Core 3.0 中修复(可能更早,我没有检查):https://dotnetfiddle.net/4jqYos

【问题讨论】:

    标签: c# .net hash decimal


    【解决方案1】:

    首先,C# 并没有做错什么。这是一个框架错误。

    虽然它确实看起来像一个错误 - 基本上,在比较相等性时涉及的任何规范化都应该以相同的方式用于哈希码计算。我已经检查过并且也可以重现它(使用 .NET 4),包括检查 Equals(decimal)Equals(object) 方法以及 == 运算符。

    看起来问题出在d0 值上,因为在d1 中添加尾随0 不会改变结果(当然,直到它与d0 相同)。我怀疑那里的确切位表示会触发一些极端情况。

    我很惊讶它不是(正如你所说,它在大多数时间都有效),但你应该在Connect 上报告这个错误。

    【讨论】:

    • FWIW 它也发生在 .NET 2.0 和 3.5 上。
    • 已连接:connect.microsoft.com/VisualStudio/feedback/details/314630/…(虽然标记为已修复!)
    • 该死的。如果那仍然存在 - 这是一种不好的情况,很少出现然后真的很讨厌;)
    • 在 .NET 4.5 中问题仍然存在(与上面相同的decimals)。
    • @AakashM 新线程(由我自己打开)Mathematical explanation of Decimal different representations as Double 的答案似乎揭示了“已修复”的内容。他们使用到Double 的损坏转换,然后从该Double 值中找到散列。他们试图应用的修复似乎涉及丢弃Double 的最低有效位。这并不总是足够好。在其他线程中查看 hvd 对 cme​​ts 的回答。
    【解决方案2】:

    另一个导致不同编译器上相同十进制的不同字节表示的错误 (?):尝试在 VS 2005 和 VS 2010 上编译以下代码。或者查看我在代码项目上的article

    class Program
    {
        static void Main(string[] args)
        {
            decimal one = 1m;
    
            PrintBytes(one);
            PrintBytes(one + 0.0m); // compare this on different compilers!
            PrintBytes(1m + 0.0m);
    
            Console.ReadKey();
        }
    
        public static void PrintBytes(decimal d)
        {
            MemoryStream memoryStream = new MemoryStream();
            BinaryWriter binaryWriter = new BinaryWriter(memoryStream);
    
            binaryWriter.Write(d);
    
            byte[] decimalBytes = memoryStream.ToArray();
    
            Console.WriteLine(BitConverter.ToString(decimalBytes) + " (" + d + ")");
        }
    }
    

    有些人使用以下规范化代码 d=d+0.0000m,这在 VS 2010 上无法正常工作。您的规范化代码 (d=d/1.000000000000000000000000000000000m) 看起来不错 - 我使用相同的代码来获得相同的字节数组以获得相同的小数。

    【讨论】:

    • 您的PrintBytes 方法是否与static 方法decimal.GetBits 显示的信息不同? 11.01.00 等具有不同表示的事实是设计使然(有意)。这个线程更多的是关于一个严重的问题,即(尽管有不同的位表示)被Equals方法(以及C#中的==和.NET中的op_Equality)认为相等的值具有不同的哈希值。
    • @JeppeStigNielsen 我不是说11.0 有不同的表示。我说的是one + 0.0m 是 VS2005 上的 1.0 和 VS2010 上的 one+0.0m1。相同的代码在不同的编译器上有不同的表示。而且,如果您使用十进制字节表示计算哈希(可能是 Microsoft 所做的),您将遇到类似的问题,但更糟糕的是:在编译器更改之前您不会看到错误。
    • 好发现!此行为再次从 VS 2013 更改为 VS 2015(2015 年新的基于 Roslyn 的 C# 编译器)。它现在已修复,因此1m + 0.0m 中必须存在的尾随零不会被优化掉(就像在 VS 2013 中错误地发生 0.0m 是编译时常量时一样)。我想 VS 2015 回到了 VS 2005 的正确行为。(某些?)中间版本中存在的错误已修复。
    【解决方案3】:

    也遇到了这个错误... :-(

    测试(见下文)表明这取决于该值可用的最大精度。错误的哈希码仅出现在给定值的最大精度附近。正如测试显示的那样,错误似乎取决于小数点左边的数字。有时 maxDecimalDigits - 1 的唯一哈希码是错误的,有时 maxDecimalDigits 的值是错误的。

    var data = new decimal[] {
    //    123456789012345678901234567890
        1.0m,
        1.00m,
        1.000m,
        1.0000m,
        1.00000m,
        1.000000m,
        1.0000000m,
        1.00000000m,
        1.000000000m,
        1.0000000000m,
        1.00000000000m,
        1.000000000000m,
        1.0000000000000m,
        1.00000000000000m,
        1.000000000000000m,
        1.0000000000000000m,
        1.00000000000000000m,
        1.000000000000000000m,
        1.0000000000000000000m,
        1.00000000000000000000m,
        1.000000000000000000000m,
        1.0000000000000000000000m,
        1.00000000000000000000000m,
        1.000000000000000000000000m,
        1.0000000000000000000000000m,
        1.00000000000000000000000000m,
        1.000000000000000000000000000m,
        1.0000000000000000000000000000m,
        1.00000000000000000000000000000m,
        1.000000000000000000000000000000m,
        1.0000000000000000000000000000000m,
        1.00000000000000000000000000000000m,
        1.000000000000000000000000000000000m,
        1.0000000000000000000000000000000000m,
    };
    
    for (int i = 0; i < 1000; ++i)
    {
        var d0 = i * data[0];
        var d0Hash = d0.GetHashCode();
        foreach (var d in data)
        {
            var value = i * d;
            var hash = value.GetHashCode();
            Console.WriteLine("{0};{1};{2};{3};{4};{5}", d0, value, (d0 == value), d0Hash, hash, d0Hash == hash);
        }
    }
    

    【讨论】:

      【解决方案4】:

      这是一个小数舍入错误。

      将 d0 设置为 .000000000000000 需要太高的精度,因此负责它的算法会出错并最终给出不同的结果。在这个例子中它可能被归类为一个错误,尽管请注意“十进制”类型应该具有 28 位的精度,而在这里,您实际上要求 d0 的精度为 29 位。

      这可以通过询问 d0 和 d1 的完整原始十六进制表示来进行测试。

      【讨论】:

      • 您的回答听起来不正确。当然可能数字太多了,但是数字d0和d1是一样的,没有问题。当然,在二进制中它们是不同的:295.50 和 295.5 也将是二进制不同的。这里的问题是 GetHashCode 函数为其中之一返回了错误的哈希值。另外:我遇到这个问题的原因是小数来自 MySQL,当然我自己从来没有输入那么多尾随零。
      • 295.50 和 295.5 是二进制相同的。很容易检查:只需以原始格式输出两个十进制值,就好像它们是原始的 128 位十六进制值一样。另一方面,295.50000000000000000000000000 略有不同。第 29 位对最低位的舍入有影响。因为当将base10舍入到base2时舍入效应是不可避免的,我猜“==”函数对最低位有一些容忍度,认为它们是“嘈杂的”,这可以解释为什么它说两个数字相等。但实际上它们并不是……
      • .NET 将尾随零 somewhere 存储在 295.50 中。 ToString() 很好地再现了它。所以它不能是二进制相等的。据我所知,它存储为 29550 * 10^-2,而 295.5 存储为 2955 * 10^-1。不相等。 == 正确比较了两者,但 GetHashCode 显然没有进行相同的转换。关于最后一位数字的 base10/base2 四舍五入:是的,我也怀疑原因在某处。
      • 你可能会在这个话题中找到这个问题的答案:stackoverflow.com/questions/33482020/… Cyan 有它的意义,但并不完全如此
      【解决方案5】:

      我在 VB.NET (v3.5) 中对此进行了测试,得到了同样的结果。

      关于哈希码的有趣之处:

      A) 0x40727800 = 1081243648

      B) 0xBF8D880F = -1081243648

      使用我发现的 Decimal.GetBits()

      格式:尾数(hhhhhhhh hhhhhhhh hhhhhhhh)指数(seee0000) (h 是值,'s' 是符号,'e' 是指数,0 必须为零)

      d1 ==> 00000000 00000000 00000B8B - 00010000 = (2955 / 10 ^ 1) = 295.5

      做 ==> 5F7B2FE5 D8EACD6E 2E000000 - 001A0000

      ...转换为 29550000000000000000000000000 / 10^26 = 295.5000000...等

      **编辑:好的,我写了一个128位十六进制计算器,上面完全正确

      这绝对看起来像是某种内部转换错误。 Microsoft 明确声明他们不保证 GetHashCode 的默认实现。如果您将它用于任何重要的事情,那么为十进制类型编写自己的 GetHashCode 可能是有意义的。将其格式化为固定小数、固定宽度的字符串和散列似乎可以工作,例如(>29 位小数,> 58 宽度 - 适合所有可能的小数)。

      * 编辑:我不知道这个了。它仍然一定是某个地方的转换错误,因为存储的精度从根本上改变了内存中的实际值。哈希码最终成为彼此的带符号负数是一个很大的线索 - 需要进一步研究默认哈希码实现以找到更多信息。

      28 位或 29 位数字无关紧要,除非存在无法正确评估外部范围的相关代码。可访问的最大 96 位整数是:

      79228162514264337593543950335

      所以你可以有 29 位数字,只要整个数字(没有小数点)小于这个值。我不禁认为这在某处的哈希码计算中要微妙得多。

      【讨论】:

      • 就我而言,我对 GetHashCode 本身并不感兴趣。我在使用标准 .NET Dictionary 或 HashTable 时遇到了问题。我不知道这是否在内部使用 GetHashCode(我假设),但无论哪种方式都有同样的问题。
      【解决方案6】:

      documetation 建议由于 GetHashCode() 不可预测,您应该创建自己的。它被认为是不可预测的,因为每种类型都有自己的实现,而且由于我们不知道它的内部结构,我们应该根据我们评估唯一性的方式来创建自己的实现。

      但是,我认为答案是GetHashCode() 没有使用数学十进制值来创建哈希码。

      在数学上,我们看到 295.50000000 和 295.5 是相同的。当您在 IDE 中查看十进制对象时,这也是正确的。但是,如果您对两个小数都执行ToString(),您会看到编译器对它们的看法不同,即您仍然会看到 295.50000000。 GetHashCode() 显然没有使用十进制的数学表示来创建哈希码。

      您的解决方法是简单地创建一个新的小数,没有所有尾随零,这就是它起作用的原因。

      【讨论】:

      • 阅读。他不是在谈论他自己的班级。有一个明确定义的合同,女孩违反了,基本上得到了 MS 的确认。你不认为这是一个错误,然后去完全不相关的文档也无济于事。
      • GetHashCode 必须为相等的对象返回相同的值 - 这就是它的全部意义
      • Decimal 覆盖 GetHashCode,因此 ValueType.GetHashCode 的 cmets 不适用。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2018-02-12
      • 2013-02-03
      • 1970-01-01
      • 2015-02-14
      • 2016-08-17
      • 1970-01-01
      • 2023-04-05
      相关资源
      最近更新 更多