Decimal と BigInteger の浮動小数点数への変換は正しく丸められます

Decimal浮動小数点型とバイナリ浮動小数点型の間の変換と、BigInteger からバイナリ浮動小数点型への変換によって、正しく丸められた結果が生成されるようになりました。 従来は、これらの変換では、切り捨てが行われたり、中間値を経由して丸められたり、有効桁が失われたりすることがありました。

導入されたバージョン

.NET 11 Preview 7

以前の動作

floatdoubleからdecimalへの変換では、それぞれ 7 桁と 15 桁の有効な 10 進数のみが保持されます。 これにより、バイナリ浮動小数点リテラルと 10 進リテラルの違いが隠れる可能性があります。

using System.Globalization;

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

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

出力は次のとおりです。

1.23

decimalからfloatまたはdoubleへの変換は、複数回丸めできます。 例えば次が挙げられます。

using System.Globalization;

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

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

出力は次のとおりです。

10000000000000.09765625

decimalからHalfまたはBFloat16への変換によって、最初に値がfloatに変換され、誤って丸められた結果が生成される可能性があります。

BigInteger から double への変換では、最も近い表現可能な値に丸めるのではなく、破棄されるビットを切り捨てていました。 例えば次が挙げられます。

using System.Globalization;
using System.Numerics;

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

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

出力は次のとおりです。

4.6116860184273874E+18

BigIntegerからfloatHalf、またはBFloat16への変換は、最初に値をdoubleに変換しました。この変換により、2 回丸めて、最後の場所で最も近い表現可能な値から離れた場所に結果 1 単位が生成される可能性があります。

新しい動作

.NET 11 以降では、変換は正確なソース値を 1 回、最も近い表現可能な変換先の値に丸めます。

最初の例では、 double 値が正確に 1.23されていません。 正確な値は約 1.229999999999999982236431605997...であるため、変換された decimal 値は次のようになります。

1.229999999999999982236431606

2 番目の例では、変換された double 値は次のようになります。

10000000000000.099609375

decimalからHalfまたはBFloat16への変換も正しく丸められ、以前のバージョンとは異なる宛先ビット パターンが生成される可能性があります。

BigIntegerの例では、変換されたdouble値は次のようになります。

4.6116860184273879E+18

BigIntegerからfloatHalf、またはBFloat16への変換も正しく丸められます。

変換がコンパイル時定数として評価される場合、.NET 11 Preview 7 SDK 以降の SDK によってホストされているコンパイラは、プロジェクトのターゲット フレームワークに関係なく、プロジェクトの再構築時に新しい結果を出力アセンブリに埋め込むことができます。

破壊的変更の種類

この変更は 動作の変更です。

変更理由

前の変換アルゴリズムでは、変換先の型が表すことができる情報が失われ、最も近い表現可能な結果以外の値が選択される場合があります。 これにより、 decimal 変換と BigIntegerからの変換の両方向で精度エラーが発生しました。 新しいアルゴリズムは、変換計算に関する想定どおりの浮動小数点演算規則に従い、中間結果を厳密な精度で計算したかのように処理した後、変換先の型に一度だけ丸めます。

詳細については、「 dotnet/runtime#130565 」および 「dotnet/runtime#130566」を参照してください。

バイナリ浮動小数点リテラルと同じソース テキストを持つ 10 進リテラルが同じ値を表すと想定しないでください。 10 進数の値を使用する場合は、10 進リテラルを使用します。

decimal value = 123.4567m;

double リテラルから変換する代わりに:

decimal value = (decimal)123.4567;

decimalに変換するときに前の結果を一般的に復元するには、変換された値を 7 桁の有効な 10 進数 (float ソースの場合) または 15 桁の有効な 10 進数 (double ソース) に丸めます。 前の変換は、偶数に結び付けて最も近い値に丸められます。切り捨てませんでした。 正と負の値は対称的に処理されました。

たとえば、ソース値を G7 または G15 標準の数値書式指定文字列で書式設定し、結果を解析すると、任意の有限値に対応する有効桁数の丸め処理が実行されます。

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

スパン ベースの実装では割り当てられません。 decimalの範囲外の値は、変換中と同様に、解析中に例外をスローし続けます。

以前の、誤って丸められた結果をエンコードしていたテストとシリアル化された期待値を更新します。 これには、0 への BigInteger 変換の切り捨てに依存するコードが含まれます。 アプリケーションでプロトコルまたはファイル形式に対して特定のレガシ floatdoubleHalf、または BFloat16 ビット パターンが必要な場合は、数値変換によって再現するのではなく、そのビット パターンを明示的にエンコードします。

以前の変換アルゴリズムを復元するための互換性スイッチはありません。

影響を受ける API