AI 资讯 知识库 一叶一世界一篇读懂浮点数:IEEE 754 与 0.1+0.2≠0.3 的真相

一篇读懂浮点数:IEEE 754 与 0.1+0.2≠0.3 的真相

0.1+0.2 为什么不等于 0.3:IEEE 754 浮点数的真相(手绘信息图)

从一个著名 bug 说起:0.1+0.2≠0.3

在任何主流语言里执行 0.1 + 0.2 == 0.3,得到的都是 false——0.1+0.2 的结果是 0.30000000000000004。这不是某个语言的 bug,而是所有遵循 IEEE 754 标准的浮点数的共同行为:JavaScript、Python、Java、C 全部如此。更实用的后果在金融与科学计算里:金额累加出现「分」的误差、循环累加误差越滚越大、两个「相等」的浮点数比较失败。要理解这些现象,得先看浮点数在计算机里到底长什么样。

浮点数的结构:科学计数法的二进制版

浮点数的本质是二进制的科学计数法。十进制的科学计数法写 123.45 是 1.2345×10²;二进制同理——IEEE 754 把一个数拆成三段:

值 = (−1)^符号 × 1.尾数 × 2^指数

符号位(1 位)定正负;指数(8 位 for 单精度 / 11 位 for 双精度)定数量级;尾数(23 位 / 52 位)定精度。关键在尾数:尾数的位数有限,决定了「能精确表示的数」是有限的。单精度 float 约 7 位十进制有效数字,双精度 double 约 15~17 位。

为什么 0.1 无法被精确表示

十进制的 0.1 是有限小数,但换到二进制,0.1 是无限循环小数:0.1 = 0.000110011001100...(二进制,0011 无限循环)。就像十进制写 1/3 = 0.333... 写不完一样,二进制写 0.1 也写不完。而浮点数的尾数只有 52 位(double)——必须在某处截断。截断后的 0.1 实际存储的是「最接近 0.1 的那个可表示值」,比真正的 0.1 大约 5.5e-18。0.2 同理也有误差,两个「带误差的数」相加,误差叠加,结果自然不是精确的 0.3。

一个反直觉的补充:0.5、0.25、0.125 这些数(2 的负幂)可以被精确表示——因为它们的二进制展开是有限的。这就是为什么「0.5 能精确、0.1 不能」:分母是 2 的幂的十进制小数才是二进制有限小数。货币金额的分(0.01 = 1/100)分母含因子 5,注定无法精确——这就是金额计算禁用浮点的根本原因。

三类经典坑:比较、累加与溢出

坑一:直接相等比较。两个「数学上应该相等」的计算路径,因舍入路径不同产生最后一位的差异——== 判 false。正确姿势是容差比较:|a − b| < epsilon(epsilon 按场景选,常用 1e-9 的相对容差)。更严谨的是 ULP(单位最后位)比较,但多数场景相对容差足够。

坑二:累加误差。对大量小数求和,每次加法都引入舍入误差,误差线性累积——百万次累加后可能偏离明显。解法按精度需求递增:Kahan 求和(补偿求和,用一个「误差寄存器」把每步丢掉的零头攒起来补回,代价几乎为零)、成对求和(pairwise,分治累加减少误差传播)、或干脆换高精度类型。大数据统计场景(指标聚合)里,Kahan 是性价比之王。

坑三:大数吞小数。浮点的指数结构决定了一个反直觉现象:在 2^53(约 9×10¹⁵)量级上,double 的相邻可表示数间隔已是 2——给一个大数加 0.1,结果纹丝不动。JavaScript 里 9007199254740992 + 1 === 9007199254740992 为 true(JS 的 Number 就是 double,整数安全上限 Number.MAX_SAFE_INTEGER = 2^53−1 由此而来)。计数器、雪花 ID 这类大整数,在 JS 里必须用 BigInt 处理。

正确姿势:按场景选工具

金额计算:整数定点或 Decimal。以「分」为单位的整数运算(本库金融篇的原则)或语言的高精度十进制类型(Java BigDecimal、Python Decimal、JS 的 decimal.js)——十进制类型按「十进制科学计数法」存储,0.1 精确表示,加减乘除按十进制规则舍入。科学计算:理解误差、管理误差。接受浮点的近似本质,用 Kahan/成对求和管理累加误差,比较用容差,避免「除以极小数」与「相近数相减」(灾难性抵消——两个相近大数相减,有效位大量丢失)。序列化传输:数字的边界。JSON 里的大整数经 JS 解析会失真(后端 snowflake ID 序列化为 number 传给前端,前端拿到的是错值)——大整数以字符串传输是行业标准做法。

浮点在真实系统里的三个现场

把原理放到三个真实现场,误差管理的画面会更具体。现场一:金额的教训。某系统用 double 存余额 0.1 元做上万次累加,对账时差了几厘——single 事故的根源是「把物理近似的工具用在精确要求的场合」。正确的金额三件套:数据库用 DECIMAL 类型、代码用整数(分)或 BigDecimal/Decimal、展示时才转字符串——三处口径一致,误差从源头归零。现场二:游戏与图形的「精度抖动」。开放世界游戏里,坐标远离原点(几十公里外)时,float 的有效位数不够(大数吞小数),画面出现抖动与闪烁——工程解法是「浮点原点重定位」(把世界坐标系分块,每块内部用相对坐标计算)——这是 float 的指数结构对「大场景」的固有约束,硬件再强也绕不开。现场三:机器学习的低精度世界。深度学习主动拥抱更低精度:FP16/BF16/FP8(本库混合精度篇)——训练与推理用低位宽换来数倍速度与显存,模型对数值噪声的容忍度远超想象。三个现场合起来给出精度的完整光谱:金额要精确(用十进制)、图形要快(用 float)、AI 要更快(用 BF16/FP8)——「用什么精度」永远是用途决定的工程决策,不是道德判断。而所有决策的共同前提,是理解 float 的误差从哪来、有多大、会不会被放大。

进阶认知:浮点的规格细节与「双陷阱」清单

再深一层是 IEEE 754 的规格细节,两块知识值得单独记忆。规格细节:零有两个(+0 与 −0,比较相等但位模式不同);非规格化数(denormal)填补 0 附近的精度空隙(渐进下溢);NaN 与 Infinity——0/0 得 NaN(Not a Number,它不等于包括自己在内的任何数!NaN === NaN 为 false 是浮点的标准行为)、溢出得 Infinity;NaN 的传染性(任何含 NaN 的运算都是 NaN)与它的静默传播是数据管道的经典 bug 源(一个 NaN 混进统计,所有下游指标变 NaN——清洗数据时的 isNaN 检查是标配)。双陷阱清单(工程速查):陷阱一「相等比较」(用容差或取整到业务精度);陷阱二「循环求和」(用 Kahan 或分块);陷阱三「大数加小数」(2^53 边界,整数用 BigInt);陷阱四「JSON 大整数」(序列化成字符串);陷阱五「NaN 静默传播」(入口校验 + 防御性检查)。把这五个陷阱贴在工位上,能预防的 bug 比任何调试技巧都多——浮点的坑是有限集合,背下来比踩一遍便宜得多。

浮点的历史注脚:一场爆炸与一个标准

浮点数的标准化史有一个惊心动魄的注脚:1996 年阿丽亚娜 5 号火箭首飞 37 秒后自爆,事后调查的根因之一是把一个 64 位浮点数转换成 16 位整数时的溢出(水平速度相关变量超出 16 位范围)——价值数十亿欧元的火箭,倒在一个隐式类型转换上。IEEE 754 标准本身的故事同样值得知道:1980 年代之前各厂商的浮点实现五花八门(同样的程序在不同机器算出不同结果),Intel 委托 William Kahan(后来拿图灵奖)主导设计统一标准——1985 年的 IEEE 754 让「浮点运算可移植、可预测」,这是此后四十余年所有数值计算的共同地基。历史的两条教训与今天的工程直接相关:其一,隐式转换是浮点事故的最大来源(语言在混合精度运算时的静默提升与截断——写代码时显式声明比信任默认安全);其二,标准的统一不等于正确性的统一(同是 IEEE 754,舍入模式、数学库的实现差异仍会让跨平台结果有细微不同——金融与科学计算对「跨平台一致性」的要求要用专门的确定性配置去满足)。浮点这一课的收束:它从设计之初就是「工程近似」的产物,理解近似,才能驾驭近似。

浮点的速查卡片:随手可查的决策表

浮点内容的最后一张速查卡,按「场景 → 处方」组织。要精确金额 → 整数(分)或 Decimal,禁用 float。要比较浮点 → 容差比较 abs(a-b) < eps,禁用 ==。要大量求和 → Kahan 或分块求和,或换高精度类型。要存大整数 ID → 字符串或 BigInt,JS 里别用 Number。要传 JSON 数字 → 大整数序列化为字符串。要高速计算 → float/BF16(接受误差,验证误差边界)。数据里可能有 NaN → 入口校验 + 每层防御性检查。要跨平台一致 → 显式舍入模式与固定数学库。八行决策表覆盖浮点的绝大多数日常——遇到浮点问题先查表,查不到再回原理——原理在本篇,表在你的工位。

一道自测题:读懂这四个输出

收尾用四个输出自测理解程度(都是真实语言的行为)。其一,0.1 + 0.2 == 0.3 的结果是 false——原因正文:0.1 与 0.2 都是带截断误差的近似,相加后误差可见。其二,0.5 + 0.25 == 0.75 的结果是 true——三个数都是 2 的负幂,二进制精确表示,零误差。其三,typeof 0.1(JS)是 "number"——JS 只有一种数字类型就是 double,没有单独的整型(大整数才需要 BigInt)。其四,Math.round(-0.5)(JS)的结果是 -0 而非 -1——round 对 .5 的取整规则(向 +∞ 取整)与直觉不同,负零的存在(IEEE 754 的 +0/−0 双零)在此显形。四个输出若都能解释「为什么」,本篇的机制层就算过关——浮点的学习不是背结论,是把每一条结论都还原成「尾数有限 + 二进制表示 + 舍入规则」的推论。

小结

浮点数是二进制科学计数法:符号 + 指数 + 有限尾数——有限的尾数注定无法表示无限的实数,0.1 在二进制里是无限循环小数,截断即误差。三类经典坑:直接相等比较(用容差)、累加误差(Kahan/成对求和)、大数吞小数(2^53 上限,大整数用 BigInt 或字符串)。正确姿势按场景:金额用整数定点或 Decimal、科学计算管理误差、传输防大整数失真。浮点数没有 bug,只有物理——理解了表示的边界,误差就从玄学变成了算术。

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?