【解码】浮点数精度问题 | 为什么(int)(32.3 x 100) = 3229?
零 | 序
前几天在找一个代码问题时,苦思不得其解,简直要怀疑人生。查看各种参数,输入输出,都符合条件,最后各种排除法之后,定位到一段简单的代码,简化后大致如下:
#include<stdio.h> int main() { double a = 32.3; int b = 100; int c = (int)(a*b); printf("c = %d",c); //c = 3229 return 0; }
原代码中本来预想c应该会等于3230,可是最后的结果却是3229!!!
第一反应就觉得应该是浮点数精度问题,但是怎么条理清晰地向别人解释呢?好像有点难度,于是回家认真翻阅了下书籍,整理了一下思路。
一个简单的解释是:
我们都知道计算机中只有0和1,也没有小数点,因此要表示浮点数时有自己的一套表示方法,这套表示方法在有限位数情况下有时并不能精确的表示某个浮点数,只能尽量逼近它,例如这个例子中,我们定义了一个double型的32.3,我们以为它表示32.3,但是计算机用有限长的0和1只能表示32.2999999......,这样当这个数乘上100时,就变成了3229.99999,当它从double转型成int时,小数点被舍掉了,就变成了3229。
这个解释......好像似懂非懂的样子,那么问题来了:
1. 浮点数在计算机中到底是怎么存储的?为什么有的小数无法精确表示?
2. 浮点数乘法是怎么实现的?
3. double转型成int时,为什么会把小数舍掉?
一 | 浮点数表示
要理解上面的问题,我们先从更简单的2进制小数开始,我们知道在10进制中:123.45 = 1 x 102 + 2 x 101 + 3 x 100 + 4 x 10-1 + 5 x 10-2,
类似的2进制小数也可以这样表示:101.11 = 1 x 22 + 0 x 21 + 1 x 20 + 1 x 2-1 + 1 x 2-2 = 5.75,
如果考虑有限长度,我们知道1/3在10进制中没办法准确表示,同样的,二进制中也有不能精确表示的数,如1/5,二进制只能表示那些能被写成a x 2b的数,就像上面的5.75。
所以本文开始的例子中32.3 = 0010 0000.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 ...... = 32.29999999999......
这就解释了为什么32.3在计算机中是32.999999999999......
等等!不是说计算机中没有小数点的吗?
确实如此,因此在实际计算机中,采用的是IEEE浮点表示法(IEEE-754标准),即V=(-1)s x (1.M) x 2E-f表示一个浮点数,其中s是符号,M是尾数,E是阶码,其存储规则如下:
单精度格式(32位):符号位(s)1位;阶码(E)8位,阶码的偏移量(f)为127(7FH);尾数(M)23位,用小数表示,小数点放在尾数域的最前面;
双精度格式(64位):符号位(s)1位;阶码(E)11位,阶码的偏移量(f)为1023(3FFH);尾数(M)52位,用小数表示,小数点放在尾数域的最前面。
举个简单的例子:(1.75)10 = (1.11)2 = 1.11 x 20,所以在单精度格式中s = 0,M = 11,E = 127 = (01111111)2
因此在计算机中,float型的1.75存储为 0 01111111 11000000000000000000000 = (3FE00000)16,
而double型的1.75存储为(3FFC000000000000)16,这个就留给您自己去推算一遍了。
下面我们通过一段代码来验证一下上面的原理,证实1.75在计算机中确实是这样存储的。
首先我们定义一个指向类型为unsigned char的对象指针,然后定义一个show_bytes方法,打印出每个以16进制表示的字节,%.2x表示整数必须用至少两个数字的十六进制格式输出。接着定义show_int,show_float,show_double分别调用show_bytes,根据不同的类型和长度,打印出对应的字节表示。
#include<stdio.h> typedef unsigned char *byte_pointer; //定义一个指向类型为unsigned char的对象指针
//以16进制打印指针指向地址中的字节序列 void show_bytes(byte_pointer start, int len){ int i; for (i = 0; i < len; i++) printf("%.2x",start[i]); printf("\n"); } //打印整数型变量 void show_int(int x){ show_bytes((byte_pointer)&x, sizeof(int)); } //打印单精度浮点变量 void show_float(float x){ show_bytes((byte_pointer)&x, sizeof(float)); } //打印双精度浮点变量 void show_double(double x){ show_bytes((byte_pointer)&x, sizeof(double)); }
//主程序
int main() { double a = 1.75; show_float(a); show_double(a);
return 0; }
运行结果为:
0000e03f 000000000000fc3f
注意到这里结果似乎跟我们推算的值不太一样,这是因为我的计算机采用小端法存储(这个概念如不清楚请Google之),即把低序的存在低地址,所以00 00 e0 3f从高地址开始读就是3f e0 00 00。那么本文一开始提到的32.3在双精度中是怎么表示的呢?修改程序后运行可得:
cdcc0042
9a99999999194040
二 | 浮点数乘法
搞清楚了浮点数的存储方式,我们来看看浮点数的乘法是怎么实现的。假设有两个浮点数:
x = Mx x 2Ex y = My x 2Ey
那么x*y =( Mx x 2Ex ) ( My x 2Ey ) = 2Ex+Ey·(Mx * My),
也就是说两个浮点数相乘的结果就是它们的阶码相加,尾数相乘。
所以在双精度中32.3 x 100 = (1.0000 0010 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 x 25) x (1.1001 x 26)
= 1.1001 0011 1011 1111 1111 1111 1111... x 211
= (0100 0000 1010 1001 0011 1011 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111)2
= 3299.9999999999......
这里是以二进制小数的方式简单说明了下浮点数的乘法,虽然浮点数的乘法可以转换成定点数的加法和乘法,但我们知道在计算机中的0和1,也并没有真正的“加法”和“乘法”,所有的操作是通过寄存器和逻辑门操作完成的,想要真正“理解”浮点数乘法操作是怎么实现的,不妨研读下“汇编语言”相关内容。
PS. 顺便说一下,浮点数32.3乘整数100,按C语言的规则是100转成浮点数再运算,而不是32.3先转成整数再运算。
三 | 浮点数转型成整数
浮点数转型成整数时,会把小数点舍掉,有人说,这是C语言规定的,没什么好解释的。但是计算机总有自己的一套规则吧,究竟是怎么转换的呢?这方面容我再好好深入学习下《汇编原理》和《深入理解计算机系统》,再来向各位汇报,也欢迎各位大神指导。
另外,如果把文章开头的double a = 32.3变成float a = 32.3,结果c会变成3230,各位读者如果有兴趣可以思考下为什么。
总结一下,浮点有风险,使用需谨慎!
参考文献:
1. 《深入理解计算机系统(第2版)》 机械工业出版社
2. https://en.wikipedia.org/wiki/IEEE_754_revision
3. http://share.onlinesjtu.com/mod/tab/view.php?id=176