☰
0.1+0.2为何不等于0.3?浮点数精度问题从原理到实战
2026/10/13 10:58:15 网站建设 项目流程

如果你在任意一门主流编程语言里敲过0.1 + 0.2,大概率见过一个让人印象深刻的输出:0.30000000000000004。我头一回遇到这个结果时,愣了几秒,第一反应是编译器坏了,第二反应是自己的电脑中了病毒。后来经历多了才明白,这是浮点数本身的宿命,几乎每门语言都一样。这篇文章会把这件事从原理到实战彻底讲透:为什么计算机非要给出这样一个不精确的结果,0.1 在内存里到底长什么样,日常写代码又该如何绕开这些精度陷阱。不管你是前端、后端还是搞算法的,都能在这篇里找到自己用得上的那部分。

1. 先看现场:0.1 + 0.2 到底输出了什么?

1.1 一组让人不淡定的测试结果

先从大家最熟悉的语言开始。下面是几个典型环境里直接求和的显示结果:

语言表达式显示结果
Python0.1 + 0.20.30000000000000004
JavaScript0.1 + 0.20.30000000000000004
Java(double)0.1 + 0.20.30000000000000004
C++(std::cout)0.1 + 0.20.30000000000000004
Ruby0.1 + 0.20.30000000000000004

有些语言默认只输出短格式,列出这一堆是为了说明一个事实:这不是某个语言的设计缺陷,而是一整套通用浮点运算规范在背后统一“制造”出来的结果。哪怕你切换语言、切换操作系统、切换CPU,只要都遵循 IEEE 754 浮点数标准,0.1 + 0.2的内部存储值就是同一个二进制位模式,打印出来自然也一样。

有人会问:C++ 下面的std::cout << 0.1 + 0.2;有时候不是打印0.3吗?那是因为默认的打印精度被设成了 6 位有效数字。不是结果真的等于 0.3,而是显示的时候做了截断。你把精度调到std::setprecision(17),真相马上露出来。

1.2 这不是“电脑算错了”,而是二进制世界的必然

先说结论:电脑算不了十进制小数,至少不是我们直觉里的那种算法。计算机内部的所有数字最终都会转成二进制,整数倒还好说,小数的转换就会带来一个根本性问题:十进制小数里有相当一部分,在二进制里写不出来。

举个更生活化的例子。十进制里1/3 = 0.3333...,这是一个无限循环小数,你用有限位纸笔永远写不完。二进制里,0.1就相当于这个角色。它的二进制表示是0.0001100110011001100110011...,无限循环,永远写不完。可计算机的存储空间是有限的,一个double只有 64 位,它不可能把这串无限循环全部装下。于是它只能截断到 53 位有效二进制位,舍掉后面的尾巴。这一截,精度就丢了。

所以,你写的0.1,从进入内存的那一刻起,就已经不是数学上的1/10,而是一个很接近但存在偏差的近似数。拿着两个近似数去做加法,结果自然很难正好等于数学意义上的0.3。

2. 0.1 的二进制真面目:一切误差的起点

2.1 二进制小数的每一位是什么意思

在十进制里,小数点后的第一位表示十分之一,第二位表示百分之一;二进制里,小数点后的第一位表示二分之一,第二位表示四分之一,第三位表示八分之一,依此类推。

对照一下会更有感觉:

  • 十进制0.5=1/2,二进制写作0.1
  • 十进制0.25=1/4,二进制写作0.01
  • 十进制0.125=1/8,二进制写作0.001
  • 十进制0.75=1/2 + 1/4,二进制写作0.11

二进制小数的每一位,都必须是以2^-n为分母的分数组合。这种体系能组合出0.5、0.25、0.375这样的数,却无法组合出0.1。因为1/10无法写成若干个2^-n的和。这个结论不是精度问题,是数学上就不可能写尽,除非允许无限多位。

2.2 手算转换:无限循环出现了

用一个最直观的“乘2取整”法来转一下0.1:

0.1 × 2 = 0.2 → 整数位 0 0.2 × 2 = 0.4 → 整数位 0 0.4 × 2 = 0.8 → 整数位 0 0.8 × 2 = 1.6 → 整数位 1,剩下 0.6 0.6 × 2 = 1.2 → 整数位 1,剩下 0.2 0.2 × 2 = 0.4 → 整数位 0 0.4 × 2 = 0.8 → 整数位 0 0.8 × 2 = 1.6 → 整数位 1,剩下 0.6 0.6 × 2 = 1.2 → 整数位 1,剩下 0.2

看到规律了吗?从第 4 步开始,余数重新回到0.2,后面的序列完全重复,循环节是0011。所以0.1的二进制形式就是:

0.000110011001100110011001100110011...

同样,0.2的二进制是:

0.001100110011001100110011001100110...

它们都是无限循环小数。计算机能存无限小数吗?不能。所以这注定是一个“近似值”的开始。真正的问题接着就来了:这个近似值到底怎么存储、怎么保留、怎么舍入,才是后面0.30000000000000004的完整故事。

3. IEEE 754 浮点数三板斧:符号、指数、尾数

3.1 一个 double 的内存结构

现代绝大多数语言里,0.1默认是double,也就是双精度浮点数,占用 64 位。这 64 位被分成三块:

  • 符号位:1 位,0 表示正,1 表示负
  • 指数位:11 位,负责表示数值的量级
  • 尾数位:52 位,负责表示有效数字

单精度float则是 1 位符号、8 位指数、23 位尾数。

类型总位数符号位指数位尾数位十进制有效数字约
float3218236-9
double641115215-17

尾数这一块有个隐藏规则:除了一些特殊值,任何非零二进制浮点数都能写成1.xxxxx × 2^n的形式,那个开头的1是永远存在的,所以标准规定不存它,只存小数部分的 52 位。加上隐式的那个1,实际有效精度相当于有 53 位二进制尾数。

3.2 把 0.1 塞进 64 位里,会发生什么

`0.1` 的无限循环形式是0.0001100110011001100110011...。二进制科学计数法写成:

1.1001100110011001100110011001100110011001100110011010... × 2^-4

指数部分用偏移量编码,尾数部分只保留小数点后的 52 位。超出 52 位的部分会触发舍入:是向上进一位,还是直接丢掉,取决于当前舍入模式。默认一般是“舍入到最近偶数”。

经过舍入后的0.1,在双精度里的实际存储值不再是数学上的1/10,而是一个精确十进制值:

0.1000000000000000055511151231257827021181583404541015625

0.2同理,它存进去的值是:

0.200000000000000011102230246251565404236316680908203125

注意看,这两个数都比表面上的0.1、0.2要略微大一点。这就是误差的起点。

3.3 为什么打印 0.1 时,看到的还是 0.1

有人会问:既然实际存的是0.1000000000000000055511...,为什么我打印0.1看到的却是0.1?因为现代语言在把浮点数转成字符串时,采用的是一种“最短往返表示”策略:在一个浮点数的所有可选十进制表示里,选择那个能唯一定位到当前二进制值的“最短字符串”。

0.1这个字符串虽然和真实值有大约5.5e-17的偏差,但它已经足够把当前这个浮点数和其他浮点数区分开,所以语言就选择打印0.1。这其实是一种温柔显示,让你以为一切都很精确。一旦参与运算之后,结果对应的最短表示可能就变成了0.30000000000000004,于是你看到了那个怪异的数字。

4. 0.1 + 0.2 为什么偏偏得出 0.30000000000000004

4.1 两个近似值相加,还要再舍入一次

现在,我们用计算机里真实存储的两个数相加:

0.1000000000000000055511151231257827021181583404541015625 + 0.200000000000000011102230246251565404236316680908203125 = 0.3000000000000000166533453693773483063544840211877136875

这个和,数学上还不是最终答案。因为计算器在做加法时,结果是直接放在高精度寄存器里的,但最终要落回 64 位浮点数存储,于是又需要一次舍入,把它变成最接近的一个可表示数。

舍入结果对应了一个非常具体的二进制模式,这个模式换算成十进制是:

0.3000000000000000444089209850062616169452667236328125

而0.3本身存进 double 后的真实值其实是:

0.299999999999999988897769753748434595763683319091796875

你看看,左右两边根本不是同一个数。既然不是一个数,就不可能相等。而为什么显示出来是0.30000000000000004?因为上面那个舍入结果的“最短往返表示”就是0.30000000000000004。

4.2 直接判断0.1 + 0.2 === 0.3的小孩都知道,永远 false

所有语言都一样。你用 Python 写:

print(0.1 + 0.2 == 0.3) # False

用 JavaScript 写:

console.log(0.1 + 0.2 === 0.3); // false

不要觉得这是玄学,也不要试图“修好”这个二进制机制。二进制浮点数就是这种精度。你真正需要做的是:日常编码里不要用绝对相等去比较浮点数结果。

4.3 哪些小数能精确表示,哪些不能

只要你把一个小数换成分数,看分母是否是 2 的幂,就能快速判断它能不能被精确表示。

  • 精确无压力:0.5、0.25、0.125、0.375、0.875,它们的分母都可以化为 2 的幂。
  • 天生没救:0.1、0.2、0.3、0.7、0.9,分母是 10,含因子 5,在二进制循环。

整数则是另一回事。绝对值小于2^53的整数,在 double 里都能精确表示;一旦超过2^53,相邻整数之间就出现空隙,你以为在加 1,其实数值没变。后面讲事故时会再次碰到这一点。

5. 实战避坑:金额、比较、格式化应该怎么写稳

光懂原理不够,更重要是知道代码里该怎么办。我把日常碰到的三种常见场景展开讲。

5.1 场景一:金融金额计算,别让 float/double 参与

涉及人民币、美元、汇率、利息时,没有人希望账单上出现0.30000000000000004。金额计算最常用、最稳定的方案是“最小货币单位整数法”:金额以“分”为单位的整数存储,所有加减运算都用整数完成,只在展示时再除以 100。

# 50.10 元,以分为单位 price_cents = 5010 count = 3 total_cents = price_cents * count print(f"{total_cents // 100}.{total_cents % 100:02d}")

不过这里有个细节要注意:如果你用价格 * 0.7去算折扣,然后int()截断,就踩坑了。因为0.7的浮点近似会导致结果比实际小一丁点,截断后少一分钱。正确做法是:将百分比也转换成整数(比如 70 表示 70%),再用整数乘后再整除 100,或者使用专门处理十进制的类型。

语言里现成的十进制类型,我建议这么用:

  • Python:decimal.Decimal("0.1"),注意必须传字符串,不能传Decimal(0.1)。
  • Java:new BigDecimal("0.1"),构造函数传字符串,不能传 double。
  • C#:直接用decimal类型。
  • 前端 JavaScript:没有内置 decimal 类型,可以用专门的第三方库,或坚持整数分。

为什么恒强调“字符串”?因为Decimal(0.1)会把你已经变成二进制近似值的0.1原样接收,然后基于那个不准确的数再构建十进制,等于把坑原封不动搬进了十进制。传字符串"0.1"才是从人类可读的十进制出发。

5.2 场景二:科学计算或普通逻辑,比较时带容差

如果你不是做金融,只是算个坐标、角度、物理参数,用 double 没问题,但比较时必须写容差。我常用的写法是这样:

def is_close(a, b, tolerance=1e-9): return abs(a - b) <= tolerance

很多标准库已经内置:

  • Python:math.isclose(a, b, rel_tol=1e-9, abs_tol=0.0)
  • JavaScript:Math.abs(a - b) < Number.EPSILON * Math.max(Math.abs(a), Math.abs(b))

注意不要看完这行Number.EPSILON就到处用。Number.EPSILON是“1 和下一个可表示数之间的距离”,它只描述 1 附近的精度,不是全局的容差标准。你计算几百万量级的数值时,该留的容差要按实际业务量级定,别机械抄一个1e-10。

5.3 场景三:累加、循环、统计,误差会越滚越大

如果在一个循环里反复加0.1:

s = 0.0 for _ in range(1000000): s += 0.1 print(s) # 不是 100000.0,而是 99999.999991...

这种误差累积在统计数据、传感器数据求和里非常常见。你能做的选择有三种:

  • 能转整数就转整数,比如计数、次数、小单位金额。
  • 使用 Kahan 求和算法,它用一个补偿变量把每次舍去的误差再加回来。
  • 提高中间精度,例如 Python 的math.fsum,或者把累加放到decimal里。

实操中我比较推荐“先判断业务数据能不能整数化”,不行再考虑算法补偿。误差补偿只能减小,不能消除,但通常够用。

5.4 格式化输出时也要多长一个心眼

很多系统的金额展示写的是(金额 * 折扣).toFixed(2),结果偶尔出现经典错误。最典型的就是1.005:

console.log(1.005.toFixed(2)); // "1.00"

原因是1.005在 double 里的实际值是1.00499999999999989...,四舍五入到两位小数时就被舍下去了。所以别在浮点数格式化上赌运气。金额用整数或 decimal 做完四舍五入,再交给 UI 层打印;如果非要浮点数,至少要自己先做一轮业务级的“半角到最近”处理,再格式化。

6. 真实项目里那些防不胜防的浮点数事故

最后讲几个真实项目里常见的“事故类型”,看完你就知道,浮点数坑远不止0.1 + 0.2。

6.1 大数吃小数

单精度 float 只有 23 位尾数,有效数字大约 7 位。当一个 float 的值达到16777216(也就是2^24)时,尾数已经无法区分1的增量了,你会看到:

float f = 16777216.0f; printf("%f\n", f + 1.0f); // 16777216.000000

这在双精度里要到2^53附近才会出现,但如果你处理地理坐标、大型累计值或者换算后的巨大数值,一样会遇到。你往一个非常大的 float/double 上加一个很小的数,小到一定程度后,整个加法等于白做。这个现象叫“大数吃小数”,本质还是尾数位有限。

6.2 单位换算后的一分钱误差

3.14 * 100按理说应该是314,但实际算出来是313.99999999999994。如果你直接int(x)或floor(x),就变成313,对不上账。

这个问题在金额计算中特别阴险,因为业务人员不会认为3.14*100会出错。我给出的建议是:任何从浮点到整数的转换,都要先走“四舍五入”而不是“直接截断”。比如 Python 中用round(3.14 * 100),Java 中用Math.round,然后再转 long。

6.3 不要依赖不同计算结果完全一致

在同一个公式里,由于编译器优化开启了对浮点乘加的融合(FMA),结果可能与未开启时存在最后一个二进制位的差异。比如a*b+c在某些 CPU 上直接用一条融合指令,中间过程不经过舍入,最终结果和“先算 a*b 再算 +c”可能差 1 个 ulp。对绝大多数业务无所谓,但如果你在写一致性校验、哈希对比、跨平台回放,就要格外小心。

最好的做法是:不要把浮点位模式当作稳定唯一键,也不要拿浮点数做 Map/Set 的 key。宁愿把它转成固定精度的字符串再使用。

6.4 NaN 和无穷:比精度问题更隐蔽

浮点数世界里还有两个特殊值:NaN(Not a Number)和正负无穷。它们的行为很容易让人大跌眼镜:

x = 0.0 / 0.0 print(x == x) # False,因为 NaN 不等于任何值,包括它自己

如果你的循环里混入了一次除零得到 NaN,后续所有NaN和普通数的比较都会变成 false,却不会报错,数据像被磁场污染一样悄悄扩散。所以,做数据校验时,第一步就应该用isNaN()、isInfinite()把异常值挡在外面,再做你的业务判断。很多看起来像精度问题的线上事故,最后排查下来是 NaN 在捣乱。

我在好几个项目里都吃过浮点数的亏。金额计算最初用 double 做,结果对不上账;统计求和用 float 垒,结果十万条数据下来误差越来越大;还见过1.005.toFixed(2)在前端展示环节把金额硬生生少显示一分钱。后来我给自己定了一个习惯:先问数据能不能用整数表示,不能就用十进制类型,实在要用浮点数就提前约定好容差和四舍五入策略。理解 0.1 + 0.2 不等于 0.3 背后的机制,不是为了背一个冷知识点,而是为了在下次遇到精度问题的时候,能一眼看出根源,而不是对着屏幕怀疑人生。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询