从一道招聘考题谈起
润乾研发部在招聘时有一个笔试题:
1/2,1/5,1/20,1/64,1/125 都可以写成有限小数,而 1/3,1/7,1/15,1/24 则必须写成无限循环小数。请指出能写成有限小数的分数具有什么样的特征?在什么情况下 1/5 也会被写成无限循环小数?
坦白地说,这个题的通过率并不高,不到一半吧。
仔细分析题目中的分母,我们会发现,这些能写成有限小数的分母,分解质因数之后就都只有 2 和 5 这两种质因子。而那些不能写成有限小数的分母分解因数后则含有不是 2 和 5 的质因子。也就是说,只有当分母可以写成 2^n*5^m 这种形式时才可能写成有限小数。
这是为什么呢?
其实很简单,因为我们把这些分数写成小数时使用的是 10 进制。而 10=2*5,对于任何一个形如 2^n*5^m 的分母,设 k=max(n,m),则把这个数乘以 10^k,即 2^k*5^k,就一定会变成一个整数了,也就是说,这个数的小数部分最多只有 k 位,这当然是有限的。而如果分母中含有其它质因子,则不可能有个 k 使得让这个数乘以 10^k 后变成整数,也就只能是无限小数了。
那么,什么时候 1/5 会写成无限小数呢?
如果我们采用的数制不是 5 的倍数,就会发现这种情况了,比如计算机普遍采用的 2 进制,这时候 1/5 会写成一个无限循环小数。
但是,我们的机器都是有限位数的,不可能真地表示一个无限位的数,只能舍弃后面的位。也就是说,1/5 在计算机中是不能被精确表示的!
这个现象会影响到我们的程序设计。
比如我们写一段这样的代码:
double x = 0;
for ( int i=0; i<=1000; i++ )
x+=0.001;
我们现在想当然地认为 x 会等于 1,然而并不是!在我的机器上的 Java 环境中跑出来 x=1.0000000000000007,一个奇怪的结果。
为什么要强调的是我的机器上的 Java 环境呢?因为浮点数的表示和 CPU 以及编译器都有关系,换一台机器或编译器就可能会跑出不同的结果。
而且,结果也不总是变大。如果我们改成反复加 1 万次(即用 i<=10000),得到的 x=9.999999999999897,没啥规律可言。
计算结果和预期值的误差其实非常小,会有什么后果吗?
如果是用于后续再计算(比如再加减乘除等),这个误差确实不重要,可以不去理它。但有时候我们可能会把它用于比较,再根据比较结果做下面的动作。比如我们预期这个 x 应当等于 1,如果后续是这样的代码:
if ( x==1.0 ) {…} else {…}
那就会执行出错误的结果了,这个 bug 还很难被发现,代码逻辑上看完全没有问题。而且由于前面所说的该现象出现的随机性,也不是对任何数都一定会产生这个结果,很可能在测试时没碰到而被放过了。
那么,怎么避免这个错误呢?
在涉及浮点数相等比较时,一般不要直接使用精确地相等去判断,而要看差的绝对值是否小于某个很小的数。代码写成:
if ( abs(x-1.0) < 1E-10) ….
就不会错了。
如果目标比较值是整数,那还可以将计算结果转换成整数,整数在 2 进制下都可以精确表示,可以放心地用 == 去判断,但注意要做四舍五入,即
if ( int(x+0.5) == 1 ) ….
如果直接用 int(x) 取整,在计算结果因舍位误差小于预期结果时,也会出错。
比较值不是整数,但能保证一定位数的精度,可以先用乘法再转换成整数:
if ( int(x*1000+0.5) == 1000 ) …
还有的办法就是避免使用浮点数。
我们知道,现代数据库都提供有 decimal 数据类型,其实就是这么个思路。decimal 可以称为定点数,其小数部分也是按位数存储的,计算时能够精确表示,不会有上述的误差。但是,decimal 不是现代 CPU 直接支持的数据类型,需要数据库软件来自行实现其计算逻辑,性能就会差出很多。所以,在不需要这种精度时(比如只是计算总数或平均值等),我们还是把它转换成浮点数来计算更好一点。集算器在从数据库取数时提供了 @d 选项就是为了自动把 decimal 转成浮点数获得高性能,但需要冒不精确的风险,所以做成选项由程序员自行根据场景决定。
实际业务中,需要精确比较的浮点数常常是金额。大多数国家的货币都是两位小数的,这样我们可以将数值先乘以 100 转换成整数再存储,而整数的运算和比较都是精确的,不会出现这种问题,但是在显示时需要再转换回来变成用户习惯的两位小数写法。CPU 计算和处理整数的性能也非常高,64 位的 CPU 能够表示的整数范围在±2^63,即使除以 100 也还有 16 位整数部分,大约是 1 千万亿,这对于相当多的场景都够用了。这样就即有精确度又有高性能。