跳到主要内容

15. 浮点数的坑:问题与局限

对应官方文档:15. Floating-Point Arithmetic: Issues and Limitations 一句话导读:这一章解释「明明是简单加法,结果却多了个尾巴」——搞懂浮点数是近似值而不是精确小数,学会正确比较、正确显示、以及什么时候该换工具。

先从一个几乎每个新手都会撞上的瞬间开始:

print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
print(0.1 + 0.1 + 0.1 == 0.3)
print(repr(0.1))
print(format(0.1, ".17f"))
0.30000000000000004
False
False
0.1
0.10000000000000001

第一反应通常是「Python 有 bug」。官方一句话就堵回去了:这不是 Python 的 bug,也不是你代码的 bug,这是二进制浮点数的本性,C、Java、JavaScript 全都一样,只是有些语言默认不把这点差异显示出来。

你可以这样理解:计算机存小数用的是二进制分数,不是十进制小数。十进制的 0.625 等于 6/10 + 2/100 + 5/1000;二进制的 0.101 等于 1/2 + 0/4 + 1/8,两者正好都是 5/8,只是记法不同。问题在于,大多数十进制小数压根没法写成有限位的二进制分数——就像 1/3 在十进制里是 0.3333... 永远写不完,1/10 在二进制里是 0.0001100110011... 也永远写不完。

from decimal import Decimal, getcontext
from fractions import Fraction

getcontext().prec = 20
print("0.625 在二进制里能精确表示:", Fraction(625, 1000), "=", Fraction(5, 8))
print("1/3 在十进制里写不完:", Decimal(1) / Decimal(3))
print("0.1 的真实十进制值:", format(0.1, ".55f"))
0.625 在二进制里能精确表示: 5/8 = 5/8
1/3 在十进制里写不完: 0.33333333333333333333
0.1 的真实十进制值: 0.1000000000000000055511151231257827021181583404541015625

repr(0.1) 只显示 0.1 是故意的美化:从 Python 3.1 起,解释器会挑「最短的、转一圈还能还原成同一个浮点数的」那个十进制串来显示。它满足 eval(repr(x)) == x,但不代表存的就是精确值。你可以这样理解:0.1、0.10000000000000001 和上面那一长串,指向的是同一个二进制数,Python 挑了 shortest 的那个给你看。

村长划重点:print(0.1) 显示 0.1 是显示层的假象,别拿它当「存得精确」的证据。想看真身就用 format(x, ".17f")、Decimal.from_float(x) 或 x.hex()。同理,format(math.pi, ".2f") 只是把显示四舍五入,机器里的值一点没变。

15.1 表示误差(Representation Error)​

表示误差指的就是:有些(其实是绝大多数)十进制分数无法用二进制分数精确表示。这是本章唯一带编号的小节,官方在这里把 0.1 的来龙去脉算了一遍,村长拆成几步讲。

53 位有效位是怎么把 0.1 截掉的​

2000 年之后几乎所有机器都用 IEEE 754 浮点标准,Python 的 float 映射到其中的 binary64 双精度:1 位符号、11 位指数、52 位显式尾数,加上隐含的最高位 1,一共 53 位有效精度。

import sys

print("尾数二进制位数:", sys.float_info.mant_dig)
print("可保证的十进制有效位:", sys.float_info.dig)
print("机器精度(1 附近的间隔):", sys.float_info.epsilon)

输出的 mant_dig=53 对应约 15 位十进制有效数字(dig=15),机器精度约 2.22e-16。

计算机要把 0.1 存成 J / 2**N 的形式,其中 J 恰好是 53 位的整数(也就是 2**52 <= J < 2**53)。把式子倒过来写成 J ≈ 2**N / 10,试出唯一合适的 N 是 56:

print("56 是不是唯一的 N:", 2**52 <= 2**56 // 10 < 2**53)

q, r = divmod(2**56, 10)
print("整除的商与余数:", q, r)
print("余数大于 5,向上进位得:", q + 1)
print("所以存的是:", (q + 1), "/ 2**56")
print("约分后:", 3602879701896397, "/ 2**55")
print("展开成十进制:", 3602879701896397 * 10**55 // 2**55)
56 是不是唯一的 N: True
整除的商与余数: 7205759403792793 6
余数大于 5,向上进位得: 7205759403792794
所以存的是: 7205759403792794 / 2**56
约分后: 3602879701896397 / 2**55
展开成十进制: 1000000000000000055511151231257827021181583404541015625

因为余数是 6、超过一半,只能向上进位,所以机器里那个数比真正的 1/10 稍微大了一丁点。它永远不可能是精确的 1/10。既然 0.1 和 0.3 各自都偏了一点,加起来自然不一定落在 0.3 上,误差还会随着运算一次次累积。

不过也别被吓到:每次运算的误差大约不超过 1 / 2**53,日常计算完全够用。只要把最终结果按需要的位数格式化显示,绝大多数场景你看到的仍是期望值。

用 hex() 和 as_integer_ratio() 看真实值​

Python 给了两把「照妖镜」,都能无损地还原出浮点数的精确值:

x = 3.14159

print("整数比:", x.as_integer_ratio())
print("十六进制:", x.hex())

n, d = x.as_integer_ratio()
print("用分数还原一致吗:", x == n / d)
print("用 hex 还原一致吗:", x == float.fromhex(x.hex()))

print("0.1 的整数比:", (0.1).as_integer_ratio())
print("0.1 的十六进制:", (0.1).hex())
print("0.1 放大 2**55 倍:", 0.1 * 2**55)
整数比: (3537115888337719, 1125899906842624)
十六进制: 0x1.921f9f01b866ep+1
用分数还原一致吗: True
用 hex 还原一致吗: True
0.1 的整数比: (3602879701896397, 36028797018963968)
0.1 的十六进制: 0x1.999999999999ap-4
0.1 放大 2**55 倍: 3602879701896397.0

hex() 的价值在于跨版本、跨语言搬数据:它描述的是精确位模式,存进文件或发给 Java / C99 程序再读回来,值分毫不差。as_integer_ratio() 则适合拿 fractions 做精确分析。

正确比较:math.isclose 与 round​

既然浮点是近似值,用 == 判等就是在赌运气。官方推荐 math.isclose(),它按相对容差(rel_tol)和绝对容差(abs_tol)判断「够不够近」;round() 只适合比「粗略的近似」。

import math

a, b = 0.1 + 0.2, 0.3
print("直接 == :", a == b)
print("isclose 默认:", math.isclose(a, b))
print("收紧到 1e-12:", math.isclose(a, b, rel_tol=1e-12, abs_tol=0.0))
print("round 到 1 位再比:", round(a, 1) == round(b, 1))
print("粗略近似:", round(math.pi, ndigits=2) == round(22 / 7, ndigits=2))
print("--- 预先 round 救不了 0.1 ---")
print(round(0.1, 1) + round(0.1, 1) + round(0.1, 1) == round(0.3, 1))
print("--- 求和:让误差别累积 ---")
print("sum:", sum([0.1] * 10))
print("math.fsum:", math.fsum([0.1] * 10))
print("最终显示:", format(math.pi, ".12g"), "/", format(math.pi, ".2f"))
直接 == : False
isclose 默认: True
收紧到 1e-12: True
round 到 1 位再比: True
粗略近似: True
--- 预先 round 救不了 0.1 ---
False
--- 求和:让误差别累积 ---
sum: 1.0
math.fsum: 1.0
最终显示: 3.14159265359 / 3.14

两处要留意:

  • round(0.1, 1) 之后那个数仍然是同一个近似值,所以「先 round 再算」并不能让它更接近真实的 1/10,round 管的是显示和粗略比较。
  • sum() 在 Python 3.12 起对浮点用了补偿求和,上面才得到 1.0;老版本里可能是 0.9999999999999999。追求稳就用 math.fsum(),它会把「丢掉的低位」都记着,最后只舍入一次。

村长划重点:永远别用 == 直接比较两个算出来的浮点数,改用 math.isclose();判断是否为零这种靠近 0 的场景记得给 abs_tol(默认值是 0.0,纯靠 rel_tol 在 0 附近会失效)。round() 是显示工具,不是修复工具。

要精确就换工具:decimal 与 fractions​

如果这个场景不能容忍任何误差,就别再用 float 了:

模块存的是什么适合场景
decimal.Decimal十进制小数,位数可配金额、财务、计费、需要「四舍五入规则可控」的场合
fractions.Fraction分子 / 分母的有理数1/3 这类分数、需要精确推导、配方比例
from decimal import Decimal

print("Decimal 算钱:", Decimal("19.99") * 3)
print("字符串构造能精确:", Decimal("0.1") + Decimal("0.2") == Decimal("0.3"))
print("从 float 构造会把误差带进来:", Decimal(0.1))
print("两者相等吗:", Decimal(0.1) == Decimal("0.1"))
Decimal 算钱: 59.97
字符串构造能精确: True
从 float 构造会把误差带进来: 0.1000000000000000055511151231257827021181583404541015625
两者相等吗: False
from fractions import Fraction

print("1/3 + 1/6 =", Fraction(1, 3) + Fraction(1, 6))
print("精确比较:", Fraction(1, 10) + Fraction(2, 10) == Fraction(3, 10))
print("0.1 的真实分数:", Fraction.from_float(0.1))
print("转回 float 只是近似:", float(Fraction(1, 3)))
1/3 + 1/6 = 1/2
精确比较: True
0.1 的真实分数: 3602879701896397/36028797018963968
转回 float 只是近似: 0.3333333333333333

村长划重点:用 Decimal 一定要用字符串构造(Decimal("0.1")),写成 Decimal(0.1) 等于把浮点误差原封不动搬过来,白忙一场。金额还有个更土但更稳的办法:全程用「整数分」存储和计算,最后一步再除以 100 显示。另外 Decimal 与 float 混着运算会抛 TypeError,别混用。

给新手的实用建议​

把上面收成三条能直接照做的规则:算钱用 Decimal 或整数分,科学计算只在最后一步用 format() 显示,浮点比较一律交给 math.isclose()。

  • 别怕浮点:官方原话是「don't be unduly wary of floating point」,误差小到 1 / 2**53 量级,绝大多数任务够用。
  • 管住两处:一是比较用 math.isclose,二是输出用 str() / format() 按需要位数格式化。
  • 只在真需要精确时换工具:财务上 Decimal,分数推导上 Fraction;大规模数值计算交给 NumPy / SciPy 这类专业库。

本章小结​

  • 浮点数以二进制分数存储,多数十进制小数(如 0.1)在二进制里是无限循环,只能被近似保存;这不是 Python 的 bug。
  • repr / print 显示的是「最短可往返」的近似值;用 format(x, ".17f")、x.hex()、x.as_integer_ratio()、Decimal.from_float(x) 才能看到真身。
  • IEEE 754 binary64 有 53 位有效位(约 15 位十进制有效数字);0.1 实际存的是 3602879701896397 / 2**55,比 1/10 略大。
  • 比较一律用 math.isclose()(靠近 0 时设 abs_tol);round() 只管显示,math.fsum() 能抑制求和时的误差累积。
  • 要精确十进制用 Decimal("0.1")(必须字符串构造),要精确有理数用 Fraction;日常任务放心用 float,只需在比较和输出两处留心。