0.1 + 0.2 为什么不等于 0.3:从手算到二进制编码
0.1 + 0.2 听起来像是一道幼儿园算术题,但把它扔给 Python,事情突然变得诡异起来:
>>> 0.1 + 0.2
0.30000000000000004
好家伙,计算机天天号称每秒能算几百亿次,结果连 0.1 + 0.2 都能翻车?
其实计算机没有算错。真正的问题是:当你输入 0.1 的那一刻,它拿到的就已经不是精确的 0.1 了。
今天我们不拿“浮点数有误差”这句正确的废话糊弄人,也不钻进满屏公式里自我感动。咱们直接动手:先把 0.1 拆成二进制,再造一台只有 8 位精度的“袖珍计算机”,亲手把那个误差算出来。
最后,我们再化身内存侦探,用 Python 的 struct.pack 给数字拍一张“X 光片”,看看 0.3 和 0.30000000000000004 到底差在哪一个比特。
一、 手算 0.1:无限循环的开始
整数转二进制,大家可能听过“除 2 取余”。
小数正好反过来:不断乘 2,每次拿走整数部分。
1. 把 0.1 扔进乘 2 机器 (Decimal to Binary)
我们拿纸笔算:
| 计算 | 拿走的整数 | 剩下的小数 |
|---|---|---|
| 0.1 × 2 = 0.2 | 0 | 0.2 |
| 0.2 × 2 = 0.4 | 0 | 0.4 |
| 0.4 × 2 = 0.8 | 0 | 0.8 |
| 0.8 × 2 = 1.6 | 1 | 0.6 |
| 0.6 × 2 = 1.2 | 1 | 0.2 |
把中间那列从上往下抄下来:
0.00011...
注意最后一行:余数又变回了 0.2。
这意味着从第二行开始,整个过程会像卡住的唱片一样不断循环:
0.1 = 0.0001100110011001100110011...₂
└─0011─┘ 无限循环
第一处真相出现了:十进制的 0.1,在二进制世界里是个无限循环小数。
2. 那 0.2 呢?
0.2 刚好等于 0.1 × 2。
二进制乘 2 不需要重新列竖式,只要把小数点向右挪一格:
0.1 = 0.00011001100110011...₂
0.2 = 0.00110011001100110...₂
它们身后都拖着一条永远写不完的 0011 尾巴。
这不是二进制“不够高级”。十进制同样写不完 1 ÷ 3,我们只能写成:
0.3333333333333333...
不同进位制,只是各自害怕的分数不同。
💡 程序员的顿悟:
0.1在十进制里看起来干净利落,是因为我们的计数系统以 10 为底。计算机以 2 为底,只有那些能不断除以 2 的小数才特别听话,例如
0.5、0.25和0.125。0.1的分母里藏着一个 5,进了二进制世界就只能无限循环。
二、 袖珍计算机:亲手制造一次浮点误差
真实的 Python 浮点数有 53 位有效精度。要在纸上抄 53 个 0 和 1,不仅不酷,还很容易把自己数瞎。
所以我们先造一台只有 8 位二进制小数的袖珍计算机。
它的规矩很简单:
- 小数点后只有 8 个座位;
- 坐不下的数字必须丢掉;
- 丢掉之前,要看看下一位是否需要四舍五入。
1. 先把 0.1 塞进去
0.1 的真实二进制是:
0.00011001100110011...
机器只有 8 个座位:
保留:0.00011001
丢掉: 100110011...
被丢掉的第一位是 1,说明后半段超过了半格,需要进位:
0.00011001
+ 1
──────────────
0.00011010
所以袖珍计算机里的 0.1 实际上是:
0.00011010₂
把它翻译回十进制也能手算。小数点后的位权依次是:
1/2 1/4 1/8 1/16 1/32 1/64 1/128 1/256
0.00011010 里,第 4、5、7 位是 1:
1/16 + 1/32 + 1/128
= 16/256 + 8/256 + 2/256
= 26/256
= 0.1015625
我们明明输入的是 0.1,机器拿到的却是 0.1015625。
第一笔误差,已经悄悄混进来了。
2. 再把 0.2 塞进去
0.2 的二进制是:
0.0011001100110011...
保留 8 位:
保留:0.00110011
丢掉: 00110011...
被丢掉的第一位是 0,这次不用进位。
0.2 ≈ 0.00110011₂
翻译回十进制:
1/8 + 1/16 + 1/128 + 1/256
= 32/256 + 16/256 + 2/256 + 1/256
= 51/256
= 0.19921875
这次结果比真正的 0.2 小了一点。
3. 见证奇迹:开始二进制加法
现在两个数都已经住进内存。接下来没有任何魔法,就是从右往左做普通加法:
0.00011010
+ 0.00110011
────────────
0.01001101
把结果换回十进制:
1/4 + 1/32 + 1/64 + 1/256
= 64/256 + 8/256 + 4/256 + 1/256
= 77/256
= 0.30078125
Bingo!
我们亲手制造出了一个“不等于 0.3”的答案:
0.1 + 0.2 ≈ 0.30078125
它和 Python 的答案长得不一样,是因为这台实验机只有可怜的 8 位,误差被放大得非常明显。
但它已经把浮点误差的整个犯罪过程演示清楚了:
0.1先被舍入一次;0.2也先被舍入一次;- 加法器算的是这两个“替身”,不是数学世界里的原值;
- 计算结果还可能需要再次舍入。
三、 真实的 IEEE 754:换一把更精细的尺子
Python 使用的不是我们的玩具格式,而是 IEEE 754 双精度浮点数,也叫 binary64。
一共 64 位,被切成三块:
┌────────┬────────────┬──────────────────────────────────────┐
│ 符号 1 │ 指数 11 │ 小数部分 52 │
└────────┴────────────┴──────────────────────────────────────┘
1. 三个区域分别干什么?
- 符号位:0 表示正数,1 表示负数。
- 指数:负责移动二进制小数点,决定数字有多大或多小。
- 小数部分:保存有效数字,决定这把尺子有多精细。
小数部分虽然只有 52 位,但开头固定的 1 可以省略不存,所以实际能提供 53 位有效精度。
你可以把它想成科学计数法:
十进制:1.23 × 10⁵
二进制:1.1001 × 2⁻⁴
只是计算机把底数从 10 换成了 2。
2. 0.1 和 0.2 住进 binary64
规格化后的 0.1 是:
0.1 = 1.100110011001100110011... × 2⁻⁴
0.2 是:
0.2 = 1.100110011001100110011... × 2⁻³
有效数字部分完全相同,区别只是小数点移动的位置。
但那条 0011 仍然无限循环。binary64 即使比袖珍计算机多出几十个座位,也终究不是无限旅馆。到了第 53 位,照样要关门、舍入。
3. 浮点加法的第一步:对阶 (Alignment)
两个浮点数的指数不同,不能直接相加。先把 0.1 的有效数字右移一位,让它和 0.2 使用相同指数:
0.110011001100110011... × 2⁻³
+ 1.100110011001100110... × 2⁻³
────────────────────────────────
10.011001100110011001... × 2⁻³
再整理一下:
1.0011001100110011001... × 2⁻²
如果内存可以无限大,这串数字继续写下去,正好就是十进制的 0.3。
但 IEEE 754 必须在第 53 位停下。停下之后,结果落到了紧挨着 0.3 上方的那个浮点数:
下面一格:0.299999999999999988897769753748...
目标位置:0.3
上面一格:0.300000000000000044408920985006...
这次加法选择了上面一格。
Python 为这个浮点数寻找了一个最短、而且重新读回来还能得到同一组 64 位数据的十进制写法:
0.30000000000000004
💡 程序员的顿悟:
浮点数不是在数轴上随便找个位置站着,而是只能站在一个个固定的“刻度”上。
0.3恰好落在刻度之间。直接写0.3时,选择了下面的刻度;计算0.1 + 0.2时,结果落到了上面的刻度。所以它们看起来极其接近,底层却不是同一个数。
四、 内存侦探:给浮点数拍一张 X 光片
说了这么多,怎么证明 Python 真的保存了这些东西?
轮到 struct.pack 登场了。
它可以把 Python 数字按照指定格式打包成原始字节。这里使用 >d:
>:大端序,高位字节放前面,方便人类阅读;d:8 字节双精度浮点数。
import struct
values = [0.1, 0.2, 0.3, 0.1 + 0.2]
for value in values:
raw = struct.pack('>d', value)
print(f'{value!r:<20} {raw.hex()}')
运行结果:
0.1 3fb999999999999a
0.2 3fc999999999999a
0.3 3fd3333333333333
0.30000000000000004 3fd3333333333334
盯住最后两行:
0.3 -> ...3333
0.1 + 0.2 -> ...3334
前面 15 个十六进制数字全部相同,只有最后一位从 3 变成了 4。
这意味着它们是相邻的两个 binary64 浮点数,中间已经塞不下第三个浮点数了。
所以这句判断返回 False,不是 Python 故意找茬:
>>> 0.1 + 0.2 == 0.3
False
直接查看浮点数的十六进制形式
Python 还提供了 float.hex():
>>> (0.1).hex()
'0x1.999999999999ap-4'
>>> (0.2).hex()
'0x1.999999999999ap-3'
>>> (0.3).hex()
'0x1.3333333333333p-2'
>>> (0.1 + 0.2).hex()
'0x1.3333333333334p-2'
这里的 p-4 表示乘以 2 的 -4 次方。再次观察最后两行,仍然只差最后那个 3 和 4。
如果想看它完整的十进制展开:
>>> format(0.1 + 0.2, '.55f')
'0.3000000000000000444089209850062616169452667236328125'
Python 平时只打印 0.30000000000000004,是为了给你一个足够区分它、同时又尽量短的结果。否则每次输出浮点数都拖着几十位尾巴,终端早就没法看了。
五、 同一串 01,能演出多少种数字?
浮点数只是数字编码的一种。
内存里的比特本身没有“整数”“小数”或“负数”的标签。11111111 到底是什么,完全取决于程序拿哪本规则手册来解释。
1. 无符号整数 (Unsigned Integer)
这是最朴素的玩法,每一位按 1、2、4、8、16……累加:
00101010₂
= 32 + 8 + 2
= 42
>>> struct.pack('>I', 42).hex()
'0000002a'
8 位无符号整数可以表示 0 到 255。它没有负数,每个比特都拿来扩大正数范围。
2. 补码整数 (Two's Complement)
要保存 -42,现代计算机通常使用补码。
手算也只有两步:全部取反,然后加一。
42:00101010
取反:11010101
加一:11010110
所以 -42 的低 8 位是 d6。扩展成 32 位时,高位全部补 1:
>>> struct.pack('>i', -42).hex()
'ffffffd6'
补码最漂亮的地方是:CPU 不需要为负数再造一套加法器。同一个二进制加法电路,正数和负数都能算。
3. 定点数 (Fixed Point)
如果你在做支付系统,最直接的办法是:别存元,存分。
0.10 元 -> 整数 10
0.20 元 -> 整数 20
相加 30 -> 0.30 元
这就是定点数。底层仍然是整数,只是所有人提前约定“小数点藏在后两位”。
- 优点:金额加减非常直观,不会冒出
0.30000000000000004。 - 代价:精度被提前锁死。以分为单位时,
0.001元仍然存不下。
4. BCD:十进制数字各住各的房间
BCD(Binary-Coded Decimal)干脆不把整个数字转成二进制,而是每 4 位保存一个十进制数字:
十进制 59 -> 0101 1001 -> 0x59
这里有个很容易踩的坑:
- 普通二进制整数
0x59等于十进制 89; - 按照 BCD 解释,
0x59表示十进制 59。
同样的字节,不同的规则,直接变成两个数字。
struct 没有内置 BCD,但我们可以自己打包:
def pack_bcd(number):
digits = str(number)
if len(digits) % 2:
digits = '0' + digits
return bytes(
(int(digits[index]) << 4) | int(digits[index + 1])
for index in range(0, len(digits), 2)
)
print(pack_bcd(20260713).hex())
# 输出:20260713
BCD 会浪费一些比特,但它和人类看到的十进制数字转换简单,因此计算器、电子钟和一些金融设备很喜欢它。
5. Decimal:让十进制留在十进制世界
Python 的 Decimal 专门处理十进制小数:
from decimal import Decimal
>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3')
注意参数是字符串。
如果写成 Decimal(0.1),那么 0.1 会先被做成有误差的 binary64,再交给 Decimal。这就像先把照片压成满屏马赛克,再要求修图软件恢复原图——神仙也救不回来。
六、 字节序:数字没变,座位换了 (Endianness)
struct.pack 里的 > 是什么意思?这就要聊到大端序和小端序。
把 16 位整数 1000 写成十六进制,是 03e8。它占两个字节:03 和 e8。
>>> struct.pack('>h', 1000).hex()
'03e8'
>>> struct.pack('<h', 1000).hex()
'e803'
- 大端序
>:高位字节在前,写成03 e8; - 小端序
<:低位字节在前,写成e8 03。
数字还是同一个 1000,只是两个字节交换了座位。
这件事听起来无关紧要,却是二进制文件和网络协议里的经典事故现场。如果写入方用小端,读取方却按大端解释,1000 当场就会变成另一个数字。
struct 常用格式可以记住这些:
| 格式 | 含义 | 大小 |
|---|---|---|
b / B |
有符号 / 无符号字节 | 1 字节 |
h / H |
有符号 / 无符号短整数 | 2 字节 |
i / I |
有符号 / 无符号整数 | 4 字节 |
q / Q |
有符号 / 无符号长整数 | 8 字节 |
f |
单精度浮点数 | 4 字节 |
d |
双精度浮点数 | 8 字节 |
⚠️ 协议开发避雷指南:
写文件格式或网络协议时,一定显式指定
>、<或!。默认的
@会跟着本机的字节序、类型大小和内存对齐走。文件换台机器就可能变脸,不适合拿来做跨平台格式。
七、 工程实战:浮点数到底应该怎么比?
既然浮点数站在离散刻度上,是不是以后再也不能比较了?
当然不是。问题不在于“比较”,而在于你是否真的要求两个数的每一个比特都相同。
1. 科学计算:判断是否足够接近
Python 提供了 math.isclose():
import math
>>> math.isclose(0.1 + 0.2, 0.3)
True
还可以明确给出相对容差和绝对容差:
math.isclose(value, expected, rel_tol=1e-9, abs_tol=1e-12)
容差不是越小越专业,而应该来自你的业务:测量温度、计算星球轨道和判断账户余额,需要的精度根本不是一回事。
2. 金额计算:不要让 float 上场
涉及金额时,优先选择:
- 整数定点数:全部换算成分、厘等最小单位;
Decimal:需要明确的十进制舍入规则时使用。
不要先用 float 算出一串误差,再指望最后调用一次 round() 就能洗白全部中间过程。
3. 浮点数是不是很糟糕?
恰恰相反,浮点数是一个极其成功的工程设计。
它用固定的 64 位,同时表示非常小和非常大的数字,现代 CPU 还能直接用硬件高速计算。图形渲染、物理模拟、机器学习和科学计算都离不开它。
它不是“精确实数”,而是一套在范围、精度、速度和存储空间之间做出的优秀折中。
总结
现在再看 0.1 + 0.2,整个案发过程已经非常清楚:
0.1和0.2变成二进制后,会无限循环0011;- 内存座位有限,只能截断并舍入;
- 加法器算的是两个已经舍入过的“替身”;
- 结果再次舍入,落在紧挨着
0.3上方的浮点刻度; - Python 把这个刻度显示成
0.30000000000000004。
如果你想自己验证全文,下面这段代码就够了:
import math
import struct
from decimal import Decimal
result = 0.1 + 0.2
print(result)
print(format(result, '.55f'))
print(struct.pack('>d', 0.3).hex())
print(struct.pack('>d', result).hex())
print(math.isclose(result, 0.3))
print(Decimal('0.1') + Decimal('0.2'))
输出:
0.30000000000000004
0.3000000000000000444089209850062616169452667236328125
3fd3333333333333
3fd3333333333334
True
0.3
0.30000000000000004 不是计算机算错后留下的尴尬尾巴,而是有限位二进制编码留下的一枚指纹。
数字写进计算机之前,必须先选择一种表示方法;选择了表示方法,也就选择了它的范围、精度与代价。