引言
0.1 + 0.2 == 0.3 在大多数语言里是 false——这不是语言 bug,而是 IEEE 754 二进制浮点的必然结果。理解浮点数不是"背几个坑",而是掌握一套在有限位宽下逼近实数的工程约定:它怎么存、怎么算、误差从哪来、什么时候该换定点数、怎么比较才算对。本文从二进制布局讲起,把符号位、阶码、尾数的分工说清楚,再解释为什么 0.1 无法精确表示、NaN 为什么不等于自己、epsilon 比较为什么也常常是错的。
前置:二进制与编码工具:Base64、Hex、xxd 与字节调试、概率统计基础实战。计算机底层的位运算与数据表示见 计算机基础专题。
目录
- 1. 浮点数的二进制解剖:符号、阶码、尾数
- 2. 规格化、非规格化与特殊值:NaN 与 Inf
- 3. 0.1 + 0.2 之谜:精度损失从哪来
- 4. 比较浮点数:epsilon 与 ULP
- 5. 舍入模式与浮点环境
- 6. 金额与定点数:别用 float 算钱
- 7. Kahan 求和与数值稳定性
- 8. 格式化与解析:往返安全
- 9. 跨语言与硬件差异
- 10. 速查表与一句话记忆
- 延伸阅读
1. 浮点数的二进制解剖:符号、阶码、尾数
IEEE 754 把一个实数拆成三段存储。以最常见的 binary32(单精度,4 字节)为例:
31 30-23 22-0
+---+--------------+-----------------------+
| S | exponent | mantissa |
| 1 | 8 | 23 |
+---+--------------+-----------------------+
value = (-1)^S × 1.mantissa × 2^(exponent - 127)
binary64(双精度,8 字节)则是 1 位符号 + 11 位阶码 + 52 位尾数,偏置值 1023。
| 名称 | 字节 | 符号 | 阶码 | 尾数 | 偏置 |
|---|---|---|---|---|---|
| binary16 | 2 | 1 | 5 | 10 | 15 |
| binary32 | 4 | 1 | 8 | 23 | 127 |
| binary64 | 8 | 1 | 11 | 52 | 1023 |
尾数前的隐含 1:规格化数里尾数总写成 1.xxxxx,那个前导 1 不存储——省下一位,白赚约 1 bit 精度。这是"规格化"的核心红利。
偏置阶码的用意:阶码存的是无符号整数,实际指数 = 存储值 − 偏置。这样"指数大小比较"等价于"无符号整数比较",硬件比较电路可以复用整数比较器。
import struct
def bits(f, fmt='>f'):
u = struct.unpack('>I', struct.pack(fmt, f))[0]
return f'{u:032b}'
print(bits(1.0)) # 00111111100000000000000000000000
print(bits(-1.0)) # 10111111100000000000000000000000
print(bits(0.5)) # 00111111000000000000000000000000
从这三行就能读出规律:符号位一改正负翻转;1.0 的阶码是 01111111(127,指数 0);0.5 的阶码是 126(指数 −1)。
记忆:浮点 = 符号 + 阶码(偏置)+ 尾数(隐含前导 1)——它存的是"科学计数法的二进制版"。
2. 规格化、非规格化与特殊值:NaN 与 Inf
阶码的两个极端取值被保留给特殊情况:
| 阶码 | 尾数 | 含义 |
|---|---|---|
| 全 0 | 全 0 | 有符号零(+0 / −0) |
| 全 0 | 非 0 | 非规格化数(subnormal) |
| 全 1 | 全 0 | ±Infinity |
| 全 1 | 非 0 | NaN(尾数区分 quiet/signaling) |
| 其它 | 任意 | 规格化数 |
非规格化数解决"下溢到 0 的悬崖":最小规格化数约 2^-126,再往下如果直接变 0,a - b 在 a≈b 时会瞬间归零,误差跳变。非规格化数让精度平滑衰减,代价是部分硬件上运算变慢(历史上有的 CPU 会触发慢路径)。
NaN 的三个反直觉性质:
nan = float('nan')
print(nan == nan) # False!NaN 不等于自己
print(nan != nan) # True
print(nan in [nan]) # True(对象身份,不是 ==)
print(sorted([3, nan, 1])) # 排序结果不可依赖——NaN 比较全是 False
判 NaN 要用专门函数:math.isnan(x)、x != x(技巧),绝不要用 x == float('nan')。
Inf 的算术:1/0.0 是 inf(不是异常,整数除法 1/0 才抛异常);inf - inf 是 nan;0.0/0.0 是 nan。
print(1 / 0.0) # inf
print(-1 / 0.0) # -inf
print(float('inf') - float('inf')) # nan
print(0.0 / 0.0) # nan(ValueError 只在整数 0/0 时)
记忆:阶码全 0 是零/非规格化、阶码全 1 是 Inf/NaN——NaN 恒不等于自己,判它必须用 isnan。
3. 0.1 + 0.2 之谜:精度损失从哪来
根因:十进制小数 0.1 在二进制下是无限循环小数,就像 1/3 在十进制下是 0.333…。二进制只能存有限位,必须截断 → 存储值 ≈ 0.1 但不等于 0.1。
from decimal import Decimal
print(Decimal(0.1)) # 0.1000000000000000055511151231257827021181583404541015625
print(Decimal(0.2)) # 0.200000000000000011102230246251565404236316680908203125
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False
哪些小数能精确表示:只有"分母是 2 的幂"的分数——0.5、0.25、0.125、0.75 精确;0.1、0.2、0.3、0.7 全都不精确。
| 十进制 | 二进制 | 精确 |
|---|---|---|
| 0.5 | 0.1 | 是 |
| 0.25 | 0.01 | 是 |
| 0.1 | 0.0001100110011… | 否(循环) |
| 0.2 | 0.001100110011… | 否(循环) |
| 0.3 | 0.010011001100… | 否(循环) |
误差不是"bug"而是"舍入":每次运算都会把结果舍入回最近的可表示值,误差在运算链中累积、抵消或放大。0.1 + 0.2 的结果恰好比 0.3 大了约 5.55e-17。
print(abs((0.1 + 0.2) - 0.3)) # 5.551115123125783e-17
print(f"{0.1 + 0.2:.17f}") # 0.30000000000000004
记忆:十进制小数在二进制里多为无限循环,必须截断——误差是"舍入的必然",不是"语言的错"。
4. 比较浮点数:epsilon 与 ULP
永远别用 == 比浮点结果,除非你确定两个值都是精确表示(如赋的常量、整数运算结果)。正确做法有两种:
方案 A:绝对 + 相对 epsilon
import math
def close(a, b, rel=1e-9, abs_tol=1e-12):
return math.isclose(a, b, rel_tol=rel, abs_tol=abs_tol)
print(close(0.1 + 0.2, 0.3)) # True
print(close(1e16, 1e16 + 1)) # True(相对容差在大数上很宽松)
print(close(0.0, 1e-13)) # False(abs_tol 兜底小量级)
math.isclose 同时给相对与绝对容差,大数用相对、小量级用绝对——单一 epsilon 在量级跨十几个数量级时必然失效。
方案 B:ULP(Unit in the Last Place)比较——直接比"相差多少个可表示单位":
import struct
def ulp_diff(a, b):
to_int = lambda f: struct.unpack('>q', struct.pack('>d', f))[0]
return abs(to_int(a) - to_int(b)) # 负数需修正符号-幅度编码,简化示意
print(ulp_diff(1.0, 1.0 + 2**-52)) # 1(相邻可表示数)
ULP 比较是库作者级的精确做法:它不受量级影响,且能给出"误差 N 个 ULP"这种可度量的结论。
比较的三种正确姿势:
1. 整数/精确值 → 直接 ==(如 1.5 * 2 == 3.0)
2. 一般数值结果 → math.isclose(相对 + 绝对容差)
3. 数值库/精度敏感 → ULP 距离比较
记忆:浮点比较用 isclose(相对 + 绝对双容差),量级敏感场景用 ULP 距离——单一 epsilon 是幻觉。
5. 舍入模式与浮点环境
IEEE 754 定义了 5 种舍入模式,默认是"最近偶数舍入"(round-half-to-even):
| 模式 | 行为 | 场景 |
|---|---|---|
| roundTiesToEven | 就近,正中取偶 | 默认,最公平 |
| roundTiesToAway | 就近,正中远离 0 | 部分十进制场景 |
| roundTowardZero | 向 0 截断 | C 的 (int) 转换 |
| roundTowardPositive | 向 +∞ | 区间算术 |
| roundTowardNegative | 向 −∞ | 区间算术 |
“最近偶数"为什么是默认:如果正中一律进位(round-half-up),大量累加会系统性偏大;取偶则让偏大偏小各半,误差期望归零。Python 的 round 也是最近偶数——round(0.5)=0、round(1.5)=2、round(2.5)=2、round(3.5)=4。
注意:round 返回的是浮点就近值,不是"四舍五入”。做金额显示要用 Decimal.quantize:
from decimal import Decimal, ROUND_HALF_UP
print(Decimal('2.5').quantize(Decimal('1'), rounding=ROUND_HALF_UP)) # 3
FMA(融合乘加):a*b + c 若用 FMA 指令只舍入一次(而非乘、加各舍一次),精度更高。这是 -ffast-math 与 math.fma(Python 3.13+)背后的东西。
记忆:默认舍入是"最近偶数",不是四舍五入——金额显示必须显式指定 ROUND_HALF_UP。
6. 金额与定点数:别用 float 算钱
金额绝不能用二进制浮点,原因有二:0.01 不精确(同上),以及误差会在对账、分账、累计利息时被放大到分位。
三条出路:
| 方案 | 存储 | 优点 | 缺点 |
|---|---|---|---|
| 整数分 | int64,单位"分" | 精确、快、可 DB 存 | 需约定单位 |
| Decimal | 十进制定点 | 精确、可指定精度 | 比 float 慢 |
| 字符串 | 文本 | 无损、可审计 | 运算要解析 |
整数分是最常见的工程解:所有金额以"分"为单位存 int64,只在展示层除 100。
from decimal import Decimal
price = Decimal('19.99')
qty = 3
total = price * qty
print(total) # 57.97(精确)
print(total.quantize(Decimal('0.01'))) # 57.97
# 整数分方案
price_cents = 1999
total_cents = price_cents * 3 # 5997 分 = 59.97 元
print(total_cents / 100) # 59.97(仅展示层转换)
Decimal 的上下文陷阱:Decimal 的除法精度由 context 决定,默认 28 位;1/3 会得到 28 位近似,仍需 quantize 收口。
from decimal import getcontext
getcontext().prec = 6
print(Decimal(1) / Decimal(3)) # 0.333333
税率、汇率、分摊这些"除法密集"场景,规则要显式写进业务:先乘后除、最后一步收口、舍入方向写死,否则不同实现差一分钱。
记忆:钱用整数分或 Decimal,绝不用 float——除法场景先乘后除、末尾统一舍入,规则必须写死。
7. Kahan 求和与数值稳定性
累加大量浮点会误差累积:每次 sum += x 都可能舍入,小量被大量"吃掉"(大数 + 小数 = 大数)。
# 经典反例:0.1 累加 10 次
s = 0.0
for _ in range(10):
s += 0.1
print(s) # 0.9999999999999999
print(s == 1.0) # False
Kahan 求和用一个"补偿项"记录每次舍入的残差,把它加回下一次运算:
def kahan_sum(nums):
total = 0.0
c = 0.0 # 补偿:上次损失的误差
for x in nums:
y = x - c
t = total + y
c = (t - total) - y # 恢复出被舍掉的部分
total = t
return total
Neumaier 改进版处理"下一次比累计大"的情况(Kahan 原版在 |x| > |total| 时失效):
def neumaier_sum(nums):
total = 0.0
c = 0.0
for x in nums:
t = total + x
if abs(total) >= abs(x):
c += (total - t) + x
else:
c += (x - t) + total
total = t
return total + c
Python 的 math.fsum 就是精确求和(内部用扩展精度),算"精确到最后一舍":
import math
print(sum([0.1] * 10)) # 0.9999999999999999
print(math.fsum([0.1] * 10)) # 1.0
其他稳定性技巧:求方差用两遍算法或 Welford 在线算法;避免"大数相减"(灾难性抵消,如 1e16 + 1 - 1e16);求和先按量级排序。
print(1e16 + 1 - 1e16) # 0.0(灾难性抵消,1 被吃掉)
记忆:累加会吃掉小量——用 math.fsum / Kahan / Neumaier;避免大数相减造成的灾难性抵消。
8. 格式化与解析:往返安全
往返安全(round-trip)指 parse(format(x)) == x。这要求格式化输出足够多的有效数字,否则解析回来就丢了精度。
Python 3 的 repr 是往返安全的(最短可往返表示):
x = 0.1 + 0.2
print(repr(x)) # 0.30000000000000004(最短且能还原)
print(str(x)) # 0.30000000000000004(同 repr)
print(f"{x:.2f}") # 0.30(展示用,不往返)
位宽与有效数字:binary64 有约 15–17 位十进制有效数字——%.17g 保证往返,%.15g 通常够但可能丢。
| 格式 | 有效数字 | 用途 |
|---|---|---|
%.15g | 15 | 显示,可能丢 |
%.17g | 17 | 保证往返 |
| repr(Python) | 最短可往返 | 序列化 |
| JSON 数字 | 实现相关 | 跨语言要小心 |
JSON 的浮点坑:很多语言的 JSON 序列化默认保留有限位,大整数或高精度小数会丢。跨语言交换金额/ID 时,用字符串或整数,别赌对方的浮点实现。
import json
print(json.dumps(0.1)) # 0.1(Python 用最短往返)
print(json.loads("0.30000000000000004")) # 保留
记忆:序列化浮点要"最短可往返"——显示用 %.2f、传输用 repr/17g,跨语言金额用字符串或整数。
9. 跨语言与硬件差异
浮点并非处处一致,工程上有几处跨平台裂缝:
| 差异点 | 说明 |
|---|---|
| x87 扩展精度 | 老 32 位 x86 用 80 位寄存器,中间结果更精确但结果不同 |
| FMA 使用 | 编译器是否用 FMA 影响末位 |
| 快速数学 | -ffast-math 破坏 NaN/Inf 与结合律 |
| 舍入模式 | 某些 DSP/GPU 默认非就近偶数 |
| denormal 处理 | 部分平台 flush-to-zero,非规格化数变 0 |
结论:浮点结果不保证跨编译选项、跨架构逐位一致。要可复现就必须:禁用 fast-math、固定 FMA 策略、避免依赖末位、必要时用整数/定点。
GPU 与 SIMD 的额外陷阱:float32 在 GPU 上通常无 FMA 一致性保证,且 rsqrt/sin 等超越函数是近似实现,误差可达若干 ULP。
可复现三原则:① 禁用 fast-math / 近似超越函数;② 固定运算顺序(不依赖编译器重排);③ 敏感计算用整数或 Decimal,别赌浮点末位。
一个真实教训:同一份物理仿真代码在 x86 与 ARM 上跑出不同轨迹——不是 bug,是浮点末位差异经混沌系统放大。需要确定性时,浮点本身就是风险源。
记忆:浮点不保证逐位可复现——fast-math、FMA、x87 扩展精度、denormal 处理都是裂缝,要确定性就用整数/定点。
10. 速查表与一句话记忆
全篇速查:
| 主题 | 结论 |
|---|---|
| 布局 | 符号 + 偏置阶码 + 尾数(隐含前导 1) |
| 特殊值 | 阶码全 0 → 零/非规格化;全 1 → Inf/NaN |
| NaN | 不等于自己,必须用 isnan 判 |
| 精度 | 只有分母为 2 的幂的小数精确 |
| 比较 | isclose(相对 + 绝对)或 ULP,禁用 == |
| 舍入 | 默认最近偶数,金额显式 ROUND_HALF_UP |
| 金额 | 整数分或 Decimal,绝不用 float |
| 求和 | math.fsum / Kahan / Neumaier |
| 抵消 | 避免大数相减 |
| 可复现 | 禁 fast-math、固定顺序、必要时用定点 |
一句话记忆:浮点是"二进制科学计数法"——符号、偏置阶码、隐含前导 1 的尾数,只能精确表示分母为 2 的幂的分数,所以 0.1 天生不精确;NaN 不等于自己、Inf 是合法值;比较用 isclose 或 ULP 而非 ==,舍入默认最近偶数;钱用整数分或 Decimal,累加用 math.fsum/Kahan,避开大数相减的灾难性抵消;序列化要往返安全,跨平台别赌浮点逐位一致——需要确定性时,浮点本身就是风险源。
延伸阅读
- 二进制与编码工具:Base64、Hex、xxd 与字节调试
- 概率统计基础实战:贝叶斯、随机变量、分布与推断
- JSON 与 YAML 处理深入:解析器原理、Schema 校验与工程陷阱
- 计算机基础专题 — 数据表示与位运算底层
- 算法与面试专题 — 数值算法与复杂度
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。