☰
浮点数精度问题全解:从0.1+0.2到金额对账
2026/10/27 13:56:44 网站建设 项目流程

第一次被浮点数坑到,不是在控制台敲出0.1 + 0.2的那一刻,而是在接手一个结算模块之后。那天报表里少了三分钱,排查了大半天,最后发现不是数据库的锅,也不是并发写坏了,而是几万笔金额在累加时被 double 一点点舍掉了。从那以后我才真正明白,浮点数精度问题不是一道面试题,它是每个写业务代码的人迟早要交学费的必修课。这篇内容不讲空话,从位模式怎么存、误差怎么滚、判等怎么写、金额怎么存,到一次真实的对账排查链路,全部拆开讲清楚。无论你是刚接触后端的新手,还是写过几年业务的老手,只要你经手过钱、分数、坐标、传感器数值或任何需要“看起来对得上”的数字,这些内容都应该能直接用上。

1. 从一行对不上的账单说起:浮点误差到底长什么样

1.1 它不像 Bug,更像“系统在跟你开玩笑”

浮点误差最烦人的地方,是它表现得特别随机。同样一段代码,输入 0.1 和 0.2,输出是一串看不到头的尾巴;输入 1 和 2,一切正常。你会本能地怀疑是自己哪里写错了,可断点打下去,每一步看起来都对。更让人抓狂的是它还会“看场景”:在本地复现不了,到了线上某台机器上跑一批数据,差异就出来了。

我在实际项目里归纳过,浮点误差的表现基本就三类。第一类是打印出长尾巴,比如金额算完变成59.699999999999996,前端直接把它渲染到页面上,用户截图来找你。第二类是相等判断静默失败,两个数学上该相等的值,==判出来是 false,于是去重失效、缓存命中率归零、幂等校验拦不住重复请求。第三类是误差累积,单次运算差那么一丁点,几万次累加之后就变成了可见的金额差。

这三类症状的共同点是:都不会抛异常。程序不崩,日志不报错,只是结果慢慢偏掉。这才是它最危险的地方——一个会崩的程序你会立刻去修,一个悄悄地少算三分钱的程序,可能三个月后才在对账时被发现。

1.2 根子在进制转换:0.1 在存进去的那一刻就已经变了

很多人以为浮点误差是“计算的时候算不准”,其实不是。真正的分水岭在你把十进制字面量写进代码的那一刻就出现了。

一个十进制小数能被有限位二进制精确表示,前提是它化成分数后分母只含质因子 2。0.5 = 1/2,可以,二进制就是 0.1。0.25 = 1/4,可以。0.75 = 3/4,可以。但 0.1 = 1/10,分母里有 5,二进制表示不了,只能写成无限循环:

0.1(十进制) = 0.000110011001100110011...(二进制,1100 无限循环)

double只有 52 位尾数可用,无限循环只能截断。截断就意味着舍入,舍入就意味着存进去的那个数,已经不是 0.1 了。你后面所有的加减乘除,都是在这个“近似值”上做运算,误差从源头上就已经埋下。

1.3 用位模式把这 0.4 个 ULP 算清楚

不把这件事算一遍,很难真正建立起“误差是有限的、可估算的”这种直觉。以双精度为例,64 位被切成三段:

段位数含义
符号位10 为正,1 为负
指数位11存的是偏移值,实际指数 = 存储值 - 1023
尾数位52存的是小数点后的部分,隐含一个前导 1

0.1 的位模式是0x3FB999999999999A。拆开看:符号 0;指数段0x3FB等于 1019,减 1023 得到 -4;尾数0x999999999999A展开成二进制是1001 1001 ... 1010。拼起来就是1.100110011001...1010 × 2^-4,注意最后那个1010结尾——原本无限循环的1001,在第 52 位之后被强行截断并进位了。

这个数在十进制里精确等于0.1000000000000000055511151231257827021181583404541015625。和 0.1 相比,多了大约5.55e-18。

再看这个量级下的一个 ULP(末位单位)有多大。0.1 落在[2^-4, 2^-3)区间,所以ULP = 2^(-4-52) = 2^-56 ≈ 1.3878e-17。刚才那点误差是5.55e-18,正好是0.4 × ULP。

提示:这个 0.4 不是巧合。IEEE 754 默认的舍入模式是“就近舍入”,误差绝对值永远不超过半个 ULP。0.4 < 0.5,说明 0.1 的存储值选取是正确的、最接近的那个候选。

记住这个结论:每个浮点数的存储误差都有上界,且上界是它自身量级的 ULP 的一半。这既是坏消息(误差必然存在),也是好消息(误差可以估算,而不是玄学)。

2. 0.1 + 0.2 = 0.30000000000000004 的完整推演

2.1 把两个数对齐到同一条指数线上

加法器做的事比我们想象的要死板得多:它不会“理解”数学,只会把两个数的二进制尾数对齐,然后硬加。

前面算过,0.1 =1.100110011001...1010 × 2^-4。0.2 呢?它的位模式是0x3FC999999999999A,指数段 1020,实际指数 -3,尾数部分和 0.1 完全一样。也就是说 0.2 的存储值就是 0.1 存储值的两倍,这很合理,因为 0.2 的十进制误差也是 0.1 的两倍(1.11e-17,仍然是 0.4 个 ULP,只是这个量级的 ULP 变成了2^-55)。

相加时,先要把指数统一到较大的那个(-3):

0.1 → 1.100110011001...1010 × 2^-4 → 0.110011001100...11010 × 2^-3 (右移一位,尾数末尾多出一位) 0.2 → 1.100110011001...1010 × 2^-3

注意右移时多出来的那一位不会凭空消失,硬件里有一个“保护位”专门留住它。IEEE 754 的加法器实际上会保留三位额外位:保护位(guard)、舍入位(round)、粘滞位(sticky,表示后面还有没有非零位)。这三位决定了最终舍入的方向。

2.2 那个“正好一半”的位置和 ties-to-even

两位尾数相加,得到一个10.011001...形式的结果,规格化后又变成1.001100110011...×2^-2。这时尾数有效位数超过了 52 位,必须再舍入一次。

关键点来了:由于 0.1 和 0.2 的尾数位完全相同,只是指数差 1,右移对齐之后,超出 52 位的部分呈现出一种非常规整的模式——正好是“1 后面跟一串 0”。换句话说,这个结果精确地落在两个相邻 double 的正中间。

两个候选值分别是:

d1 = 0x3FD3333333333333 = 0.299999999999999988897769753748434595763683319091796875 d2 = 0x3FD3333333333334 = 0.3000000000000000444089209850062616169452667236328125

d1 的尾数末位是十六进制的3,二进制末位是 1,属于奇数;d2 的尾数末位是4,二进制末位是 0,属于偶数。

IEEE 754 的默认舍入规则叫“就近舍入,平局取偶”(round to nearest, ties to even)。遇到正好一半的平局,选尾数末位为 0 的那个。所以选 d2。

这就是0.1 + 0.2 = 0.30000000000000004的全部来历。它不是随机漂移,不是精度不够,而是一次严格按规则执行的、可完全复现的舍入决策。

2.3 为什么 0.3 打印出来却是干净的

这里有个特别容易让人误解的地方。你写console.log(0.3)或者print(0.3),屏幕上老老实实显示0.3。可 0.3 的存储值是0.299999999999999988897769753748...,明明不是 0.3。

原因是现代运行时的浮点打印走的是“最短往返表示”(shortest round-trip)策略:在所有能被解析回同一个 double 的十进制串里,挑最短的那个。0.3 的存储值解析回去是 d1,而"0.3"这个字符串解析出来也正好是 d1,两者一致,所以直接打印0.3就行,没必要印出 20 位。而0.1 + 0.2的结果是 d2,"0.3"解析不出 d2,最短能往返的串是"0.30000000000000004",于是就是这么长。

这意味着:你看到的打印结果不等于内部存储值。想看真身,Python 里用format(x, '.20f'),结果是0.30000000000000004441;用Decimal(0.1 + 0.2)会直接给你那个 50 多位的精确值。排查问题时如果只看print,很容易得出“这里没问题”的错误结论。

3. 五个真会被咬到的业务场景

3.1 金额与库存:差一分钱是怎么滚出来的

金额是重灾区,因为它同时满足两个条件:有明确的、不可让步的正确性要求(对不上账要人工核销),以及大量使用乘除法(折扣、税率、分摊)。

举个最常见的例子:商品单价 19.9,买 3 件。数学上 59.7。代码里跑一下19.9 * 3,Python 给出59.699999999999996。如果这时你用round(x, 2)得到 59.7,好像没事;但如果你用int(x * 100)转成“分”,得到的是 5969,少了一分钱。这个int()截断的写法在代码评审里极其常见,因为写的人觉得“乘 100 就变成整数了,转一下很安全”。

库存也是同理。按重量计价的商品,0.1 千克 × 3 件,累加结果可能是 0.30000000000000004 千克;如果上架数量校验写的是if (stock > 0.3),那 0.30000000000000004 就过不了校验,明明有货却提示库存不足。

还有一种更隐蔽的:分摊。100 块钱按 3 个人分,每人 33.333...,你的舍入规则如果是“各自四舍五入”,三个人加起来是 99.99,少一分;如果是“最后一个取差额”,那三个人的结果又依赖于顺序,换个排序结果就变了。这类问题本质上是舍入策略问题,但浮点误差会把它放大成“看起来像是随机出错”。

3.2 批量累加:误差随规模放大的隐蔽性

单次运算的误差在 1e-16 量级,看着无所谓。但它会累积。

Python 里sum([0.1] * 10)得到0.9999999999999999。加 10 次就肉眼可见了。加到 100 万次呢?误差不是简单线性增长,因为每次的舍入方向是随机的,统计上接近随机游走,量级在sqrt(n) × eps × 部分和附近。

我在两类数据类型上做过粗略对比,用的是“100 万次累加 0.1”这种典型压力场景:

类型尾数位单次 eps累加到 1e5 量级的典型偏差最坏情况
float3224约 6e-80.1 上下几十
float6452约 2.2e-161e-9 上下1e-6 量级

最坏情况的估算方式是:累加到S时,每一步最大的舍入误差是ULP(S)/2,乘以步数。float32 累加到 10000 左右时,ULP 是2^-10 ≈ 0.00098,10 万步最坏能到 49。虽然实际跑出来通常远小于这个上界,但只要数据规模够大、或者数据本身量级差异悬殊,最坏情况是能撞上的。

这也是为什么很多统计类系统会莫名其妙地对不上小数额——不是谁写错了,是顺序和规模变了。

3.3 相等判断与容器键:最安静的雷

if (a == b)用在浮点上,等于在赌两次计算路径的舍入完全一致。只要中间多一步或少一步运算,结果就可能差一个 ULP。

更麻烦的是把浮点数当容器键。用 double 做 Map 的 key、做去重、做缓存键,逻辑上“这两个值应该一样”,实际上哈希值可能不同。缓存命中率突然下降、去重后数量比预期多、幂等表拦不住重复请求,排查起来都极其痛苦,因为你看代码觉得完全合理。

还有排序。浮点数的比较虽然满足全序(除了 NaN),但两个“数学上相等”的值在排序后可能分居两处,导致分组结果不稳定。做聚合统计时,如果按浮点值分组,很可能出现两组本该合并的数据。

3.4 序列化往返与字段类型不匹配

按理说,double 序列化成十进制再解析回来,应该能还原成同一个值,因为最短往返表示保证了这一点。真正的坑出在类型不一致上。

第一种:JSON 数字在 JavaScript 里统一是 double。后端用int64存了一个订单号9007199254740993,前端JSON.parse之后变成了9007199254740992。用户点开详情页,看到的订单号和列表页的不是同一个。这类问题在“ID 刚好超过 2^53”的系统里必然出现一次。

第二种:协议里某个字段是 32 位浮点,中间层用 double 接收,再写回去就多出了精度噪声。C 结构体、Thrift/Protobuf 的 float 字段、某些图形或传感器接口都是这个路数。

第三种:数据库字段类型选错。金额字段用FLOAT或REAL存,读写一次就变一次形;从DECIMAL读出来赋给代码里的double,本来精确的值也在赋值瞬间被舍入了。

3.5 大整数与 2^53 那条线

双精度能精确表示所有不超过2^53 - 1 = 9007199254740991的整数。超过之后,整数之间开始出现“空洞”,相邻可表示整数的间隔变成 2、4、8……

有意思的是 2^53 本身的 ULP 已经是 2,所以1e16 + 1.0在 double 里等于1e16。这不是加法坏了,是 1 这个增量小于半个 ULP,被舍掉了。这种现象叫“大数吃小数”(absorption)。

工程上要特别注意两种场景:一是雪花 ID 之类的 64 位整数直接扔给前端,二是把时间戳(毫秒级,13 位)乘以 1000 之后当纳秒用,很快就能超过安全范围。前者应该在序列化时转成字符串,后者应该用专门的 64 位整数类型。

4. 判等这件事:容差怎么写才不至于自欺欺人

4.1 绝对容差为什么在大小数混用时失效

最常见的写法是abs(a - b) < 1e-9。这个写法只在“数值量级已知且稳定”的前提下成立。

问题出在两个极端。一端是接近零的数:1e-12和2e-12在被判等的情况下,相对误差是 100%,这显然是荒谬的。另一端是大数:1e12的 ULP 大约是1e-4,两个数相差 1 其实是合理的、有意义的差异,但1e-9的容差会让它被判为不等——虽然这个方向还算“保守”,但如果你把容差调大到1e-3,又会让1e12量级上相差 0.1 的两个数被判等,这就完全失真了。

所以绝对容差只能用于量级收敛、且你有明确业务容忍度的场景,比如“两个坐标相差 1 微米以内视为同一个点”。脱离量级谈容差,就是在自欺欺人。

4.2 相对容差、ULP 与 isclose 的参数含义

相对容差的思路是:误差相对于数值本身的大小才是有意义的。Python 的math.isclose是这么判的:

abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)

注意它是两个条件取或:要么满足相对容差,要么满足绝对容差。abs_tol默认是 0,也就是说默认情况下“接近零的两个数”是不被宽容的,必须自己显式指定。很多人在用isclose时踩的坑就是忘了传abs_tol,结果 0 和 1e-12 被判为不相等。

参数怎么选?我的经验是:

场景建议
单次或少数几次运算的结果比较rel_tol=1e-12,约 4500 个 ULP,够用且不松
经过数十步迭代或矩阵运算rel_tol=1e-9
传感器原始值、物理量比较按传感器精度反推,通常1e-3到1e-6
涉及接近零的结果必须补abs_tol,取该业务的最小有效单位

顺便提一句,NumPy 的np.isclose默认参数是rtol=1e-5, atol=1e-8,比math.isclose松得多。做数值测试时如果直接拿默认值比较,很可能把真正的精度问题放过去,建议测试里显式写死容差。

比容差更严格的方式是按 ULP 比较:先算出两个数之间隔着多少个可表示的浮点数,再判断这个数量是否在可接受范围内。Java 的Math.ulp、Python 3.9+ 的math.ulp都能直接拿到某个值对应的 ULP 大小。

4.3 可以直接抄走的比较函数

JavaScript 没有内置近似比较,手写一个:

function nearlyEqual(a, b, relTol = 1e-9, absTol = 0) { if (a === b) return true; // 顺带处理 ±0 和同为 Infinity 的情况 if (Number.isNaN(a) || Number.isNaN(b)) return false; if (!Number.isFinite(a) || !Number.isFinite(b)) return false; const diff = Math.abs(a - b); const scale = Math.max(Math.abs(a), Math.abs(b)); return diff <= Math.max(relTol * scale, absTol); }

Java 里同构的写法:

public static boolean nearlyEqual(double a, double b, double relTol, double absTol) { if (a == b) return true; if (Double.isNaN(a) || Double.isNaN(b)) return false; if (Double.isInfinite(a) || Double.isInfinite(b)) return false; double diff = Math.abs(a - b); double scale = Math.max(Math.abs(a), Math.abs(b)); return diff <= Math.max(relTol * scale, absTol); }

这里有个细节值得说:if (a === b) return true这一句不只是性能优化,它顺手处理了-0.0 === 0.0为真的情况,也避免了Infinity - Infinity得到 NaN 之后再参与比较的麻烦。函数里把 NaN 和 Infinity 单独挡掉,是因为“两个 Infinity 相等”和“两个 NaN 不等”这两种语义在不同业务里期望不同,明确拒绝比默默返回一个可能错的结果更安全。

注意:判等函数只是止血,不是治本。如果你发现业务里到处都在传容差参数,说明数据模型本身有问题——典型信号就是“金额在比相等”或者“用浮点做分组键”。这时候该做的是改存储类型,而不是继续调容差。

5. 从根上绕开:整数分、定点数与十进制库的取舍

5.1 金额一律用最小货币单位的整数

我现在的默认做法是:任何涉及金额的字段,一律用 long/int64 以“分”为单位存储,绝不让 double 出现在链路上。

数据库层用BIGINT存分,或者用DECIMAL(18,2)存元(DECIMAL是定点数,不会丢精度,但代码里读出来必须是BigDecimal或字符串,不能赋给 double)。接口层用整数传输,字段名带上单位后缀,比如amountCents,让人一眼看出单位。

乘法怎么处理?比如“单价 1999 分 × 数量 3”,直接整数乘,得到 5997 分。遇到折扣这种带小数的系数,用分子分母的形式传参,别传 0.87 这种 double:

def apply_rate(cents: int, numerator: int, denominator: int) -> int: # 四舍五入到分,正负对称处理 if cents >= 0: return (cents * numerator + denominator // 2) // denominator return -((-cents * numerator + denominator // 2) // denominator)

这个写法把+ denominator // 2放在除法之前,实现的是“四舍五入”而不是“截断”,同时用绝对值处理保证负数也对称——直接对负数用//会有向负无穷取整的问题,-3 // 2等于-2而不是-1,这是 Python 里非常容易踩的一脚。

分摊场景要额外定策略。要么“末尾补差”:前 n-1 份按舍入算,最后一份等于总额减去前面之和,保证总额绝对准确;要么用“最大余数法”,把所有小数部分排序,把剩余的零头分给小数部分最大的那几个。前者简单,后者更公平。无论哪种,都要在需求文档里写死,因为它会影响到退款时的金额。

5.2 Decimal 的正确初始化方式与性能代价

Python 的decimal.Decimal、Java 的BigDecimal都是十进制浮点,走的是十进制运算,不会出现 0.1 存不进去的问题。但它们有两个必须知道的坑。

第一个坑是初始化。Python 里Decimal(0.1)和你想要的完全不是一回事:

from decimal import Decimal Decimal(0.1) # Decimal('0.1000000000000000055511151231257827021181583404541015625') Decimal('0.1') # Decimal('0.1')

传 float 进去,它会把 float 的精确值原封不动搬进来,等于把误差也搬进来了。必须传字符串,或者用Decimal.from_float明确表示“我就要这个 float 的精确值”。Java 里同理,new BigDecimal(0.1)和BigDecimal.valueOf(0.1)结果不同:前者用Double.toString的精确展开,后者走Double.toString的最短表示,后者通常才是你要的。最稳的还是new BigDecimal("0.1")。

第二个坑是 equals 和 compareTo 不等价。BigDecimal("1.0").equals(new BigDecimal("1.00"))返回 false,因为equals会比较 scale。用HashSet存BigDecimal去重,1.0 和 1.00 会被当成两个元素。要比较数值相等统一用compareTo(...) == 0。

还有除法。BigDecimal的divide在除不尽时会直接抛ArithmeticException,必须显式给 scale 和舍入模式:

BigDecimal result = a.divide(b, 2, RoundingMode.HALF_UP);

至于性能,BigDecimal的运算比 double 慢一到两个数量级,Decimal在 Python 里也差不多是这个量级。所以我的原则是:金额、税额、利息这类必须精确的地方用十进制类型;统计、排序、图形、物理模拟这类只要足够精确的地方用 double。别为了“保险”把所有数值都换成 Decimal,那会把性能拖垮,而且很多地方你并不需要那个精度。

5.3 各语言落地对照

语言金额推荐做法十进制类型需要留意的点
Pythonint存分decimal.Decimal必须用字符串构造,Decimal(0.1)是坑
Javalong存分BigDecimalequals比 scale,divide必须给舍入模式
JavaScriptBigInt或整数分社区十进制库Number只有 53 位有效整数,ID 必须转字符串
Goint64存分math/big.Rat或定点库结构体字段别用float32接收金额
SQLBIGINT存分或DECIMAL(18,2)DECIMAL/NUMERIC别用FLOAT/REAL存钱,聚合要注意类型提升

表格里最后一条特别值得强调:DECIMAL字段在参与某些运算时可能被隐式转换成浮点,尤其是和字面量、SUM、AVG混用时。写聚合查询前先DESCRIBE一下字段类型,比事后对账省事得多。

6. 一次“对账差三分钱”的排查链路复盘

6.1 第一步:先分清误差是输入带来的还是计算带来的

当时的现象是:明细表逐笔加总,和汇总表的总金额差 0.03。第一反应是并发写漏了,查了半天日志,没有异常,笔数也对得上。

这时候正确的动作不是继续猜,而是把问题切成两段:输入侧和计算侧。

输入侧验证:把明细表的原始金额导出来,用十进制精确求和,看结果是多少。如果精确求和和汇总表一致,说明落库的时候就已经错了,问题在写入链路;如果不一致,问题在汇总计算。

这一步我用了 Python 的Decimal写了个十几行的小脚本,把所有金额当字符串读进来,用Decimal加总。结果精确求和等于明细之和,说明落库没问题,误差是在汇总时产生的。整个验证花了不到十分钟,比继续翻日志高效得多。

6.2 第二步:二分定位到累加顺序

确定是计算侧问题后,下一步是缩小范围。我的做法是:把明细按不同方式排序(按 ID、按金额、按时间),各算一遍,看结果是否一致。

结果很说明问题:按 ID 排和按时间排算出来的总额一致,按金额从小到大排算出来差了 0.03。顺序影响结果,说明是累加过程的舍入误差,而不是某一笔数据本身有问题。

再往下就是确认具体的机制。这批数据里混了几笔很大的金额(几十万),也有大量小额(几块钱)。当累加器已经变成几十万时,它的 ULP 大约是3e-11,看起来还很小;但如果中间还混着一些以 float32 存储的字段(当时有个历史遗留的统计表),ULP 就变成了0.03这个量级——加一笔几块钱进去,直接被舍掉了。

这一步的关键工具是对照实验:同一批数据,用 double 加一遍,用 Decimal 加一遍,两者相差多少、相差在第几笔开始出现,一目了然。我当时的做法是每加 1000 笔打一个快照,很快就定位到了具体的数据区间。

6.3 第三步:修复方案与回归验证

定位清楚之后,修复分短期和长期两步走。

短期止血:汇总报表改成用高精度类型重算,一次性把历史数据刷对。这一步只改查询和脚本,不动表结构,风险可控。

长期治本:把金额字段统一改成整数分存储,链路上所有float、double接收点全部排查一遍。这一步花的时间比预想的长,因为要改的不只是主链路,还有导出、对账、报表、消息体这些边缘入口。经验是:只要有一个入口还在用 double,前面所有的努力都会从这里漏出去。

回归验证这块,我加了一类测试:每个涉及金额的计算函数,都配一个“高精度参照实现”,用Decimal或整数写一遍,和主实现对比,容差设为 0。这类测试写起来不难,但能挡住绝大多数回归。另外在 CI 里加了一条数据校验任务:每天抽样一批订单,用 Decimal 重算总额,和线上结果比对,差一分就报警。

提示:如果你们的系统里已经有大量历史 double 数据,不要指望一次迁移能干净。务实的做法是加一个“新旧双算”的过渡期,两条链路并行跑一段时间,差异归零之后再下线旧链路。

7. 几个容易被忽略的角落

7.1 round 和 toFixed 的边界

前端做金额展示,很多人顺手写(1.005).toFixed(2),满心以为得到"1.01",实际输出"1.00"。

原因还是二进制。1.005 的存储值大约是1.00499999999999989341858963598497211933135986328125,本来就小于 1.005,四舍五入到两位自然是 1.00。这不是toFixed的 Bug,是输入本身就已经偏了。

Python 的round也有类似的坑,而且还有第二个坑:Python 3 用的是银行家舍入(round half to even),round(0.5)是 0,round(1.5)是 2,round(2.5)是 2。如果业务规则明确要求“四舍五入”,直接用round会出错。

我的建议是:展示层不要做任何依赖浮点的舍入。要么后端已经把金额算成整数分传过来,前端只做除法展示;要么用Decimal或整数运算在服务端算好再传。前端唯一该做的就是格式化字符串,而不是修数字。

7.2 -0.0、NaN 与哈希

-0.0 === 0.0是 true,这看起来挺好,但1 / -0.0等于-Infinity,Object.is(-0, 0)是 false。如果某个地方用浮点值做缓存键,-0和0可能落到不同的桶里。

NaN 更麻烦:它和任何值都不相等,包括自己。NaN === NaN是 false。这在排序、去重、断言里都会引发意外。用Number.isNaN判断,别用x === NaN(永远 false)。序列化时也要小心,JSON.stringify(NaN)得到的是"null",一个非法的数值悄无声息地变成了空值。

-0的产生也有隐蔽来源:0 * -1、Math.round(-0.4)、某些减法结果是 0 但符号位为 1。如果业务里有除法,建议在出口统一用x === 0 ? 0 : x归一化掉符号。

7.3 Kahan 求和什么情况下值得上

如果确实需要在 double 上做长序列累加,又不想换类型,可以用补偿求和。Kahan 求和的核心思路是:每次加法之后,把“因为舍入丢掉的那部分”单独存起来,下一次再加回去。

def kahan_sum(values): total = 0.0 c = 0.0 # 补偿量 for v in values: y = v - c t = total + y c = (t - total) - y # 精确算出这一步丢掉了什么 total = t return total

Kahan 能把误差从O(n·eps)降到O(eps),代价是每步多几次浮点运算和一个额外的变量。Neumaier 变体在此基础上补了一层,能更好地处理“当前项比累加和大很多”的情况:

def neumaier_sum(values): total = 0.0 c = 0.0 for v in values: t = total + v if abs(total) >= abs(v): c += (total -

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询