几乎每个写代码的人都撞见过它:0.1 加 0.2,结果不等于 0.3,而是多出一长串莫名其妙的尾巴。我第一次注意到时,先怀疑解释器出了 bug,再怀疑系统坏了,最后发现真正该修正的是自己的直觉。换语言没有用,JavaScript、Java、C++,换到哪里结果都一样------这背后本来就没有 bug,只有一套运行了几十年的标准在忠实地工作。
这篇文章想讲清楚三件事:计算机究竟怎么存小数;误差具体在哪一步产生;工程上有哪些成熟的应对办法。
1 一道不符合直觉的算术题
先看现象。在 Python 的交互式环境里:
python
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
有两个细节值得注意。第一,误差不是随机的:每次运行都得到同一个 0.30000000000000004,它是确定的、可复现的。第二,这不是 Python 的锅------把同样的代码放进 JavaScript、Java、C、C++、Go,结果完全一致,JavaScript 甚至只有这一种常规数字类型。凡是遵循 IEEE 754 浮点标准的语言,无一例外。
换句话说,这不是实现上的疏漏,而是标准本身规定的行为。要理解它,得回到更底层的问题:计算机究竟是怎么存小数的。
2 计算机是怎么存小数的
2.1 二进制小数:0.1 根本写不完
人类用十进制,小数点后每一位代表十的负幂次,0.1 就是 1/10,0.25 就是 25/100。计算机用二进制,规则一模一样,只是底数换成 2:小数点后依次是 1/2、1/4、1/8、1/16。能写成有限个这类分数之和的数,才能被精确存储。
问题出在整除性上。十进制能写尽的分数,分母的质因数只有 2 和 5;二进制能写尽的分数,分母只能是 2 的幂。于是 0.1 转成二进制后是 0.00011001100...,从第四位起 1100 这一段无限循环------就像十进制下的 1/3,0.3333 后面永远还有下一个 3。
所以,0.1 从被写进源代码的那一刻起,就只能以近似值的形态存在。这不是 "精度不够" 的问题:分配再多比特位也没用,无限循环的数在任何有限存储里都注定失真。真正的解法只剩一个------规定一套统一的截断与舍入格式,并且让所有机器都遵守它。
这就是 IEEE 754。
2.2 IEEE 754:有限比特里的科学计数法
以最常用的 64 位双精度浮点数(也就是 Java 的 double、Python 的 float,JavaScript 的 Number 同样是它)为例,一个数被拆成三段:
- **1 位符号位:**标记正负;
- **11 位指数位:**决定数量级,对应科学计数法里 10 的那个指数;
- **52 位尾数位:**存放有效数字。
它相当于二进制版的科学计数法:任何非零的数都规整成 1.xxx × 2^n 的形式。小数点前的这个 1 永远固定,标准干脆不存它,称为 "隐藏位" ------所以 52 位尾数实际能表达 53 位二进制有效数字,折算成十进制大约相当于 15 到 17 位有效数字。顺带一提,32 位单精度只是配比不同(1、8、23),思想完全一致。
还有个细节:指数位存的是加过偏置的码(双精度的偏置为 1023),这样正负指数都能以无符号的形式表达。至于舍入规则,标准默认采用 "就近舍入,平局向偶数舍入"。这套设计的意图很清楚:在有限的比特里,同时换取足够大的表示范围和足够可预测的运算行为。至于代价,下一节就能看到。
3 误差到底出在哪一步
回到 0.1+0.2。整个计算分三步,每一步都有一次舍入的机会:
- 第一步,把字面量 0.1 转成二进制。展开是无限循环,只能保留 53 位有效数字,舍入一次,存成一个比 0.1 略大的近似值;
- 第二步,0.2 走同样的流程,同样舍入;
- 第三步,两个近似值相加。精确和需要更多位,超出 53 位的容量,于是再舍入一次,结果才落进内存。
换句话说,那个 0.30000000000000004 不是 "算错了",而是三次舍入叠加后的产物,并且已经是这套规则下最接近真值的答案。
Python 提供了一个很方便的观察窗口,用 decimal.Decimal 包裹一个 float,就能看到它真正存下来的十进制值:
python
from decimal import Decimal
print(Decimal(0.1)) # 0.1000000000000000055511151231257827021181583404541015625
print(Decimal(0.2)) # 0.200000000000000011102230246251565404236316680908203125
print(Decimal(0.1) + Decimal(0.2))
# 0.3000000000000000166533453693773481063544750213623046875
print(Decimal(0.1 + 0.2))
# 0.3000000000000000444089209850062616169452667236328125
前两行是两个近似值的真身,第三行是它们相加的精确结果------注意它依然无法被 53 位有效数字容纳;第四行才是 0.1+0.2 真正写进内存的值。至于 print(0.1 + 0.2) 显示的 0.30000000000000004,只是最短往返表示:能唯一还原这个浮点数的最短十进制写法。这也是 print(0.1) 看起来 "没事" 的原因------0.1 存的确实是近似值,repr 只是挑了最短的写法,把尾巴藏了起来。细看前两行还会发现,0.2 的存储值恰好是 0.1 的两倍------尾数完全相同,只是指数加了 1。
再深挖一层,0.1 的二进制真身可以用 hex 方法看到:
python
>>> (0.1).hex()
'0x1.999999999999ap-4'
含义是 1.999999999999a(十六进制)乘以 2 的负 4 次幂。0.1 规格化后的有效数字是 1.100110011...,小数部分每 4 位一组换算成十六进制,恰好是 9 的无限循环;存储时只能保留 13 个十六进制位(正好 52 位),无限延续的 9 在末尾被舍入进位,变成了 a------这个 a,就是舍入留下的指纹。
4 工程上怎么应对
理解了原理,剩下的就是把它翻译成工程习惯。下面三个场景,基本覆盖了日常开发的大多数情况。
4.1 金额计算:把浮点数挡在外面
浮点数的用武之地是科学计算、图形渲染、信号处理这类允许微小误差的领域,金额不属于这里:一分钱的偏差乘以亿万笔交易,就是实打实的资损。两种可靠的做法:
- **整数方案:**以最小货币单位存储,比如 "分"。1.25 元存成 125,四则运算全部在整数域完成,天然精确,展示时再格式化;
- **十进制定点方案:**Java 的 BigDecimal、Python 的 decimal.Decimal、MySQL 的 DECIMAL 类型,按十进制方式存储和运算,0.1 就是真正的 0.1。
BigDecimal 有一个流传极广的坑,值得单独展示------它有两个构造入口,走字符串还是走 double,结果天差地别:
java
import java.math.BigDecimal;
public class MoneyDemo {
public static void main(String[] args) {
BigDecimal fromString = new BigDecimal("0.1");
BigDecimal fromDouble = new BigDecimal(0.1);
System.out.println(fromString); // 0.1
System.out.println(fromDouble);
// 0.1000000000000000055511151231257827021181583404541015625
}
}
字符串构造得到的是精确的 0.1;double 构造会先把字面量转成二进制近似值,再把近似值原样打包进来。所以涉及 BigDecimal 的编码规范几乎都写着同一句话:构造参数一律用字符串。
4.2 浮点数比较:放弃 ==,改用容差
科学计算、图形学、机器学习绕不开浮点数,此时的正确姿势是容差比较。Python 3.5 起标准库内置了 math.isclose:
python
import math
a = 0.1 + 0.2
b = 0.3
print(a == b) # False
print(math.isclose(a, b)) # True
math.isclose 默认采用相对容差(rel_tol 为 1e-09),数量级越大允许的偏差越大。这比手写 abs(a - b) < 1e-9 更稳妥:绝对容差在数量级悬殊时要么过松要么过紧,而对接近 0 的数,还需要 abs_tol 参数兜底。NumPy 用户可以直接用 np.allclose,它把两种容差都封装好了。容差取多少没有标准答案,取决于业务能接受的精度。
4.3 连续求和:小误差会被攒大
单次运算的误差微不足道,但求和会把它攒成大问题:朴素累加的误差随次数增长,在特定数据分布下还会被放大。经典对策是 Kahan 补偿求和,思路很巧------用一个额外变量,把每一步被舍入 "吃掉" 的低位记下来,下一步加回去。
python
def kahan_sum(values):
total = 0.0
compensation = 0.0 # 补偿项:记录每一步被舍入吃掉的低位
for x in values:
y = x - compensation # 先把上一轮丢掉的部分补回来
t = total + y # 常规累加
compensation = (t - total) - y # 提取这一轮实际损失
total = t
return total
用十次 0.1 累加做个对照:
python
import math
data = [0.1] * 10
def naive_sum(values):
total = 0.0
for x in values:
total += x
return total
print(naive_sum(data)) # 0.9999999999999999
print(math.fsum(data)) # 1.0
print(kahan_sum(data)) # 1.0
十次累加就能让朴素求和差出一个最低位,规模一大可想而知。工具层面也在进步:math.fsum 从设计上就保证正确舍入的结果,NumPy 内部用成对求和压低误差,Python 3.12 起连内置的 sum() 都换上了更精细的 Neumaier 算法。但在依赖工具之前,先弄清楚误差从哪里来、往哪里去,永远处在更可靠的位置。
5 写在最后
浮点数精度问题常被当成 "语言的坑",我更愿意把它看作一扇窗:透过 0.1+0.2 这个小小的反常,能看清一个数在计算机里的完整旅程------进制转换、格式约束、反复舍入、误差累积。这条链路想通之后,那些散落各处的 "最佳实践"(金额用整数、比较用容差、求和用 math.fsum)就不再是需要背诵的条目,而是可以自己推导出来的结论。
补一个有趣的事实:Raku(原 Perl 6)默认用有理数存储 0.1,在那里 0.1+0.2==0.3 是成立的。这不是魔法,只是把精确表示 1/10 的成本转移到了类型系统里,同时引入了它自己的表示限制。工程世界没有免费的午餐,只有清醒的取舍。
下次再遇到 "不符合直觉" 的输出,不妨别急着绕过去。往下一层,通常站着一个值得搞懂的设计。