使用浮点字面量与浮点变量时编译器行为异常

7

我注意到了C#编译器在浮点数的四舍五入/截断方面有一个有趣的行为。当一个浮点字面量超出了可表示的范围(7位小数)时,a) 明确将float结果强制转换为float(在语义上不必要的操作)和b) 将中间计算结果存储在本地变量中都会改变输出结果。例如:

using System;

class Program
{
    static void Main()
    {
        float f = 2.0499999f;
        var a = f * 100f;
        var b = (int) (f * 100f);
        var c = (int) (float) (f * 100f);
        var d = (int) a;
        var e = (int) (float) a;
        Console.WriteLine(a);
        Console.WriteLine(b);
        Console.WriteLine(c);
        Console.WriteLine(d);
        Console.WriteLine(e);
    }
}

输出结果如下:
205
204
205
205
205

在我的电脑上,经过JIT编译的调试版本中,b的计算方法如下:
          var b = (int) (f * 100f);
0000005a  fld         dword ptr [ebp-3Ch] 
0000005d  fmul        dword ptr ds:[035E1648h] 
00000063  fstp        qword ptr [ebp-5Ch] 
00000066  movsd       xmm0,mmword ptr [ebp-5Ch] 
0000006b  cvttsd2si   eax,xmm0 
0000006f  mov         dword ptr [ebp-44h],eax 

而d的计算方式如下:

          var d = (int) a;
00000096  fld         dword ptr [ebp-40h] 
00000099  fstp        qword ptr [ebp-5Ch] 
0000009c  movsd       xmm0,mmword ptr [ebp-5Ch] 
000000a1  cvttsd2si   eax,xmm0 
000000a5  mov         dword ptr [ebp-4Ch],eax 

最后,我的问题是:为什么输出的第二行与第四行不同?这额外的fmul有如此大的影响吗?还要注意,如果去掉甚至减少浮点数f中的最后一个(已经无法表示的)数字,一切都“就位”了。


我在这里看到了这个问题的答案,但是找不到它。 - Andrey
3个回答

5

您的问题可以简化为询问为什么这两个结果不同:

float f = 2.0499999f;
var a = f * 100f;
var b = (int)(f * 100f);
var d = (int)a;
Console.WriteLine(b);
Console.WriteLine(d);

如果您查看.NET Reflector中的代码,您会发现上述代码实际上被编译成以下代码:
float f = 2.05f;
float a = f * 100f;
int b = (int) (f * 100f);
int d = (int) a;
Console.WriteLine(b);
Console.WriteLine(d);

浮点数计算并不总能精确进行。 由于舍入误差,2.05 * 100f的结果并不完全等于205,而只是稍微少一点。当将这个中间结果转换为整数时会被截断。当存储为浮点数时,它会四舍五入到最近可表示的形式。这两种舍入方法会产生不同的结果。
关于您在我的答案中写下的评论:
Console.WriteLine((int) (2.0499999f * 100f));
Console.WriteLine((int)(float)(2.0499999f * 100f));

计算完全在编译器中完成。上述代码等同于以下代码:
Console.WriteLine(204);
Console.WriteLine(205);

所以你说原因是(int)通过截断来完成,而(float)表示四舍五入。如果是这样的话,那么为什么下面两个输出结果不同呢? Console.WriteLine((int)(2.0499999f * 100f)) 和 Console.WriteLine((int)(float)(2.0499999f * 100f))? - Alan
@Alan,请检查我的答案。原因是float只能容纳7个数字。log(2 ^ 23)= 6.9。 - Andrey
@Alan:当您使用硬编码常量时,计算完全在编译器中进行,并使用编译器的规则,而不是在.NET运行时中进行。 - Mark Byers
@Andrey,谢谢。我知道浮点数超出了可表示范围(请参见问题,我没有编辑那部分),但是有了您的确认,这让我感到有些害怕-将浮点数强制转换为浮点数不应该有任何区别,但在这种情况下确实如此。 - Alan
@Mark:这些规则不同吗?如果是,我是否应该从C#语言参考文档或MSDN中了解到这一点,还是这只是编译器和运行时之间偶尔的差异? - Alan
@Alan:显然,正如你的例子所示,它们是不同的。但我认为,一般来说,依赖浮点计算的最低有效数字是一个极其糟糕的想法。 - Mark Byers

4

在你的评论中,你问道:

这些规则不同吗?

是的。或者说,规则允许不同的行为。

如果是这样,我是否应该知道这一点,无论是从C#语言参考文档还是MSDN,还是这只是编译器和运行时之间偶尔的差异?

这是规范所暗示的。浮点运算有一定的最小精度水平必须满足,但编译器或运行时可以根据需要使用更多的精度。当进行放大小变化的操作时,这可能会导致大量可观察的变化。例如,四舍五入可以将一个极小的变化转变成一个极大的变化。

这个事实经常在这里被问到。关于这种情况和其他可能产生类似差异的情况的背景,请参见以下内容:

为什么这个浮点数计算在不同的机器上给出不同的结果?

C# XNA Visual Studio:发布模式和调试模式之间的区别?

CLR JIT优化违反因果关系?

https://stackoverflow.com/questions/2494724


1
Eric,非常感谢你。你最后提供的链接特别有启发性。实际上,在我发布问题之前,我曾经搜索过类似的情况,但显然我的范围太狭窄了。 - Alan

2

马克对编译器的看法是正确的。现在让我们愚弄这个编译器:

    float f = (Math.Sin(0.5) < 5) ? 2.0499999f : -1;
    var a = f * 100f;
    var b = (int) (f * 100f);
    var c = (int) (float) (f * 100f);
    var d = (int) a;
    var e = (int) (float) a;
    Console.WriteLine(a);
    Console.WriteLine(b);
    Console.WriteLine(c);
    Console.WriteLine(d);
    Console.WriteLine(e);

第一个表达式没有意义,但可以防止编译器进行优化。结果是:

205
204
205
204
205

好的,我找到了解释。

2.0499999f无法存储为浮点数,因为它只能容纳7个十进制数字。而这个字面量是8位数字,所以编译器会将其四舍五入,因为无法存储。(在我看来应该给出一个警告)

如果您改为2.049999f,则将得到预期的结果。


谢谢Andrey,我根据编译器与运行时信息选择了Mark的回复,但你的回复也很相关。 - Alan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接