Ответ 1
В Decimal.cs
мы видим, что GetHashCode()
реализуется как собственный код. Кроме того, мы можем видеть, что приведение в double
реализовано как вызов ToDouble()
, который, в свою очередь, реализован как собственный код. Поэтому оттуда мы не видим логического объяснения поведения.
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
дает один и тот же хеш-код, но этого недостаточно для полного устранения проблемы.