type round decimals c# .net floating-point decimal bcl

round - decimal type c# precision



Explicación matemática de por qué la conversión de Decimal a Double está rota y Decimal.GetHashCode separa las instancias iguales (2)

No estoy seguro de si esta forma no estándar de plantear una pregunta de desbordamiento de pila es buena o mala, pero aquí va:

¿Cuál es la mejor explicación (matemática o técnica) de por qué el código:

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

da el siguiente resultado "divertido" (aparentemente incorrecto):

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

Intenté esto bajo .NET 4.5.2.


En Decimal.cs , podemos ver que GetHashCode() se implementa como código nativo. Además, podemos ver que la ToDouble() a double se implementa como una llamada a ToDouble() , que a su vez se implementa como código nativo. Entonces, desde allí, no podemos ver una explicación lógica para el comportamiento.

En la antigua CLI de fuentes compartidas , podemos encontrar implementaciones antiguas de estos métodos que, con suerte, arrojan algo de luz, si es que no han cambiado demasiado. Lo podemos encontrar en 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

y

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

Podemos ver que la implementación de GetHashCode() se basa en la conversión a double : el código hash se basa en los bytes que resultan después de una conversión a double . Se basa en el supuesto de que decimal valores decimal iguales se convierten en valores double iguales.

Entonces, VarR8FromDec llamada al sistema VarR8FromDec fuera de .NET:

En Delphi (en realidad estoy usando FreePascal), aquí hay un programa corto para llamar a las funciones del sistema directamente para probar su comportamiento:

{$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.

Tenga en cuenta que, dado que Delphi y FreePascal no tienen soporte de idioma para ningún tipo decimal de punto flotante, estoy llamando a las funciones del sistema para realizar los cálculos. Estoy configurando FortyTwo primero a 42 . Luego sumo 1 y resto 1 . Luego sumo 0.1 y resto 0.1 . Etcétera. Esto hace que la precisión del decimal se amplíe de la misma manera en .NET.

Y aquí está (parte de) la salida:

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

De este modo, se muestra que este es realmente un problema de larga data en Windows que simplemente está expuesto por .NET. Son las funciones del sistema las que dan resultados diferentes para valores decimales iguales, y deben ser fijos, o .NET debe cambiarse para no usar funciones defectuosas.

Ahora, en el nuevo .NET Core, podemos ver en su código decimal.cpp para solucionar el problema:

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

Esto parece implementarse en el .NET Framework actual, basado en el hecho de que uno de los valores double incorrectos da el mismo código hash, pero no es suficiente para solucionar el problema por completo.


En cuanto a la diferencia en los hashes, de hecho parece estar equivocado (el mismo valor, hash diferente) -> pero LukeH ya lo responde en su comentario.

En cuanto al casting para doblar, aunque ... lo veo así:

42000000000000000000000 tiene una representación binaria diferente (y menos ''precisa'') que 420000000000000000000000 y, por lo tanto, paga un precio más alto por tratar de redondearlo.

¿Por qué importa? Al parecer, el decimal mantiene un registro de su ''precisión''. Entonces, por ejemplo, está almacenando 1m como 1*10^0 pero su equivalente a 1.000m como 1000*10^-3 . Es muy probable que pueda imprimirlo más tarde como "1.000" . Por lo tanto, al convertir su decimal en doble, no es 42 lo que necesita representar, sino, por ejemplo, 420000000000000000 y esto está lejos de ser óptimo (la mantisa y el exponente se convierten por separado).

Según un simulador que he encontrado (js uno para Java, no exactamente lo que podemos tener para C # y, por lo tanto, resultados un poco diferentes, pero significativos):

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

Como puede ver, el valor para 4.2E19 es menos preciso que para 4.2E20 y puede terminar redondeando a 4.19. Si así es como ocurre la conversión al doble, el resultado no es sorprendente. Y ya que al multiplicar por 10, generalmente encontrará un número que no está bien representado en binario, entonces deberíamos esperar tales problemas a menudo.

Ahora, en mi opinión, es todo el precio por mantener el rastro de dígitos significativos en decimal. Si no fuera importante, siempre podríamos ex. normalice 4200*10^-2 a 4.2*10^1 (como lo hace el doble) y la conversión a doble no sería tan propensa a errores en el contexto de los hashcodes. Si vale la pena? No soy yo para juzgar.

Por cierto: esos 2 enlaces proporcionan una buena lectura sobre decimales representación binaria: https://msdn.microsoft.com/en-us/library/system.decimal.getbits.aspx

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