数学解释为什么Decimal的转换为Double并且Decimal.GetHashCode将相等的实例分开

我不确定这种说明Stack Overflow问题的非标准方式是好还是坏,但是这里有:

什么是最好的(数学或其他技术)解释为什么代码:

static void Main() { decimal[] arr = { 42m, 42.0m, 42.00m, 42.000m, 42.0000m, 42.00000m, 42.000000m, 42.0000000m, 42.00000000m, 42.000000000m, 42.0000000000m, 42.00000000000m, 42.000000000000m, 42.0000000000000m, 42.00000000000000m, 42.000000000000000m, 42.0000000000000000m, 42.00000000000000000m, 42.000000000000000000m, 42.0000000000000000000m, 42.00000000000000000000m, 42.000000000000000000000m, 42.0000000000000000000000m, 42.00000000000000000000000m, 42.000000000000000000000000m, 42.0000000000000000000000000m, 42.00000000000000000000000000m, 42.000000000000000000000000000m, }; foreach (var m in arr) { Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "{0,-32}{1,-20:R}{2:X8}", m, (double)m, m.GetHashCode() )); } Console.WriteLine("Funny consequences:"); var h1 = new HashSet(arr); Console.WriteLine(h1.Count); var h2 = new HashSet(arr.Select(m => (double)m)); Console.WriteLine(h2.Count); } 

给出以下“有趣”(显然不正确)的输出:

  42 42 40450000
 42.0 42 40450000
 42.00 42 40450000
 42.000 42 40450000
 42.0000 42 40450000
 42.00000 42 40450000
 42.000000 42 40450000
 42.0000000 42 40450000
 42.00000000 42 40450000
 42.000000000 42 40450000
 42.0000000000 42 40450000
 42.00000000000 42 40450000
 42.000000000000 42 40450000
 42.0000000000000 42 40450000
 42.00000000000000 42 40450000
 42.000000000000000 42 40450000
 42.0000000000000000 42 40450000
 42.00000000000000000 42 40450000
 42.000000000000000000 42 40450000
 42.0000000000000000000 42 40450000
 42.00000000000000000000 42 40450000
 42.000000000000000000000 41.999999999999993 BFBB000F
 42.0000000000000000000000 42 40450000
 42.00000000000000000000000 42.000000000000007 40450000
 42.000000000000000000000000 42 40450000
 42.0000000000000000000000000 42 40450000
 42.00000000000000000000000000 42 40450000
 42.000000000000000000000000000 42 40450000
有趣的后果:
 2
 3

在.NET 4.5.2下试过这个。

Decimal.cs ,我们可以看到GetHashCode()是作为本机代码实现的。 此外,我们可以看到转换为double的实现是对ToDouble()的调用,而ToDouble()又实现为本机代码。 因此,从那里,我们无法看到行为的逻辑解释。

在旧的Shared Source CLI中 ,我们可以找到这些方法的旧实现,如果它们没有发生太大变化,那么它们可能会有所启发。 我们可以在comdecimal.cpp中找到:

 FCIMPL1(INT32, COMDecimal::GetHashCode, DECIMAL *d) { WRAPPER_CONTRACT; STATIC_CONTRACT_SO_TOLERANT; ENSURE_OLEAUT32_LOADED(); _ASSERTE(d != NULL); double dbl; VarR8FromDec(d, &dbl); if (dbl == 0.0) { // Ensure 0 and -0 have the same hash code return 0; } return ((int *)&dbl)[0] ^ ((int *)&dbl)[1]; } FCIMPLEND 

 FCIMPL1(double, COMDecimal::ToDouble, DECIMAL d) { WRAPPER_CONTRACT; STATIC_CONTRACT_SO_TOLERANT; ENSURE_OLEAUT32_LOADED(); double result; VarR8FromDec(&d, &result); return result; } FCIMPLEND 

我们可以看到GetHashCode()实现基于转换为double :哈希代码基于转换为double后得到的字节。 它基于相等的decimal值转换为相等的double值的假设。

那么让我们测试.NET之外的VarR8FromDec系统调用:

在Delphi中(我实际上使用的是FreePascal),这里有一个简短的程序来直接调用系统函数来测试它们的行为:

 {$MODE Delphi} program Test; uses Windows, SysUtils, Variants; type Decimal = TVarData; function VarDecFromStr(const strIn: WideString; lcid: LCID; dwFlags: ULONG): Decimal; safecall; external 'oleaut32.dll'; function VarDecAdd(const decLeft, decRight: Decimal): Decimal; safecall; external 'oleaut32.dll'; function VarDecSub(const decLeft, decRight: Decimal): Decimal; safecall; external 'oleaut32.dll'; function VarDecDiv(const decLeft, decRight: Decimal): Decimal; safecall; external 'oleaut32.dll'; function VarBstrFromDec(const decIn: Decimal; lcid: LCID; dwFlags: ULONG): WideString; safecall; external 'oleaut32.dll'; function VarR8FromDec(const decIn: Decimal): Double; safecall; external 'oleaut32.dll'; var Zero, One, Ten, FortyTwo, Fraction: Decimal; I: Integer; begin try Zero := VarDecFromStr('0', 0, 0); One := VarDecFromStr('1', 0, 0); Ten := VarDecFromStr('10', 0, 0); FortyTwo := VarDecFromStr('42', 0, 0); Fraction := One; for I := 1 to 40 do begin FortyTwo := VarDecSub(VarDecAdd(FortyTwo, Fraction), Fraction); Fraction := VarDecDiv(Fraction, Ten); Write(I: 2, ': '); if VarR8FromDec(FortyTwo) = 42 then WriteLn('ok') else WriteLn('not ok'); end; except on E: Exception do WriteLn(E.Message); end; end. 

请注意,由于Delphi和FreePascal对任何浮点十进制类型都没有语言支持,我正在调用系统函数来执行计算。 我首先将FortyTwo设置为42 。 然后我加1并减去1 。 然后我加0.1并减去0.1 。 等等。 这会导致在.NET中以相同的方式扩展小数的精度。

这是(部分)输出:

 ...
 20:好的
 21:好的
 22:不行
 23:好的
 24:不行
 25:好的
 26:好的
 ...

因此,这表明这确实是Windows中一个长期存在的问题,只是碰巧被.NET暴露。 它的系统函数为相等的十进制值提供不同的结果,要么它们应该被修复,要么.NET应该被更改为不使用有缺陷的函数。

现在,在新的.NET Core中,我们可以在其decimal.cpp代码中看到解决问题的方法:

 FCIMPL1(INT32, COMDecimal::GetHashCode, DECIMAL *d) { FCALL_CONTRACT; ENSURE_OLEAUT32_LOADED(); _ASSERTE(d != NULL); double dbl; VarR8FromDec(d, &dbl); if (dbl == 0.0) { // Ensure 0 and -0 have the same hash code return 0; } // conversion to double is lossy and produces rounding errors so we mask off the lowest 4 bits // // For example these two numerically equal decimals with different internal representations produce // slightly different results when converted to double: // // decimal a = new decimal(new int[] { 0x76969696, 0x2fdd49fa, 0x409783ff, 0x00160000 }); // => (decimal)1999021.176470588235294117647000000000 => (double)1999021.176470588 // decimal b = new decimal(new int[] { 0x3f0f0f0f, 0x1e62edcc, 0x06758d33, 0x00150000 }); // => (decimal)1999021.176470588235294117647000000000 => (double)1999021.1764705882 // return ((((int *)&dbl)[0]) & 0xFFFFFFF0) ^ ((int *)&dbl)[1]; } FCIMPLEND 

这似乎也是在当前的.NET Framework中实现的,基于其中一个错误的double值确实提供了相同的哈希代码,但这还不足以完全解决问题。

至于哈希的差异,它确实似乎是错误的(相同的值,不同的哈希) – >但是LukeH在他的评论中已经回答了它。

至于铸造加倍,虽然..我这样看:

42000000000000000000000具有与42000000000000000000000不同(并且更少“精确”)的二进制表示,因此您为尝试舍入它而支付更高的价格。

为什么重要? 十进制显然跟踪其“精确度”。 因此,例如,它存储1m为1*10^0但其等效的1.000m为1000*10^-3 。 最有可能将其打印为"1.000" 。 因此,当您将小数转换为double时,它不是您需要表示的42,但是例如420000000000000000,这远非最优(尾数和指数分别转换)。

根据我发现的一个模拟器(js one for Java,所以不完全是我们可能拥有的C#,因此结果有点不同,但有意义):

 42000000000000000000 ~ 1.1384122371673584 * 2^65 ~ 4.1999998e+19 420000000000000000000 = 1.4230153560638428 * 2^68 = 4.2e+20 (nice one) 4200000000000000000000 ~ 1.7787691354751587 * 2^71 ~ 4.1999999e+21 42000000000000000000000 ~ 1.111730694770813 * 2^75 ~ 4.1999998e+22 

正如您所看到的,4.2E19的值不如4.2E20精确,最终可能会四舍五入为4.19。 如果这是转换为双倍的方式,那么结果就不足为奇了。 而且,由于乘以10,你通常会遇到一个二进制表示不完整的数字,那么我们应该经常会遇到这样的问题。

现在我想到了保留十进制有效数字的所有代价。 如果它不重要,我们总是可以。 规范化4200*10^-24.2*10^1 (双倍)并且转换为double不会在哈希码的上下文中容易出错。 如果它值得吗? 不是我要判断。

顺便说一句:这2个链接提供了关于小数二进制表示的很好的阅读: https : //msdn.microsoft.com/en-us/library/system.decimal.getbits.aspx

https://msdn.microsoft.com/en-us/library/system.decimal.aspx