同事在工作中遇到了一个与浮点数运算相关的奇怪问题,值得一记,该问题涉及代码摘要如下:
1 #include <iostream> 2 using namespace std; 3 4 int main() 5 { 6 double s = 6.0; 7 double e = 0.2; 8 9 cout << static_cast<int>(s/e) << endl; 10 return 0; 11 }
这段代码看起来很简单,眉头略皱掐指一算,应该输出30才对,但结果却是我们在 32 和 64位 linux 平台下得到了不同的结果,分别是 29 和 30,意想不到吧?
然后,如果把代码改成如下:
1 #include <iostream> 2 using namespace std; 3 4 int main() 5 { 6 double s = 6.0; 7 double e = 0.2; 8 9 double d = s/e; 10 11 cout << static_cast<int>(d) << endl; 12 return 0; 13 }
你会发现在两个平台上都得到了相同的“正确”结果!为什么呢?
稀疏的浮点数
众所周知,计算机是无法精确地表示所有浮点数的,无理数的稠密性使得无论我们用多高精度的数据类型来表示浮点数,所能表示的范围相对整个无理数来说都是相当相当地稀疏。
因此在计算机的世界里,我们只能尽可能地用有限精度来表示一定范围的数据,至于那些没法精确表示的数字,就只能在计算机所能表示的范围里找一个和它最接近的数来凑和凑和。
这个好像比较好理解,比如说根号2什么的,我们都知道这些无理数不能在计算机里完全精确地表示,但还有那么一些有理数,在十进制里虽然可以精确地表示,在二进制里却也是无法精确表示的,比如说上面例子中的0.2,你如果对此有怀疑,可以好好回顾一下怎么把小数转成二进制,然后慢慢用笔在纸上演算一下。
讲这些,无非还是想说明,计算机世界里的浮点数是相当疏松地,借用《深入理解操作系统》一书里的一张图,让大家感受一下:
上图展示的是按照IEEE754标准,用一个6bit来表示浮点数时,所能表示的数据范围。
浮点数的折断与转换
因为很多小数是无法精确表示的,因此我们只能尽可能在有限精度的小数里找到最接近的数来近似那些无限的小数。
那么计算机是怎么样来做这些逼近的呢?常用的有如下4种方式:
其中第一种是默认使用方式,需要注意的是这些折断方式并不仅限于由浮点数转为整数,浮点数之间也是适用的。
在C语言中,浮点数与整数的转换有以下几条原则:
1) int型转为float,不会overfloat,但有些数用float无法表示,因此可能需要rounding,记住float是很稀疏的。
2) 由int或float转为double时,精度不会丢失,毕竟double精度高太多了。
3) double转为float时,很可能会overfloat, 转换则用round-to-even的方式(默认)进行。
4) 由float, double转换为int时用round-to-zero的方式转换,当然也很可能会被截断。
请注意第3条,第4条原则,它们转换时使用的不同原则有时会导致一些很微妙的结果。
Intel IA32 浮点运算
IA32 处理器和很多其它一些处理器一样,有专门用于保存浮点数的寄存器,当在 cpu 中进行浮点数运算时,这些寄存器就用来保存输入输出及相关的中间结果。但 IA32 有一个比较特别的地方,它的浮点数寄存器是 80 位的,而我们在程序中只用到 32 和 64 位两种类型,因此当把 float,double 放入到 cpu 中时,它们都会先被转换成了 80 位,然后以 80 位的方式进行运算,最后得到的结果再转换回来。这样特性使得浮点数的计算可以相对更精确些,但同时,一不小心很可能也会引出一些意想不到的问题。
你可能突然恍然大悟了,对的,我们最开始提到那个奇怪的问题就与此相关:s/e得到结果是个80位的浮点数,由这个浮点数先转换成double再转成int,与直接就转换成int,结果很可能是不同的。比如在我们的例子中,s/e ~ 29.999999....时,s/e转换成double使用round-to-even的方式,会得到也许是30.0000001,再转成整形时,得到30,但如果直接由29.99999...转换成整型,得到却是29。
后来新出的系列Intel处理器,包括IA32及64位的处理器,提供了专门的硬件来直接处理浮点数,使得可以分开对待float型与double型,这些硬件特性在compiler的支持下,可以生相对高效的代码,同时也避免了我们上面所遇到的问题,有兴趣的读者可以 google 一下相关的关键字: sse。