Подтвердить что ты не робот

Математическое объяснение, почему десятичное преобразование в Double разбито и Decimal.GetHashCode отделяет равные экземпляры

Я не уверен, является ли этот нестандартный способ постановки вопроса о переполнении стека хорошим или плохим, но здесь говорится:

Какое лучшее (математическое или иное техническое) объяснение, почему код:

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);
}

дает следующий "смешной" (явно неверный) вывод:

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.

4b9b3361

Ответ 1

В Decimal.cs мы видим, что GetHashCode() реализуется как собственный код. Кроме того, мы можем видеть, что приведение в double реализовано как вызов ToDouble(), который, в свою очередь, реализован как собственный код. Поэтому оттуда мы не видим логического объяснения поведения.

В старом 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.

Итак, давайте протестируем системный вызов 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.

Обратите внимание, что поскольку 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

Это, как представляется, также реализовано в текущей платформе .NET Framework на основе того факта, что одно из неправильных значений double дает один и тот же хеш-код, но этого недостаточно для полного устранения проблемы.

Ответ 2

Что касается разницы в хэшах, это действительно кажется неправильным (одинаковое значение, другое хеш) → , но на это отвечает уже LukeH в своем комментарии.

Что касается того, что кастинг удваивается, я вижу это так:

42000000000000000000000 имеет другое (и менее "точное" ) двоичное представление, чем 420000000000000000000000, и поэтому вы платите более высокую цену за попытку округлить его.

Почему это имеет значение? По-видимому, десятичный отслеживает свою "точность". Так, например, он хранит 1 м в качестве 1*10^0, но его эквивалент 1.000 м как 1000*10^-3. Скорее всего, его можно будет напечатать позже "1.000". Поэтому, когда вы конвертируете десятичную цифру, чтобы удвоить ее, а не 42, которую вам нужно представить, но, например, 420000000000000000, и это далеко не оптимально (мантисса и экспонента преобразуются отдельно).

В соответствии с симулятором я нашел (js one для Java, поэтому не совсем то, что у нас может быть для С# и, следовательно, немного разные результаты, но значимые):

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. Если это то, как происходит преобразование в double, результат не вызывает удивления. И поскольку умножение на 10, вы обычно сталкиваетесь с числом, которое не является хорошо представленным в двоичном формате, тогда мы должны часто ожидать такие проблемы.

Теперь, на мой взгляд, вся его цена за отслеживание значащих цифр в десятичной форме. Если бы это было не важно, мы всегда могли бы. normalize 4200*10^-2 до 4.2*10^1 (как это делает двойной), а преобразование в double не будет подвержено ошибкам в контексте хэш-кодов. Если это того стоит? Не мне судить.

BTW: эти 2 ссылки обеспечивают приятное чтение о двоичном представлении десятичных знаков: https://msdn.microsoft.com/en-us/library/system.decimal.getbits.aspx

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