😖

【.NET 11 Preview 7】浮動小数点数とdecimalの変換処理が変わったことによる影響

に公開

はじめに

先日に、.NET 11 Preview 7がリリースされました。

https://devblogs.microsoft.com/dotnet/dotnet-11-preview-7/

RC1に近づいているためなのか、Preview 6と比較しても、多くの内容の追加と更新、バグ修正が行われました。
その中で、個人的に直近でハマった動作変更(バグ修正)である「decimalと浮動小数点数の変換処理の修正」について、実例を用いて紹介します。

内容

まず対応が行われたPRは以下になります。

https://github.com/dotnet/runtime/pull/130566

修正内容としては、decimalと浮動小数点数(float/double)の変換処理を再実装し、正しく厳密に変換するようになったというものです。
.NET 11 Preview 7より前の動作だと、doubleを例に出すと、doubleからdecimalへキャストした場合、有効数字分の15桁に丸めてから変換する動作になっていましたが、このPRにより、doubleの厳密値を正確に変換するようになりました。

どういう動作変更が発生するか

以下のサンプルコードを例に実際にどう変わるか説明します。
サンプルコード自体は、doubleである3.14decimalにキャストし、3.14mである場合、"3.14m と同じ!"が出力、それ以外の値だと"3.14m と違う!"が出力されます。

3.14をdecimalにキャストするサンプルコード
decimal castedValue = (decimal)3.14;

if (castedValue == 3.14m)
{
    Console.WriteLine("3.14m と同じ!");
}
else
{
    Console.WriteLine("3.14m と違う!");
}

まず、double3.14は、厳密値でいうと3.1400000000000001243449787580....という値になっています。これは、以下コードから確認することが可能です。

double doubleValue = 3.14;

// 最短往復表現で出力
// doubleValue: 3.14
Console.WriteLine($"doubleValue: {doubleValue}");

// 有効数字30桁まで出力
// doubleValue: 3.14000000000000012434497875802
Console.WriteLine($"doubleValue: {doubleValue:G30}");

.NET 11 Preview 7より前では、3.14decimalにキャストすると3.14mと同じ値になります。そのため、castedValue == 3.14mはtrueとなり、3.14m と同じ!と出力されます。

decimal castedValue = (decimal)3.14; // 3.14m と同じ

if (castedValue == 3.14m)
{
    Console.WriteLine("3.14m と同じ!"); // ここに入る
}

.NET 11 Preview 7の場合、3.14decimalにキャストすると、doubleの厳密値をdecimalに変換するため、3.14mではなく3.1400000000000001243449787580mになり、castedValue == 3.14mfalseとなるため、"3.14m と違う!"と出力されます。

decimal castedValue = (decimal)3.14; // 3.1400000000000001243449787580m になる

...
else
{
    Console.WriteLine("3.14m と違う!"); // ここに入る
}

これから、.NET 11 Preview 7からdecimalへキャストした場合の値が変わっていることが確認できました。

ハマった点

では、私が実際にハマった実例をベースに記載します。

まず一般C#er?たるもの新しいバージョンを試すために、.NET 11 preview 7をインストールしました。
そして、自作のTOMLパーサー/シリアライザーCsToml[1]のテストを実行したところ、ローカルでもGitHub Actionでのテストでも問題なかったはずなのに、一部テストが失敗するようになっていました。
以下が実際に失敗したテストの一例です。(テスト自体はnet8.0/net9.0/net10.0で実行しています。)

CsTomlで失敗したテスト例
[Fact]
public void DeserializeFromFloat()
{
    using var buffer = Utf8String.CreateWriter(out var writer);
    writer.AppendLine("IntegerValue = 0");
    writer.AppendLine("FloatValue = 3.14");
    writer.AppendLine("NegativeValue = -123.456");
    writer.AppendLine("ZeroValue = 0.0");
    writer.Flush();

    var result = CsTomlSerializer.Deserialize<TypeDecimal>(buffer.WrittenSpan);
    result.FloatValue.ShouldBe((decimal)3.14);
    result.NegativeValue.ShouldBe((decimal)(-123.456));
    result.ZeroValue.ShouldBe(0m);
}

ここで注目すべき点は、(decimal)3.14は「定数値3.14decimalキャスト」であることが確定していることから、定数値として書き込まれますが、この書き込みがビルド時に行われることです。
そしてはビルドを行うコンパイラは .NET 11 preview 7 のランタイム上で動作するため、対象フレームワークがnet8.0/net9.0/net10.0で実行した場合でも、今回の動作変更の影響を受けて、3.1400000000000001243449787580mになります。実際にILSpyで確認したところ、(decimal)3.143.1400000000000001243449787580mになっています。

ビルドを行うコンパイラが.net11ランタイム(net10.0)でビルド時のILSpy結果

確認のため、global.jsonでSDKを.NET 10 ランタイムに指定し、同様のビルドを行ったところ、(decimal)3.143.14mになっています。

ビルドを行うコンパイラが.net10ランタイム(net10.0)でビルド時のILSpy結果

このことから、今回の動作変更の影響によるものと判断して、decimalキャストしている部分をdecimalリテラルのmサフィックスを用いた形に変更しました。

mサフィックスを使用する
var result = CsTomlSerializer.Deserialize<TypeDecimal>(buffer.WrittenSpan);
result.FloatValue.ShouldBe(3.14m);  // (decimal)3.14から3.14mに変更!
result.NegativeValue.ShouldBe(-123.456m);
result.ZeroValue.ShouldBe(0m);

再度テストを実行して全て問題ないことを確認した後、ついでに.NET 11での確認のためにターゲットフレームワークにnet11.0を追加すると、再び失敗するテストが発生する事態に...。
デバッガーで実際の値を確認してみると、result.FloatValue3.14ではなく厳密値である3.1400000000000001243449787580mになっていました。

.net11.0でのDeserializeFromFloatテストのデバッグ

ちなみ、net10.0だと以下の通りに3.14になっています。

.net10.0

ではライブラリ側の実装を確認すると、予想通り(decimal)doubleValueがありました。

CsToml
internal sealed class DecimalFormatter : ITomlValueFormatter<decimal>
{
    public decimal Deserialize(ref TomlDocumentNode rootNode, CsTomlSerializerOptions options)
    {
//...

        if (rootNode.TryGetDouble(out var doubleValue))
        {
            return (decimal)doubleValue;
        }

        ExceptionHelper.ThrowDeserializationFailed(typeof(decimal));
        return default;
    }
}

ライブラリでは、decimalの直接キャストを使うのではなく、doubleSpan<byte>に書き込んだ後にdecimalに再パースするように修正を行いました。(破壊的変更にはなりますが、ランタイムのバージョンに依存せず、TOMLに書かれた桁をそのまま返すようにしました。)
これで、.NET 10/.NET 11 のどちらでも、テストは成功するようになりました。

doubleをSpan<byte>に書き込んだ後、decimalに変換する
internal static decimal ConvertToDecimal(double doubleValue)
{
    Span<byte> utf8 = stackalloc byte[32];
    if (doubleValue.TryFormat(utf8, out var bytesWritten, "G", CultureInfo.InvariantCulture) &&
        Utf8Parser.TryParse(utf8.Slice(0, bytesWritten), out decimal value, out var bytesConsumed, 'G') &&
        bytesConsumed == bytesWritten)
    {
        return value;
    }
    return (decimal)doubleValue;
}

SDK × 実行ランタイム

SDK/ランタイム、実行時変換と定数畳み込みの対応表としては以下の通りになります。
ビルドを行うコンパイラのランタイムと実行ランタイムが異なる場合(今回だと.NET 11 preview 7/.NET 10)には、実行時と定数時で動作が一致しないので、注意するべきところです。

SDK 実行ランタイム 実行時 (decimal)3.14 定数 (decimal)3.14
.NET 10.0.400 .NET 10 3.14m 3.14m
.NET 10.0.400 .NET 11 ※1 3.14m
.NET 11 preview 7 .NET 10 3.14m ※1
.NET 11 preview 7 .NET 11 ※1 ※1

※1は 3.1400000000000001243449787580m

おわりに

ということで、実際に私がハマった例を踏まえた、.NET 11 Preview 7が含まれた「decimalと浮動小数点数の変換処理」による動作変更についてでした。
PR自体は、マージした際に確認していたのですが、個人的にはあまり影響しないかなと思っていたのですが、無事影響を受けました笑。(こればっかり実際に発生しないとわからないものですね)

それでは今回はこのへんで。

追記 2026/09/09

.NET 11 RC1のリリースがありました。
そのノートにこの動作について、.NET 11の破壊的変更カテゴリ(Breaking changes in .NET 11)の一つとして文書化されていました。

https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/11/decimal-biginteger-floating-point-conversions

脚注
  1. prozolic/CsToml - https://github.com/prozolic/CsToml ↩︎

Discussion