每个程序员迟早都会遇到这个问题。在 JavaScript 控制台里输入 0.1 + 0.2,得到的结果是 0.30000000000000004。
我第一次见到这个现象时,第一反应是:JavaScript 是垃圾。后来用了 Python、Ruby、C,发现全一样。于是我开始怀疑计算机是垃圾。
但真正的问题是:计算机是用二进制表示数字的,而人类用十进制。这两种进制之间,存在一些无法精确转换的小数。
计算机为什么没法精确表示 0.1
你可以在十进制下精确写出 1/10 = 0.1。但在二进制下,1/10 是一个无限循环小数。
可以类比一下:1/3 在十进制下是 0.33333...,永远写不完。你只能截断到某一位。0.1 在二进制下也是一样的道理——它是无限循环的 0.00011001100110011...
但计算机的内存是有限的。IEEE 754 标准规定单精度浮点数用 32 位存储——1 位符号、8 位指数、23 位尾数。23 位尾数只能存放有限位的二进制数,超出的部分必须截断或舍入。
所以 0.1 在内存里的实际值并不是精确的 0.1,而是一个"最接近 0.1 的二进制浮点数"。这个近似值和真实值之间的误差,在单次运算中微不足道,但累积起来就会产生可见的偏差。
具体看一下 0.1 在 IEEE 754 单精度下的实际值。它被存为:符号位 0,指数 123(偏移 127 后实际是 -4),尾数 1.10011001100110011001101。这样表示的值大约是:
1.10011001100110011001101 × 2^(-4)
= 0.100000001490116119384765625
比精确的 0.1 稍微大了一点。而 0.2 的二进制表示是 0.00110011001100110011001100110011... 存成浮点数后也有误差。两个近似值相加,误差就累积到了 0.30000000000000004。
IEEE 754 的位布局
IEEE 754 标准定义了几种浮点数格式。最常用的是 32 位单精度(float)和 64 位双精度(double)。
单精度浮点数的 32 位是这样分配的:
第 31 位(1 bit):符号位。0 表示正数,1 表示负数。这很简单,就是正负号。
第 30-23 位(8 bits):指数部分。存储的是"指数加偏移量"。单精度的偏移量是 127。所以实际指数是存储值减去 127。
为什么用偏移量而不是直接用补码表示指数?因为这样可以让浮点数的二进制表示直接比较大小——正数的指数部分越大,整个浮点数的值越大。如果指数用补码,比较就要分情况讨论了。
第 22-0 位(23 bits):尾数部分。存储的是小数部分,但有一个"隐式的 1"——因为任何非零的浮点数都可以被规范化为 1.xxx 的形式,所以尾数只存 xxx 部分,前面的 1 被省略了。这相当于白送了 1 位精度。
所以一个浮点数的实际值是:
value = (-1)^S × (1 + M/2^23) × 2^(E-127)
双精度类似,只是指数用 11 位(偏移 1023),尾数用 52 位。精度大约是 16 位十进制有效数字,足够覆盖大部分科学计算场景。
特殊值,无穷大和 NaN
IEEE 754 标准最有意思的部分不是它怎么表示普通数字,而是它怎么处理"不是数字"的情况。
标准定义了三个特殊区间:
指数全 0(E=0)- 非规格化数。当指数为 0 时,隐式的 1 不再存在,尾数变成 0.xxx(而不是 1.xxx)。这些数被称为"非规格化数"(subnormal numbers),用来表示绝对值接近 0 的数。它们可以逐渐下溢到 0,而不是突然跳到 0。
非规格化数的存在允许你表示比最小规格化数更小的数。最小规格化数的值大约是 1.17 × 10^(-38),而非规格化数能延伸到 1.4 × 10^(-45)。代价是精度降低——非规格化数尾数前面的 0 越多,有效位数越少。
指数全 1(E=255)- 无穷大和 NaN。指数全 1 表示特殊情况:尾数全 0 时表示无穷大(分正负),尾数非 0 时表示 NaN(Not a Number)。
NaN 又分两种:quiet NaN(qNaN,安静 NaN,不触发异常)和 signaling NaN(sNaN,信号 NaN,触发浮点异常)。大部分编程语言只暴露 qNaN,sNaN 通常被硬件或底层运行时处理。
IEEE 754 对 NaN 有一个特殊规定:任何涉及 NaN 的运算结果都是 NaN。所以 NaN + 1、NaN * 0、Math.sqrt(NaN) 的结果都是 NaN。但 NaN === NaN 在大多数语言里返回 false——这曾经是我调试半天没找到 bug 的原因。
精度丢失的实际案例
理论之后再看看之前踩过的坑。
案例 1:金额计算。 一个支付系统用了 float 存储订单金额。客户购买了 100 件单价 9.99 的商品,总金额应该是 999.00。但在系统里算出来是 998.999999...,订单被标记为"金额不匹配"。
解决方案是:金额永远用整数"分"为单位存储,而不是用浮点数。9.99 元存为 999 分。乘法得到 99900 分,展示时再除以 100。
这在金融系统里是基本准则。Java 的 BigDecimal、Python 的 decimal.Decimal,都是为了解决这个问题而存在的。但它们内部用的是十进制表示(将数字编码为整数数组或字符串),速度比二进制浮点数慢得多。
案例 2:游戏中的累计伤害。 一个 RPG 游戏的伤害每秒触发一次,每次伤害值是一个浮点数。运行了半小时后,累计伤害比理论值差了 0.5%。
原因是每次伤害的浮点数运算误差在累积。半小时内触发了 1800 次伤害计算,每次误差虽然只有 0.0003% 左右,但累积到 1800 次后就变成了 0.5%。玩家在界面上看到的数字跟服务器记录的最终值不一致。
解决方法是改用整型存储累计值(放大 1000 倍),只在展示时做浮点数运算。
案例 3:浮点数比较。 这段代码我见过很多团队写过:
if (a + b === 0.3) {
// 永远不会执行
}
正确的写法是用容差比较:
if (Math.abs(a + b - 0.3) < Number.EPSILON) {
// 现在能执行了
}
Number.EPSILON 是 JavaScript 里 1 和大于 1 的最小浮点数之间的差值,大约是 2.22 × 10^(-16)。它不是万能的,对于大数运算,容差需要根据数值大小动态调整。
Kahan 求和,补偿误差累积
如果你必须在浮点数上做大量累加运算(比如计算大量数值的平均值),Kahan 求和算法可以减少误差。
原理很简单:每次加法后,记录下被丢弃的低位部分,下次加法时把这部分加回来。
function kahanSum(values) {
let sum = 0.0;
let c = 0.0; // 补偿值
for (const v of values) {
const y = v - c; // 减去上次的补偿
const t = sum + y;
c = t - sum - y; // 计算丢失的低位
sum = t;
}
return sum;
}
对于普通求和,Kahan 可以把误差降低几个数量级。但它不能消除误差,只能减小。如果你真的需要精确的数值,还是得用十进制浮点或者整数运算。
进制转换工具
如果你在工作中遇到了浮点数精度问题,可以看看进制转换工具,它支持在各种进制之间转换,包括浮点数的二进制表示。有时候把十进制小数转成二进制看看它的真实表示,能帮你更快定位精度问题的来源。
写在最后
IEEE 754 是计算机科学里的一个"原罪"——它不是故意设计成有误差的,而是因为二进制系统在物理上先天地无法精确表示所有十进制小数。理解了这一点之后,你再遇到 0.1 + 0.2 ≠ 0.3 就不会觉得计算机坏了,而是知道:这是有限精度表示法的固有代价。
处理它的方法也很固定:金融用整数,比较用容差,大量累加用 Kahan。