首页 › programming › 0.1加0.2≠0.3:浮点数算不了钱

0.1加0.2≠0.3:浮点数算不了钱

打开任何一个编程语言的 REPL,敲下 0.1 + 0.2,Python 会一本正经地回你 0.30000000000000004,JavaScript、Java、C、Rust 全都一样。这不是 bug,这是设计。Computerphile 的这期《Floating Point Numbers》就用十分钟讲透了一个反直觉的事实:计算机里的小数,本质上不是"数",而是一种"带精度限制的科学记数法"。它快得离谱,却天生算不准钱。

为什么这件事值得每个程序员认真看一遍?因为浮点数是这个行业里最普遍、最隐蔽、又最被低估的陷阱。它不是面试八股,它是真的会炸火箭、漏导弹、让股票指数凭空蒸发掉一半的隐形杀手。这门"必修课"大多数人从来没系统学过,全靠线上事故被动补课。这篇解读会把视频的核心论点拆开重组,再补上视频没展开的真实灾难、设计者 William Kahan 的天才之处,以及你在工程里真正该怎么做。

视频背景卡片

项目 内容
视频标题 Floating Point Numbers - Computerphile
频道 Computerphile(Brady Haran 出品,Numberphile 姊妹频道,订阅约 250 万+)
主讲人 Robert Miles(诺丁汉大学计算机系)
发布日期 2014-01-22
时长 约 10 分钟(系列含 Part 1 浮点 vs 定点、Part 2 浮点加法)
播放量 百万级(Computerphile 最经典高播放视频之一)
核心主题 浮点数如何用二进制科学记数法表示、为什么算不了钱

核心内容拆解

一、浮点数 = 二进制的科学记数法

这是整个视频最该记住的一句话。我们小学学的科学记数法是 1.234 × 10⁵ 这种形式——一个尾数乘以一个底数的若干次方。浮点数把这件事搬到二进制:符号位 × 尾数(significand) × 2^指数。一个 64 位 double(IEEE 754 binary64)的 64 个 bit 被切成三段:1 位符号、11 位指数、52 位尾数。因为正规化的二进制小数永远以 1.xxxx 开头,那个开头的 1 不用存,凭空白送你一位精度——这就是著名的"隐含位(hidden bit)",也是 Kahan 设计上的小巧思。

理解了"科学记数法"这个本体,后面所有反直觉现象都有了解释。尾数决定精度(double 大约 15–17 位有效十进制位),指数决定范围(double 能表示从约 2.2e-308 到 1.8e308 这个匪夷所思的跨度)。精度和范围从来是同一个跷跷板的两端,浮点数用"有限位数 + 可滑动的小数点"换来了极大的表示范围,代价是大部分实数它根本存不下来,只能存"最近的那个能表示的值"。这是后续一切舍入误差的根源。

二、为什么 0.1 + 0.2 ≠ 0.3

视频里 Robert Miles 反复强调一个观点:浮点数是"带精度限制的近似"。把 0.1 这个十进制数翻译成二进制,你会得到 0.0001100110011... 一个无限循环小数——就像十进制里 1/3 = 0.333... 永远写不完一样。但 double 只有 52 位尾数,于是循环部分被硬生生截断、舍入。存进去的 0.1 其实是 0.1000000000000000055...,0.2 也一样是近似值。两个近似值相加,误差叠加,结果就变成了 0.30000000000000004。

这是我的延伸:关键不是"误差很小可以忽略",而是"浮点数本质上是离散网格上的点"。整个实数轴上,double 只能精确命中极少数的点(那些能被 2 的幂次组合出来的数,比如 0.5、0.25、0.75),其余全是四舍五入到最近网格点的近似。而且这个网格不是均匀的——数字越大,网格间距越大(这就是 ULP,unit in the last place,"最后一位单位")。在 1 附近,相邻两个 double 相差约 2.2e-16;到了 1e16 附近,相邻两个 double 之间的间距已经大于 1,也就是说 1e16 + 1 == 1e16,那个 1 直接被吞掉了。这叫"大数吃小数",是浮点数最坑人的特性之一。

三、速度换精度:这是笔划算但不完美的买卖

视频给了一个很直观的对比数据:同样一段计算,用浮点数跑比用定点数跑大约快 4.5 倍(这也是 Computerphile 视频里 Robert Miles 点出的核心卖点)。原因是浮点运算有专门的硬件浮点单元(FPU)支持,正规化的格式让硬件加减乘除可以流水线化、并行化,指数对齐、尾数移位都是专用电路一条龙搞定。现代 CPU/GPU 的浮点峰值算力(TFLOPS)就是这么堆出来的,AI 训练、3D 渲染、物理仿真全靠它。

但这笔买卖的不完美之处在于:你买到的是"快",不是"准"。对于图形渲染、科学计算、机器学习这些领域,0.0001 的误差完全无所谓,甚至模型本身就是概率近似——浮点数的速度优势是碾压性的,精度损失可以接受。视频里那句"Why can't floating point do money?"正是这个权衡的反面:钱的场景要求精确,0.1 元 + 0.2 元必须等于 0.3 元,一分一厘都不能错。于是浮点数在金融系统里是禁区,这是行业铁律。

四、那些救命的"特殊值":0、无穷、NaN

这是视频点到但没深挖、而我特别想展开的部分。IEEE 754 之所以被称为天才设计,很大程度上是因为它规定了一整套特殊值的语义,让计算在异常情况下不崩溃、错误还能被传播和观测:

这一整套设计的精神是:让异常变得"可计算"而非"致命"。在 754 之前,不同厂商的机器浮点格式各不相同,一段数值代码换个机器结果就变,NaN 这种东西更是不存在——出错直接停机。Kahan 用一套标准把整个行业的"数值可移植性"问题解决了。

五、隐形杀手:被浮点毁掉的真实事故

理论讲完,来看真实的血泪。这些是我补充的、视频未涉及但最能震慑人的案例:

爱国者导弹(1991 年 2 月 25 日,沙特宰赫兰)。这是史上最著名的浮点事故之一,美国 GAO 的报告编号 IMTEC-92-26 写得清清楚楚。爱国者系统用一个 24 位定点寄存器,以 1/10 秒为单位累计时间。问题来了:1/10 在二进制里是无限循环小数,被截断后产生微小舍入误差。系统连续运行约 100 小时后,误差累积到约 0.3 秒。导弹以拦截窗口靠这个时间定位来袭的飞毛腿导弹,0.3 秒的偏差意味着雷达把目标锁在了错误的位置——结果那枚飞毛腿直接命中美军兵营,28 名士兵阵亡。一个 0.1 秒单位的舍入,漂成了 0.3 秒,要了 28 条人命。

阿丽亚娜 5 号首飞(1996 年 6 月 4 日,Ariane flight V88)。欧洲航天局的旗舰火箭,首飞起飞约 37 秒后自行解体,连带的 4 颗 Cluster 卫星一起报销,损失超过 5 亿美元。事后调查(ESA Inquiry Board)揪出的根因令人扼腕:火箭的水平速度作为一个 64 位浮点数,被一段从阿丽亚娜 4 号原封不动继承过来的"死代码"转换成 16 位有符号整数。阿丽亚娜 5 的速度比 4 号快得多,数值超出了 16 位整数上限 32767,触发溢出异常,导航计算机崩溃,火箭误判姿态失控,启动自毁。一段本该删掉的代码,因为"它在 4 号上能跑"就被保留了下来——浮点和整数之间的边界,从来不是想当然的安全区。

温哥华股票交易所指数(1982 起)。这是个更隐蔽的慢性中毒。交易所的指数计算程序在更新时把结果截断(向下舍入)到 3 位小数,而不是四舍五入。每一次微小向下偏差不断累加,22 个月里指数从 1000 点被生生"算"成了约 520 点,凭空蒸发了近一半,而真实市场根本没跌那么多。重新用正确算法计算后,指数一夜跳回约 1098。舍入方向选错,时间一长就是系统性失真。

这三个案例覆盖了浮点坑的三种典型形态:累积漂移(爱国者)、范围溢出(Ariane 5)、舍入方向偏差(温哥华)。它们共同说明一件事——浮点数的错误几乎从不是"立刻报错",而是沉默地、缓慢地、在边缘条件下腐蚀你的系统,直到某天撞上一个临界点爆雷。

六、设计者的远见:William Kahan 与 1985

回到设计层面。IEEE 754-1985 标准的灵魂人物是加州大学伯克利分校的 William Kahan,业界尊称"浮点之父",1989 年获 ACM 图灵奖。他的贡献远不止"定个格式"——他设计了一套让浮点运算在数学性质上尽可能可预测、可移植、可诊断的体系:上述的渐进下溢、有符号零、NaN/Inf 传播、以及严格的舍入规则(默认"就近舍入到偶数",避免统计偏差),都是他坚持的结果。

Kahan 还写过一个叫 Paranoia 的程序(1980 年代),专门用来诊断一台机器的浮点实现是否合规、有哪些缺陷——这种"主动给标准配上体检工具"的工程自觉,在今天看来依然超前。他和学生还贡献了被广泛使用的 fdlibm 数学库。可以说,今天全世界几乎所有 CPU、GPU、编程语言的 float/double 行为一致、跨平台可复现,都要追溯到 1985 年那套标准和 Kahan 的执拗。标准后来在 2008、2019 年修订扩充(加入了十进制浮点、半精度 binary16 等),但骨架未变。

知识结构图

flowchart TD
    A["浮点数 IEEE 754<br/>= 二进制科学记数法<br/>符号位 × 尾数 × 2^指数"] --> B["存储结构 binary64"]
    A --> C["核心权衡"]
    A --> D["特殊值设计"]
    A --> E["失败模式"]
    A --> F["工程解药"]

    B --> B1["符号位 1 bit"]
    B --> B2["指数 11 bit 偏移1023<br/>决定范围 ~1e-308..1e308"]
    B --> B3["尾数 52 bit + 隐含1<br/>决定精度 ~15-17 位十进制"]

    C --> C1["速度:硬件 FPU<br/>比定点快约 4.5 倍"]
    C --> C2["代价:舍入误差<br/>只能精确命中少数实数"]

    D --> D1["+0 / -0 有符号零"]
    D --> D2["+Inf / -Inf 溢出不崩"]
    D --> D3["NaN ≠ NaN"]
    D --> D4["次正规数 渐进下溢"]

    E --> E1["0.1+0.2≠0.3<br/>二进制无限循环小数"]
    E --> E2["大数吃小数 累加误差<br/>ULP 随数量级变大"]
    E --> E3["灾难性抵消<br/>相近数相减丢精度"]
    E --> E4["真实事故<br/>爱国者/Ariane5/温哥华交易所"]

    F --> F1["算钱用整数或 BigDecimal"]
    F --> F2["比较用 epsilon 容差<br/>禁用 =="]
    F --> F3["Kahan 求和算法"]
    F --> F4["关键计算用 Decimal/高精度库"]

实战启示:在工程里到底该怎么做

看完上面这些,最该带走的是一套可执行的纪律,而不是一堆恐惧。我把它们按场景给你拆开:

第一,凡是涉及钱的,永远不要用 float/double。 这是最硬的一条铁律。正确做法三选一:用最小货币单位的整数(比如人民币用"分",金额乘以 100 存成 int);用语言自带的 Decimal 类型(Python 的 decimal.Decimal、C# 的 decimal);用 Java 的 BigDecimal。BigDecimal 还要注意用字符串构造(new BigDecimal("0.1")),千万别用 new BigDecimal(0.1),否则那个 0.1 在传入之前就已经被 double 近似污染了。

第二,永远不要用 == 比较浮点数。 因为相等的"数学值"在浮点里可能差一个 ULP。正确做法是判断差的绝对值是否小于一个容差 epsilon(abs(a - b) < 1e-9),而且对于跨数量级的比较要用相对容差而非绝对容差。如果你用语言内置的近似相等函数(如 Python 3.5+ 的 math.isclose),默认实现就是相对+绝对混合容差,比自己手写更稳。

第三,警惕循环里的累加。 把上百万个小浮点数加起来,误差会以可怕的速度累积,尤其当你用"从大到小"的顺序累加时——大数会吃掉后面所有小数的贡献。两个对策:先排序再从小到大累加;或者用 Kahan summation 算法,它用一个"补偿项"把每次丢失的低位精度捡回来,几乎能恢复到接近全精度的结果。Numpy 的 np.sum 在很多实现里底层就用了成对求和(pairwise summation)来缓解这个问题。

第四,远离"灾难性抵消"。 当你把两个非常接近的大数相减,结果的相对误差会被放大几个数量级(因为前导有效位全部抵消掉了)。经典例子是方差计算:用"平方的均值减去均值的平方"这个教科书公式,在数值上极不稳定,应该用 Welford 算法这种在线、数值稳定的版本。凡是公式里出现"两个接近的大数相减",都要本能地警觉。

第五,给关键计算留出"精度预算"。 在做物理仿真、几何运算、迭代求解时,心里要有个数:这一路算下来会损失多少有效位?关键判定(比如"是否碰撞""是否收敛")的阈值要和这个误差预算匹配,别把 1e-15 量级的抖动当成真实信号。对于必须精确的场景(比如区块链余额、投票计数、科学计量基准),上 Decimal 或定点库,慢一点但绝不会算错。

延伸学习路径

  1. David Goldberg, "What Every Computer Scientist Should Know About Floating-Point Arithmetic"(1991) —— 领域内公认必读经典,比任何视频都系统,免费可查。把这篇啃下来,你对浮点的理解就超过了 90% 的从业者。
  2. IEEE 754 某百科条目与官方标准文档(IEEE 754-2019) —— 想精确知道 binary16/32/64/128 的位布局、舍入模式、异常处理,这是权威出处。
  3. 网站 float.exposed —— 一个交互式工具,输入任意一个 double,立刻看到它 64 位的符号/指数/尾数分解和"实际能精确表示的真实值",比看一百遍讲解都直观。
  4. Computerphile 本系列的 Part 2《How to Add Floating Point Numbers》 —— 承接本视频,讲浮点加法为什么要先对齐指数、再移位尾数,补全运算层的细节。
  5. William Kahan 的个人主页(Berkeley EECS) —— 收录了他大量关于浮点陷阱、数学库错误、标准设计动机的原始文章,读他的吐槽本身就是一种享受。

一句话总结

浮点数是用速度换精度的二进制科学记数法——它让人类算力爆炸,却也用沉默的舍入误差在金融、航天、国防里埋下了无数颗延时引信;真正成熟的程序员,是在用它的速度的同时,永远清楚它在哪里、以什么方式"骗"了你。


参考来源

  1. Floating Point Numbers - Computerphile — 本文核心解读视频,Robert Miles 主讲
  2. GAO Report IMTEC-92-26: Patriot Missile Defense Software Problem — 爱国者导弹宰赫兰事故官方报告,1991 年 28 名士兵阵亡的浮点舍入累积漂移根因

本文由「51学通信」技术团队出品。51学通信由通信行业18年老兵爱卫生(微信ID: gprshome201101)维护,聚焦5G/6G核心网、3GPP标准解读、通信前沿技术深度分析。 出版作品有《5G核心网原理与实践》《IMS原理与实践》等4本通信专业书籍。知识星球「51学通信」提供深度技术答疑和行业洞察。


完成摘要(What was done)

下一步计划(What's next)

  1. (优先)等待下一轮分配:Round 3 → business(商业与创业),届时按相同流程选题
  2. 若需对本文做可视化增强,可调用 mermaid-generator 或 knowledge-card-pro 将"知识结构图"生成独立图片
  3. 若需把本文转成口播短视频,可调用 content-to-video 或 book-to-short-videos skill

注意事项(If applicable)

[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。

51学通信知识星球海报-扫码加入