Skip to content
Published on

浮点数:为什么 0.1 + 0.2 不等于 0.3

分享
Authors

引言 — 令所有人困惑的一行

只要写过一点程序的人,早晚都会碰到这样的场景。打开控制台,只是做了一次最简单的加法,结果却很奇怪。

>>> 0.1 + 0.2
0.30000000000000004

第一次看到这个结果,很多人会怀疑电脑是不是坏了。连小学生都知道 0.1 加 0.2 等于 0.3,为什么这台昂贵的机器却给出 0.30000000000000004 这种怪异的答案?而且不只是 Python。JavaScript、Java、C、Go、Ruby 等几乎所有主流语言,给出的都是同一个答案。

先说结论:这不是 bug。既不是语言的缺陷,也不是 CPU 的错误。这是计算机存放实数的方式 — IEEE 754 浮点数 — 所带来的必然结果。而它的根源可以归结为一个出人意料地简单的事实:计算机以二进制存放数字,而 0.1 在二进制下是无法精确写出的。本文将从最底层挖出这个原因。

计算机以二进制为生

正如我们使用十进制,计算机使用二进制。在十进制中,小数 0.75 意味着"十分之七加百分之五";在二进制中,小数则表示为"二分之一、四分之一、八分之一……"之和。

  十进制  0.75 = 7/10 + 5/100

  二进制  0.11  = 1/2 + 1/4 = 0.5 + 0.25 = 0.75  (精确)

有些数在二进制下恰好能除尽。0.5 是二分之一,所以是二进制的 0.1;0.25 是四分之一,是 0.01;0.75 是 0.11,都很精确。分母是 2 的幂的分数,在二进制下都能用有限位数表示。

问题出在不属于这一类的数上。0.1 是十分之一,而 10 不是 2 的幂。试着把这个数写成二进制,会得到一个永远不会结束的无限小数。

  十进制 0.1 换算成二进制:
  0.0001100110011001100110011001100110011...  (0011 永远循环)

这和在十进制下试图写出三分之一时 0.333333... 永远写不完,是完全相同的现象。正如三分之一在十进制下无法有限地写出,十分之一在二进制下也无法有限地写出。只是进制不同,原理却是一样的。

计算机无法存放无限多的位数,所以必须在某处截断。存入 0.1 的那一刻,计算机存放的并不是真正的 0.1,而是"最接近 0.1、能用有限位数表示的二进制数"。这个微小的误差,正是整个故事的起点。

IEEE 754 — 装载实数的标准容器

那么,这些有限的位数该如何排布呢?这里就要提到 IEEE 754 这一标准。如今几乎所有硬件都以这种方式存放实数。其核心思想是科学记数法(scientific notation)。

正如我们在处理非常大或非常小的数时会写成 6.022 × 10^23 这样的形式,浮点数也把一个数拆成三部分:符号(sign)、尾数(mantissa,也叫 significand)、指数(exponent)。

  值 = (-1)^符号 x 尾数 x 2^指数

  符号     : 是正数还是负数 (1 比特)
  指数     : 小数点放在哪里 (移动位数)
  尾数     : 有效数字 (承载精度)

最广泛使用的 64 位双精度(double),把这 64 比特按如下方式划分。

  64 位 double:
  [ 符号 1 比特 ][ 指数 11 比特 ][ 尾数 52 比特 ]

  32 位 float:
  [ 符号 1 比特 ][ 指数 8 比特 ][ 尾数 23 比特 ]

"浮动(floating)"这个名字的含义,在这里就显现出来了。小数点的位置并不固定,而是随指数的变化而浮动移动。指数变大就能表示很大的数,指数变小就能表示很小的数,位数却始终不变。正因为这种灵活性,浮点数才能覆盖从原子尺度到星系尺度的广阔范围。

但这里有一个核心限制 — 尾数的位数是有限的。double 的尾数只有 52 比特,因此大约只能容纳十进制 15~17 位有效数字。超出这个范围的精度会被舍弃。所以像 0.1 这样无限延续的二进制小数,会在 52 比特处被截断,存放的正是那个被截断后的值。

那么,为什么 0.1 + 0.2 不是 0.3

现在可以解开最初的谜题了。当你把 0.1 存入计算机时,实际存放的是一个比真正的 0.1 略大一点点的值。0.2 也一样,存放的是一个略有不同的值。

  想要存放的值      实际存放的值 (近似)
  0.1        ->    0.1000000000000000055511151231257827021181583404541015625
  0.2        ->    0.2000000000000000111022302462515654042363166809082031250

把这两个近似值相加,误差也会一并叠加。它们的和,与真正的 0.3 的近似值之间也存在微小的偏差。

  0.1(近似) + 0.2(近似) = 0.3000000000000000444089...
  0.3 本身的近似值        = 0.2999999999999999888977...

  两个值不同!所以 0.1 + 0.2 == 0.3 为假

也就是说,三个不同的舍入误差(0.1 的误差、0.2 的误差,以及再次存放它们之和时产生的误差)叠加在一起,才产生了我们看到的 0.30000000000000004。计算机的运算完美精确,只是一开始存入的原料,本来就不是真正的 0.1 和 0.2 而已。

一个令人稍感安慰的事实是,这种误差并非随机,而是确定性的(deterministic)。相同的运算永远产生相同的误差。因此它是可复现、可预测、可管理的。真正的问题不在于误差的存在本身,而在于不了解它就直接写代码。

不要精确比较实数 — 误差范围(epsilon)

处理浮点数时最常见的错误,是用 == 直接比较两个实数。正如上面所见,0.1 + 0.2 与 0.3 并不精确相等,所以这样的代码会给出与预期不同的结果。

if 0.1 + 0.2 == 0.3:
    print("相等")
else:
    print("不相等")   # 实际上会输出这一行

正确的做法不是问"是否精确相等",而是问"是否足够接近"。如果两个值的差小于一个很小的容许误差(误差范围,epsilon),就认为它们相等。

def close_enough(a, b, epsilon=1e-9):
    return abs(a - b) < epsilon

print(close_enough(0.1 + 0.2, 0.3))   # True

不过这里也藏着一个陷阱。固定的 epsilon(比如 1e-9),未必适合所有数量级的值。比较两个非常大的数时,这么小的差异反而可能比自然产生的误差还小,导致比较失败;比较两个非常小的数时,这个容许范围又可能过于宽松。所以在实务中,通常会同时考虑绝对误差和相对误差。

  绝对误差比较:  |a - b| < eps
    适合小数值,不适合大数值

  相对误差比较:  |a - b| <= eps * max(|a|, |b|)
    容许范围随数值大小成比例调整

  实务中的库通常把两者结合起来
  (例如 Python 的 math.isclose 同时考虑相对与绝对误差)

Python 的 math.isclose、NumPy 的 numpy.allclose 这类标准函数,已经实现了这种结合方式。如果自己挑选 epsilon 有困难,使用这类经过检验的函数会更安全。

金钱绝不要用 float 处理

浮点数误差表现得最危险的地方,是金融计算。金钱必须精确。哪怕一分钱的误差,在会计上也不可接受,一旦这种误差在数百万笔交易中累积,就会变成实实在在的损失。可一旦用 float 处理金钱,正是这种误差会悄悄渗入。

# 不好的例子: 用 float 计算金额
price = 0.1
total = 0.0
for _ in range(10):
    total += price
print(total)          # 0.9999999999999999  — 不是 1.0!

把 0.1 加十次本应得到 1.0,但由于前面提到的原因,结果会有微小偏差。用这样的值生成账单或比较余额,会是一场灾难。解决方向有两个。

1. 用整数处理(使用最小单位)。 把金额不以元为单位,而以货币的最小单位(比如人民币的分,或不同货币对应的最小单位)的整数来存放。如果是 1,234.56 美元,就存成 123456 美分。整数运算完全没有误差,因此绝对精确。只在显示到屏幕时才插入小数点。

# 好的例子: 以整数(分)处理
price_cents = 10          # 把 0.10 美元存成 10 美分
total_cents = 0
for _ in range(10):
    total_cents += price_cents
print(total_cents / 100)  # 1.0  — 精确!

2. 使用十进制类型(decimal)。 大多数语言都提供能原样处理十进制的十进制(decimal)类型。Python 的 decimal.Decimal、Java 的 BigDecimal 就是代表。它们在内部按十进制位存放数字,因此能把 0.1 当作真正的 0.1 来处理。

from decimal import Decimal

a = Decimal("0.1")
b = Decimal("0.2")
print(a + b)              # 0.3  — 精确!
print(a + b == Decimal("0.3"))   # True

这里有一个重要的细节。创建 Decimal 时必须以字符串形式传入(Decimal("0.1"))。如果像 Decimal(0.1) 那样传入 float,那个已经混入误差的 float 值会原样进入,十进制的优势也就随之消失。原料一旦被污染,再精密的容器也无济于事。

总结一下:性能重要、单位明确的大批量计算适合整数方式;可读性和任意精度重要的会计逻辑适合 decimal 方式。无论哪一种,都必须避开单纯的 float。

NaN、无穷大,以及负零

IEEE 754 除了普通数字之外,还定义了几个特殊值。不了解它们,就会遇到意料之外的 bug。

无穷大(Infinity)。 超出可表示的最大数(溢出),或者用非零数除以 0,就会得到无穷大。正无穷大与负无穷大是分开的两个值。

print(1e308 * 10)   # inf   (溢出)
print(-1e308 * 10)  # -inf

NaN (Not a Number)。 未定义运算的结果。用 0 除以 0、用无穷大减去无穷大、或对负数开平方,都会得到 NaN。NaN 最臭名昭著的性质,是它连和自己都不相等。

nan = float("nan")
print(nan == nan)   # False!  — NaN 和任何东西都不相等,甚至包括它自己
print(nan != nan)   # True

# 所以判断 NaN 不用 ==,而要用专用函数
import math
print(math.isnan(nan))   # True

这个性质乍看很奇怪,但这是标准本身的规定。因此,判断"这个值是不是 NaN"的一个惯用写法就是 x != x — 和自己不相等就说明是 NaN。不过明确使用 isnan 函数会更易读。

负零 (-0.0)。 浮点数里正零和负零是分开的两个值。用 == 比较时两者相等,但在一些微妙的情况下行为会不同。比如用 0 做除数时,符号会决定结果是正无穷大还是负无穷大。

print(0.0 == -0.0)      # True   (比较结果相等)
print(1.0 / 0.0)        # 要得到 inf 需要额外处理 (Python 会抛出异常)
# 在 C、Java 等语言中:  1.0/0.0 -> +inf,  1.0/-0.0 -> -inf

负零通常不需要特别在意,但在符号具有意义的数值计算中(比如极限、复数、某些物理模拟),它会带来微妙的差异。

累积误差 — 微小误差堆积之时

到目前为止看到的误差,单个都非常小(大约在小数点后第 16 位左右)。但把运算重复上百万次,这些微小的误差就会累积(accumulate),长成肉眼可见的规模。当加数的数量级相差极大时,这个问题尤其严重。

# 不断把一个小数加到一个大数上,小数会被「吞掉」
big = 1e16
small = 1.0
print(big + small)        # 1e16  — small 消失了!
print(big + small == big) # True

这里发生了什么?double 的尾数只有大约 16 位有效数字。1e16 已经处在这一精度的边缘,此时再加上 1.0,这个 1 就被挤到了可表示位数之外,被舍入而消失。这被称为吸收误差(absorption) — 大数吞掉了小数。

在对大量数字求和时,这个现象会成为实际问题。如果单纯地从左到右依次相加,随着累计和越来越大,后面加入的小数值就越来越容易被忽略。缓解这一问题的著名技巧是卡汉求和(Kahan summation)。它的做法是在每一步都单独记住被舍弃的误差,并在下一步中补偿回来。

def kahan_sum(numbers):
    total = 0.0
    compensation = 0.0   # 记住被舍弃的低位比特
    for x in numbers:
        y = x - compensation
        t = total + y
        compensation = (t - total) - y   # 这一步丢失的误差
        total = t
    return total

通过误差补偿,卡汉求和能给出比朴素求和精确得多的结果。在数据分析、科学计算、图形学这类需要处理海量实数运算的领域,这样的数值稳定性技巧十分重要。核心的教训是:误差单个来看很小,但会在重复中不断长大;而且运算的顺序和方法本身,也会影响精度。

实务规则整理

把安全处理浮点数的实践规则压缩一下,大致如下。

  • 不要用 == 比较实数。 改用基于误差范围的近似比较(如 math.isclose)。
  • 不要用 float 处理金钱。 使用整数(最小单位)或 decimal 类型。
  • 创建 decimal 时要用字符串。Decimal("0.1"),而不是 Decimal(0.1)
  • 检测 NaN 要用专用函数。 x == nan 永远为假,所以要用 isnan
  • 留意数量级相差极大的求和。 必要时使用卡汉求和这类稳定技巧。
  • 区分显示用的四舍五入与计算用的精度。 屏幕上可以只显示小数点后两位,但内部计算要保留更高的精度。
  • 如果精度极端重要,就使用任意精度库。 不过这要以牺牲速度为代价。

这些规则背后共同的精神,是始终意识到"浮点数是一种近似"这一事实。带着这份认知去使用,它是强大的工具;一旦误以为它是精确值,它就会悄悄地给出错误的结果。

结语

0.1 + 0.2 不等于 0.3,并不是计算机出了错,而是用有限的比特去表示无限的实数这一根本性妥协所带来的结果。计算机以二进制存放数字,而像十分之一这样的许多十进制小数,在二进制下会无限延续,因此必然要在某处被舍入。IEEE 754 把这一妥协精巧地装进符号、指数、尾数三部分,用可表示范围换取了实用的精度。

理解了这一点之后,浮点数就不再是变幻莫测的魔法,而是对懂得规则的人而言可预测的工具。需要精确比较,就用误差范围;需要精确的金额,就用整数或 decimal;需要稳定的大批量求和,就用数值稳定技巧。核心只有一句话:浮点数是模仿实数的近似,并非实数本身。记住这一句,就能避开大多数浮点数陷阱。

参考资料