笔者之前的文章 【开箱】知乎社区 2024 年新年礼盒介绍了知乎每年的台历,其中有这样一页我觉得很有意思,它也再次让我儿子,领教了计算机和人类在完成一个任务上的处理差异。
问题:为什么手机计算器上,50% + 50% = 0.75?
![](https://img.haomeiwen.com/i2085791/2003dff75d38dee5.png)
我以前从没留意过这个问题。在三星手机上试了一下,还真是这样:
![](https://img.haomeiwen.com/i2085791/7edc14a13e533e32.png)
知乎上这个问题的回复:
因为手机计算器(大部分情况下的默认计算器),都按照 a% + b% = a + ab% 或 a(1+b%)计算。
当你输入 50% + 50% 的时候,手机先会把前面一个 50% 转化成 0.5(因为它的前面没有数了,于是就默认转成小数,a% = a/100),后一个就理解为“加上前一个数的 50%”,于是 50% + 50% = 50% + 50% * 50% = 50% + 25% = 75% = 0.75.
![](https://img.haomeiwen.com/i2085791/5a3245e1ecd98537.png)
手机计算器的这种处理方式,让我儿子觉得有点不可思议。
让他不可思议的事情还在后头。
我想起了自己多年前,刚接触 JavaScript 时,学习到的一些知识点。
比如 JavaScript 里,0.1 + 0.2 = 0.30000000000000004.
![](https://img.haomeiwen.com/i2085791/922c285076f98136.png)
我儿子第一次看到 JavaScript 这个计算结果时,也很吃惊。
十进制小数 0.1 转二进制的计算过程:
- 0.1*2=0.2……0——整数部分为 0 。整数部分 0 清零后为 0,用 0.2 接着计算。
- 0.2*2=0.4……0——整数部分为 0 。整数部分 0 清零后为 0,用 0.4 接着计算。
- 0.4*2=0.8……0——整数部分为 0 。整数部分 0 清零后为 0,用 0.8 接着计算。
- 0.8*2=1.6……1——整数部分为 1 。整数部分 1 清零后为 0,用 0.6 接着计算。
- 0.6*2=1.2……1——整数部分为 1 。整数部分 1 清零后为 0,用 0.2 接着计算。
- 0.2*2=0.4……0——整数部分为 0。 整数部分 0 清零后为 0,用 0.4 接着计算。
- 0.4*2=0.8……0——整数部分为 0 。整数部分 0 清零后为 0,用 0.8 接着计算。
- 0.8*2=1.6……1——整数部分为 1。 整数部分 1 清零后为 0,用 0.6 接着计算。
- 0.6*2=1.2……1——整数部分为 1。 整数部分 1 清零后为 0,用 0.2 接着计算。
- 0.2*2=0.4……0——整数部分为 0。 整数部分 0 清零后为 0,用 0.4 接着计算。
- 0.4*2=0.8……0——整数部分为 0。 整数部分 0 清零后为 0,用 0.8 接着计算。
- 0.8*2=1.6……1——整数部分为 1。 整数部分 1 清零后为 0,用 0.6 接着计算。
... 无限循环下去
最后十进制小数 0.1 的二进制表示是 0.000110011001100(无限循环下去)
同理,十进制小数 0.2 的二进制表示是 0.00110011001100110011(无限循环下去)
导致 JavaScript 里 0.1 + 0.2 = 0.30000000000000004 问题的根源,在于二进制系统无法精确表示一些十进制小数,例如 0.1 和 0.2.
在 JavaScript 和许多其他编程语言中,浮点数表示采用的是 IEEE 754 标准,这种标准下最高的 1 位是符号位 S,接着的 11 位是指数 E,剩下的 52 位为有效数字 M.
这种规范试图用有限的 64 位,去描述一个在二进制表示形式下会无限循环的十进制数,超出有效数字位之外的循环节,会进行舍入(round)操作,因此会导致精度损失。
![](https://img.haomeiwen.com/i2085791/8ed8125a8496e9bf.png)
当 0.1 和 0.2 这两个浮点数相加时,二者各自进行舍入操作导致的精度损失,在相加时会累积,导致最终的结果略微偏离我们预期的 0.3.
![](https://img.haomeiwen.com/i2085791/28798c5453aae94f.png)
再试试 ABAP:
![](https://img.haomeiwen.com/i2085791/074be52c6cded136.png)
结果和 JavaScript 一致:
![](https://img.haomeiwen.com/i2085791/b351b2c55dcbfd48.png)
所以 ABAP 里 0.3 / 0.1 = 2.9999999999999996 也就不难理解了。
![](https://img.haomeiwen.com/i2085791/7f03b2942ea57a55.png)
看到这里,你认为只有小数才会遇到这些麻烦?
再来看看这个例子:9999999999999999 = 10000000000000000
![](https://img.haomeiwen.com/i2085791/3379d6fdf955a87f.png)
编码时,变量 v1 的值还是硬编码的 9999999999999999.
运行这个 ABAP 报表,调试器里一看,9999999999999999 变成了 10000000000000000:
![](https://img.haomeiwen.com/i2085791/255f12061db5fb67.png)
再看这个泥牛入海的 1:10000000000000000 + 1 = 10000000000000000
![](https://img.haomeiwen.com/i2085791/c7a9d972d6836c35.png)
无论是在循环体外,还是循环体内,如果每次只给 10000000000000000 累加一个 1,这个 1 就像肉包子打狗一样,有去无回。
上面代码里两个 WRITE 语句,打印的仍然是 10000000000000000 这个数。
![](https://img.haomeiwen.com/i2085791/9f6353e434f6698c.png)
![](https://img.haomeiwen.com/i2085791/f007523c4255bddc.png)
但如果是一次性给 10000000000000000 加上 2,这个累加的 2 就生效了:
![](https://img.haomeiwen.com/i2085791/24a048ca0ac29bc7.png)
说到底,这些看似怪异的结果,还是因为 IEEE 754 二进制浮点运算标准造成的。
9999999999999999 被舍入成了离它最近的一个偶数 10000000000000000. 而 10000000000000000 加上 1 之后的结果,因为存储精度损失,被舍入回 10000000000000000,因此无论是直接加1,还是在循环里逐次累加,最后的舍入结果仍然为 10000000000000000.
如果对这些例子感兴趣想深入研究,建议阅读 SAP ABAP 帮助文档:Binary Floating Point Numbers 一节。
![](https://img.haomeiwen.com/i2085791/fe47172f76ce7e62.png)
本来我儿子觉得计算机内部的计算是绝对"精确",永远不会"出错",远远胜过人类的,但是看了这些例子,他对计算机计算能力的信仰崩塌了,囧。
回到本文开头的例子,50% + 50% = 0.75,知乎上被大多数朋友接受的一种说法:这是一种 working as designed 的行为:
![](https://img.haomeiwen.com/i2085791/598a8da5e0e2127e.png)
网友评论