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

Jep*_*sen 23 .net c# floating-point decimal base-class-library

我不确定这种说明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<decimal>(arr);
  Console.WriteLine(h1.Count);
  var h2 = new HashSet<double>(arr.Select(m => (double)m));
  Console.WriteLine(h2.Count);
}
Run Code Online (Sandbox Code Playgroud)

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

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
Funny consequences:
2
3

在.NET 4.5.2下试过这个.

小智 13

Decimal.cs,我们可以看到它GetHashCode()是作为本机代码实现的.此外,我们可以看到转换double为实现为调用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
Run Code Online (Sandbox Code Playgroud)

FCIMPL1(double, COMDecimal::ToDouble, DECIMAL d)
{
    WRAPPER_CONTRACT;
    STATIC_CONTRACT_SO_TOLERANT;

    ENSURE_OLEAUT32_LOADED();

    double result;
    VarR8FromDec(&d, &result);
    return result;
}
FCIMPLEND
Run Code Online (Sandbox Code Playgroud)

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

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

在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.
Run Code Online (Sandbox Code Playgroud)

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

这是(部分)输出:

...
20: ok
21: ok
22: not ok
23: ok
24: not ok
25: ok
26: ok
...

因此,这表明这确实是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
Run Code Online (Sandbox Code Playgroud)

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

  • *编辑:*是的,这就是为什么一个有趣的结果是`3`而另一个是'2`.这意味着它们的修复在某些情况下有效,但在错误"包围"并且在"Double"表示的相对有效数字中给出错误的情况下不会.他们应该已经将转换修复为"Double",而不是破解散列算法以丢弃最不重要的部分.问题仍然是为什么转换为"Double"会失败.我猜mikus的答案指向一些可能与错误转换相关的"随机"模式. (3认同)

mik*_*kus 8

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

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

42000000000000000000000具有不同(并且不那么"精确")的二进制表示420000000000000000000000,因此您需要为尝试舍入它而支付更高的价格.

为什么重要?十分小数显然跟踪其"精确度".因此,例如它存储1米,1*10^0但相当于1.000 米1000*10^-3.最有可能在以后打印出来"1.000".因此,当您将小数转换为双倍时,它不是您需要表示的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
Run Code Online (Sandbox Code Playgroud)

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

现在我想到了保留十进制有效数字的所有代价.如果它不重要,我们总是可以.normalize 4200*10^-2to 4.2*10^1(double as it)和转换为double在hashcode的上下文中不会出错.如果它值得吗?不是我判断.

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

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