每个人都会遇到的浮点精度问题
打开任何编程语言,输入 0.1 + 0.2。你会得到 0.30000000000000004。不是 0.3。这不是 JavaScript、Python、Java、C++ 或 Rust 的 bug——这是计算机用二进制表示十进制小数的根本性后果。你看到的浮点精度误差不是错误;它们是对近似值进行二进制运算的数学正确结果。
原因:十进制的 0.1 在二进制中是循环小数(0.0001100110011...),就像 1/3 在十进制中是循环的(0.333...)。64 位浮点数只能存储 53 个有效位,所以 0.1 被舍入到最近的可表示值:0.1000000000000000055511151231257827021181583404541015625。当你把两个这样的近似值相加时,舍入误差不会抵消——它们会累积。
这影响所有使用 IEEE 754 浮点数的语言(也就是所有使用硬件加速数学运算的语言)。Python、JavaScript、C、Java、Go、Rust——都产生相同的结果,因为它们都使用相同的 64 位双精度格式。唯一默认避免这个问题的语言是那些使用任意精度算术的(比如 Haskell 的 Rational 类型或 Python 的 Decimal 模块),它们用速度换取精确性。
IEEE 754 如何表示数字
IEEE 754 双精度(64 位)浮点数把数字存储为:1 位符号位、11 位指数位和 52 位尾数位(加上 1 位隐含的前导位 = 53 位精度)。值为:(-1)^符号 × 2^(指数-1023) × 1.尾数。这给你大约 15-17 位有效十进制数字的精度,范围从 ±5×10^-324 到 ±1.8×10^308。
53 位尾数意味着你可以精确表示到 2^53(9,007,199,254,740,992)的整数。超过这个值,连续可表示的数字之间的间隔大于 1。在 JavaScript 中:9007199254740992 + 1 === 9007199254740992 求值为 true。这个数字字面上无法表示。这就是为什么 JavaScript 有 BigInt,为什么超过 2^53 的数据库 ID 在 JSON 中必须作为字符串传输。
任意两个可表示浮点数之间,有无穷多个无法表示的实数。可表示数字之间的间隔是变化的——在零附近,连续浮点数相差约 5×10^-324。在 1.0 附近,相差约 1.1×10^-16。在 1,000,000 附近,相差约 1.2×10^-10。这意味着相对精度大致恒定(约 15 位),但绝对精度随数字增大而降低。
特殊值:+0 和 -0(是的,负零存在,而且在 JavaScript 中 -0 === +0)、Infinity 和 -Infinity(溢出或除以零的结果)、以及 NaN(Not a Number——0/0、sqrt(-1) 或无效运算的结果)。NaN 有一个奇怪的属性:NaN !== NaN——它是 IEEE 754 中唯一不等于自身的值。用 Number.isNaN() 或 isNaN() 来检查它。
// The classic floating point precision errors
0.1 + 0.2 // 0.30000000000000004
0.1 + 0.2 === 0.3 // false
1.0 - 0.9 // 0.09999999999999998
0.1 * 0.2 // 0.020000000000000004
// Large integer precision loss
9007199254740992 + 1 // 9007199254740992 (unchanged!)
9007199254740993 === 9007199254740992 // true (!)
// Special values
1 / 0 // Infinity
-1 / 0 // -Infinity
0 / 0 // NaN
NaN === NaN // false (!)
Number.isNaN(NaN) // true
// The epsilon approach for comparison
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON // true
// But EPSILON is too small for accumulated errors:
Math.abs(0.1 + 0.2 + 0.3 - 0.6) < Number.EPSILON // false!浮点数和金钱(别用浮点数)
永远不要用浮点数做金融计算。¥0.10 + ¥0.20 应该等于 ¥0.30,而不是 ¥0.30000000000000004。在数千笔交易中,这些微小误差会累积成真实的差异。一家每天处理 1000 万笔交易的银行,如果每笔交易有 ±0.000000000000001 的舍入误差,每天可能漂移 ¥0.01——这会触发审计失败和监管问题。
标准解决方案:用最小单位的整数存储金额(分)。¥19.99 变成 1999 分。所有运算都是精确的整数数学。只在显示时转换为小数。这就是支付宝、微信支付和所有正经支付系统的做法。在 JavaScript 中:const total = priceInFen * quantity; const display = (total / 100).toFixed(2)。
对于需要亚分精度的货币(加密货币、外汇),使用定点数库。JavaScript 没有内置的 decimal 类型,但 decimal.js、big.js 或 dinero.js 等库可以处理任意精度的十进制运算。Python 有 decimal 模块(from decimal import Decimal; Decimal("0.1") + Decimal("0.2") == Decimal("0.3") 为 True)。Java 有 BigDecimal。任何需要精确十进制结果的计算都应该使用这些。
一个真实案例:2012 年,Knight Capital 在 45 分钟内亏损了 4.4 亿美元,原因是一个软件 bug。虽然严格来说不是浮点误差,但它说明了金融系统中微小的计算错误如何灾难性地级联放大。教训:金融代码需要精确运算、充分测试和故障保护。我们的 percentage-calculator 在计算税率和折扣时内部使用整数运算来避免这些问题。
浮点数的比较
永远不要用 ===(或 ==)比较浮点运算结果。应该检查差值是否小于一个容差:Math.abs(a - b) < epsilon。但 epsilon 应该是多少?Number.EPSILON(2.2×10^-16)是 1.0 和下一个可表示浮点数之间的最小差值——它适合比较 1.0 附近的数字,但对更大的数字太小,对接近零的数字太大。
更好的方法:相对 epsilon。将差值与数字的量级进行比较:Math.abs(a - b) <= epsilon * Math.max(Math.abs(a), Math.abs(b))。这让容差随值的大小缩放。对大多数应用,1e-9 到 1e-12 的相对 epsilon 效果不错。对累积计算(求和数千个值),你可能需要 1e-6。
ULP(最后一位单位)方法:如果两个浮点数相差不超过 N 个 ULP,就认为它们"相等"。一个 ULP 是一个浮点数和它最近邻居之间的距离。这是数学上最严格的比较方法,但实现起来更难。实际上,相对 epsilon 比较覆盖了 99% 的使用场景。
对于排序和去重,浮点比较特别棘手。如果你要从测量值列表中去除"重复"值,你需要一个一致的等价关系(如果 a≈b 且 b≈c,那么 a≈c)。简单的 epsilon 比较不保证这种传递性。考虑在比较前舍入到固定的有效数字位数,或使用整数表示。
科学计算:精度最重要的时候
灾难性抵消:两个几乎相等的数字相减会丢失大部分有效数字。如果 a = 1.0000001 且 b = 1.0000000(都以 15 位精度存储),a - b = 0.0000001 只有 1 位精度——其他 14 位都是噪声。这发生在二次方程计算、数值导数以及任何计算大数之间小差值的算法中。
二次方程的修复:不要用 x = (-b ± sqrt(b²-4ac)) / 2a,而是使用数值稳定的形式。用标准公式计算一个根(选择避免抵消的符号),然后用 x1 * x2 = c/a 得到另一个根。这避免了当 b² >> 4ac 时发生的灾难性抵消。我们的 scientific-calculator 内部使用这种稳定化形式。
求和顺序很重要。把 1e-16 加到 1.0 得到 1.0(小值低于精度阈值)。但把 1e-16 加一万亿次应该得到 0.0001。朴素求和完全丢失了这些小贡献。Kahan 求和(也叫补偿求和)追踪舍入误差并进行修正,给出精确到浮点数完整精度的结果。当你对许多不同量级的值求和时,使用它。
区间算术:不是计算单个浮点数,而是追踪包含真实结果的范围 [下界, 上界]。每次运算后,区间扩大以考虑舍入。如果你的最终区间是 [2.99999, 3.00001],你知道真实答案是 3.0 ± 0.00001。这给你一个严格的误差界限,不像单浮点计算那样只能猜测累积了多少误差。MPFI(C)和 pyinterval(Python)等库实现了这个方法。
各语言的特有坑
JavaScript:所有数字都是 64 位双精度浮点数(直到 BigInt 出现前没有整数类型)。这意味着整数运算只在 2^53 以内是精确的。JSON.parse() 解析大整数时会默默丢失精度:JSON.parse("9007199254740993") 返回 9007199254740992。返回大 ID 的 API(Twitter、Discord)必须把它们作为字符串发送。超过 2^53 的精确整数运算使用 BigInt。
Python:float 是 64 位双精度。但 Python 还有 Decimal(任意精度十进制)和 Fraction(精确有理数运算)。金融代码用:from decimal import Decimal, ROUND_HALF_UP。科学代码:numpy 默认使用 64 位双精度,但在某些平台支持 float128。Python 的 // 运算符做地板除法,对负数的行为与截断不同。
C/C++:float 是 32 位(7 位精度),double 是 64 位(15 位),long double 是 80 位或 128 位取决于平台。x87 FPU 内部使用 80 位扩展精度,这意味着同样的代码可能因为中间值是留在寄存器中(80 位)还是存到内存(64 位)而给出不同结果。使用 -ffp-contract=off 和 -fno-fast-math 获得可重现的结果。
SQL:FLOAT 和 DOUBLE 是 IEEE 754 类型,有同样的精度问题。DECIMAL(p,s) 和 NUMERIC(p,s) 是精确的定点类型——用这些存储金额。PostgreSQL 的 NUMERIC 可以存储小数点前最多 131,072 位和小数点后 16,383 位。MySQL 的 DECIMAL 支持最多 65 位总数。金融列永远用 DECIMAL,绝不用 FLOAT 或 DOUBLE。
避免浮点 bug 的实用规则
规则一:金钱用整数。存分,不存元。存聪,不存比特币。只在显示时转换为小数。这消除了业务应用中 90% 的浮点 bug。
规则二:永远不要用 === 比较浮点数。使用适合你领域的容差比较。对 UI 坐标,0.01 像素的容差就够了。对科学计算,使用相对 epsilon。对金融计算,你根本不应该用浮点数(见规则一)。
规则三:对减法保持警惕。如果你在减两个可能接近的数字,就有灾难性抵消的风险。重新表述算法以避免减法,或者对那个特定计算使用更高精度。二次方程、数值导数和方差计算是经典例子。
规则四:用对抗性输入测试。接近零的值、非常大的值、恰好是 2 的幂的值(可精确表示)vs 不是的值(0.1、0.3、0.7)。许多小值的求和。几乎相等的值的差。如果你的代码处理金钱,用 ¥0.01、¥0.10、¥99.99 和 ¥999,999,999.99 测试。我们的 basic-calculator 工具通过使用适当的内部表示来正确处理这些边界情况。