As conversões de Decimal e BigInteger para ponto flutuante são arredondadas corretamente

As conversões entre Decimal e tipos de ponto flutuante binário, e as conversões de BigInteger para tipos de ponto flutuante binário, agora produzem resultados corretamente arredondados. Anteriormente, essas conversões podiam truncar, arredondar com base em valores intermediários ou descartar dígitos significativos.

Versão introduzida

.NET 11 Versão Prévia 7

Comportamento anterior

As conversões de float e double para decimal mantiveram apenas 7 e 15 algarismos decimais significativos, respectivamente. Isso pode ocultar a diferença entre um literal de ponto flutuante binário e um literal decimal:

using System.Globalization;

double value = 1.23;
decimal converted = (decimal)value;

Console.WriteLine(converted.ToString("G29", CultureInfo.InvariantCulture));

A saída foi:

1.23

Conversões de float para double ou decimal podem ser arredondadas mais de uma vez. Por exemplo:

using System.Globalization;

decimal value = 10000000000000.099609375m;
double converted = (double)value;

Console.WriteLine(converted.ToString("G99", CultureInfo.InvariantCulture));

A saída foi:

10000000000000.09765625

Conversões de decimal para Half ou BFloat16 primeiro convertiam o valor para float, o que também podia produzir um resultado arredondado incorretamente.

Conversões de BigInteger para double truncavam os bits descartados em vez de arredondar para o valor representável mais próximo. Por exemplo:

using System.Globalization;
using System.Numerics;

BigInteger value = long.MaxValue / 2;
double converted = (double)value;

Console.WriteLine(converted.ToString("G17", CultureInfo.InvariantCulture));

A saída foi:

4.6116860184273874E+18

Conversões de BigInteger , floatHalfou BFloat16 primeiro converteram o valor em double, que poderia arredondar duas vezes e produzir um resultado de uma unidade no último lugar longe do valor representável mais próximo.

Novo comportamento

Começando em .NET 11, as conversões arredondam o valor exato da origem uma vez para o valor de destino representável mais próximo.

Para o primeiro exemplo, o double valor não é exatamente 1.23. Seu valor exato é aproximadamente 1.229999999999999982236431605997..., portanto, o valor convertido decimal agora é:

1.229999999999999982236431606

Para o segundo exemplo, o valor convertido double agora é:

10000000000000.099609375

Conversões de decimal para Half ou BFloat16 também são arredondadas corretamente e podem produzir um padrão de bit de destino diferente das versões anteriores.

Para o BigInteger exemplo, o valor convertido double agora é:

4.6116860184273879E+18

Conversões de BigInteger para float, Halfou BFloat16 também são arredondadas corretamente.

Se uma conversão for avaliada como uma constante de tempo de compilação, um compilador hospedado pelo SDK do .NET 11 Preview 7 ou um SDK posterior poderá inserir o novo resultado no assembly de saída quando o projeto for recriado, independentemente da estrutura de destino do projeto.

Tipo de mudança disruptiva

Esta é uma alteração comportamental.

Motivo da alteração

Os algoritmos de conversão anteriores perderam informações que o tipo de destino poderia representar e, às vezes, selecionavam um valor diferente do resultado representável mais próximo. Isso causou erros de precisão em ambas as direções de conversão de decimal e em conversões de BigInteger. Os novos algoritmos seguem a regra esperada para cálculos com ponto flutuante no cálculo da conversão, como se ela fosse feita com precisão intermediária exata, seguida de um único arredondamento para o tipo de destino.

Para obter mais informações, consulte dotnet/runtime#130565 e dotnet/runtime#130566.

Não suponha que um literal binário de ponto flutuante e um literal decimal com o mesmo texto-fonte representem o mesmo valor. Se um valor deve ser decimal, use um literal decimal:

decimal value = 123.4567m;

em vez de uma conversão a partir de um double literal:

decimal value = (decimal)123.4567;

Para, em geral, restaurar o resultado anterior quando você converter para decimal, arredonde o valor convertido para 7 dígitos decimais significativos para um tipo float de origem ou para 15 dígitos decimais significativos para um tipo double de origem. A conversão anterior arredondada para a mais próxima com vínculos com even; não foi truncado. Valores positivos e negativos foram tratados simetricamente.

Por exemplo, formatar o valor de entrada com a cadeia de caracteres de formato numérico padrão G7 ou G15 e interpretar o resultado realiza o arredondamento correspondente de dígitos significativos para valores finitos arbitrários:

using System.Globalization;

static decimal ConvertToDecimalLikePrevious(float value)
{
    Span<char> text = stackalloc char[32];
    value.TryFormat(text, out int length, "G7", CultureInfo.InvariantCulture);
    return decimal.Parse(text[..length], NumberStyles.Float, CultureInfo.InvariantCulture);
}

static decimal ConvertToDecimalLikePrevious(double value)
{
    Span<char> text = stackalloc char[32];
    value.TryFormat(text, out int length, "G15", CultureInfo.InvariantCulture);
    return decimal.Parse(text[..length], NumberStyles.Float, CultureInfo.InvariantCulture);
}

decimal fromFloat = ConvertToDecimalLikePrevious(14.1f);          // 14.1
decimal fromDouble = ConvertToDecimalLikePrevious(-123.4567);     // -123.4567

A implementação baseada em Span não realiza alocações. Valores fora do intervalo de decimal continuarão a lançar uma exceção durante a análise sintática, assim como durante a conversão.

Atualize os testes e os valores esperados serializados que continham o resultado anterior, arredondado incorretamente. Isso inclui código que dependia do truncamento na conversão BigInteger em direção a zero. Se um aplicativo exigir um padrão de bits legado BFloat16, Half, float ou double específico para um protocolo ou formato de arquivo, codifique esse padrão de bits explicitamente em vez de reproduzi-lo por meio de uma conversão numérica.

Não há nenhuma opção de compatibilidade para restaurar os algoritmos de conversão anteriores.

APIs afetadas