从 0.1+0.2 不等于 0.3 说起:聊聊浮点数精度问题

几乎每个写代码的人都撞见过它: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 的成本转移到了类型系统里,同时引入了它自己的表示限制。工程世界没有免费的午餐,只有清醒的取舍。

下次再遇到 "不符合直觉" 的输出,不妨别急着绕过去。往下一层,通常站着一个值得搞懂的设计。

相关推荐
(●—●)橘子……1 小时前
力扣 第525场周赛 练习理解
数据结构·python·学习·算法·leetcode
海鸥-w1 小时前
区域名称同步存储实现笔记
java·笔记
镜子AI1 小时前
镜子AI工程实践:用 Python 自动生成 GEO 问答语料与意图覆盖矩阵(零依赖)
人工智能·python·矩阵
长沙三为智能科技1 小时前
多平台内容矩阵怎么配合GEO:4层分工与3条去重规则
python·aigc·geo优化
ycydynq1 小时前
django 添加信息时,对外键设置条件
数据库·python·django
Rain的Java大神之路1 小时前
LeetCode 21 合并两个有序链表:一道 Easy 题,凭什么刷掉一半候选人?
java·后端·算法
小白的码BUG之路1 小时前
K8S -- 集群搭建环境基础
java·服务器·kubernetes
清水白石0081 小时前
Python 安装与环境配置:Windows、macOS、Linux 完整指南(第2集)
windows·python·macos
w_t_y_y1 小时前
JWT(5)JWT+spring security
java·后端·spring