1. 先搞清楚浮点数到底在解决什么问题1.1 一次数据对不上的经历早些年我接手过一个数据核对的小工具输入两边的金额判断是否一致。逻辑简单到不能再简单两个数相减差的绝对值小于 0.01 就算通过。上线第一天就有人拿着截图来找我左边 3.3右边 3.3程序判成不一致。我当时的第一反应是数据源有问题去查日志把两个数打印到十七位小数才发现一边是3.2999999999999998另一边是3.2999999999999999。人眼看着一模一样机器眼里差了一个最低位。那次之后我才真正坐下来把浮点数的表示方式从头捋了一遍。在此之前我对它的理解停留在小数在计算机里存不准这一句话上——知道有这么回事但不知道具体不准在哪里、误差有多大、什么时候会咬人。这篇笔记就是我那段时间的整理成果从二进制表示一直写到工程里怎么绕坑。它不要求你有多少前置知识只要你知道二进制是什么、能看懂一小段代码就能跟着走完。如果你平时写业务代码偶尔处理金额、坐标、传感器读数、图形渲染、科学计算那这套东西迟早会找上你。浮点数的表示这件事核心要回答三个问题一个十进制小数怎么变成一串 0 和 1这串 0 和 1 怎么反解回一个数以及在这个过程中哪些信息被丢掉了、丢了多少。把这三件事想明白后面所有的精度丢失比较不相等大数变小就都有了解释而不是靠背结论。1.2 从科学计数法到二进制浮点我们平时写大数小数习惯用科学计数法6.02×10^23、1.6×10^-19。这个写法包含两个信息——一个有效数字尾数和一个量级指数。它的好处是同一套写法既能表示星体质量也能表示电子电荷不需要为每个量级单独设计格式。计算机里的浮点数就是这套思路的二进制版本只不过底数从 10 换成了 2。为什么底数要换成 2因为硬件是二进制的。存储、运算、移位都是 2 的幂次操作底数为 2 时调整小数点位置就是移动二进制位一条移位指令搞定。如果底数用 10每次规格化都要做一次除以 10 的运算而 10 不是 2 的幂除法在硬件上是昂贵的操作。这就是一个典型的格式设计迁就硬件的例子后面的偏置设计、隐含位设计背后的逻辑也都类似——不是为了数学上好看而是为了让电路更简单、更快。在这样的格式下一个浮点数被拆成两部分一部分记录数字长什么样叫尾数也叫有效数字、significand、mantissa另一部分记录小数点该往哪挪叫阶码exponent。再加上一个记录正负的符号位三样东西拼起来就是一个完整的浮点数。IEEE 754 标准做的事情就是把这三位各自的位数、编码方式、特殊值的含义固定下来让不同厂商的芯片、不同的编程语言能互相读懂对方存下来的数。1.3 定点与浮点的取舍在浮点之前还有一种更朴素的表示法叫定点数约定小数点在固定位置比如用 32 位存数前 16 位整数部分、后 16 位小数部分。这种格式的好处是加减法极其简单直接把两个整数相加就行精度均匀实现成本低。缺点也很明显——范围被焊死了。16 位小数部分意味着最小只能表示 1/65536再小就全变成 016 位整数部分意味着最大只能到 65535再大就溢出。浮点数的价值就在这儿它把小数点位置也变成一个可以存的值。同样 32 位浮点能表示从 10^-45 量级到 10^38 量级的数跨度几十个数量级。代价是精度不均匀——靠近 0 的地方密远离 0 的地方稀。这就像一把刻度不均匀的尺子量小东西很精细量大东西只能给个大概。理解这一点非常关键后面很多为什么大数加小数等于没加的问题根子都在这个不均匀上。具体来说单精度浮点在 1 附近的最小间隔是 2^-23约 1.19×10^-7在 1000000 附近的最小间隔就变成了 2^-23×2^20约 0.125。同样是加 1在 1 附近能体现出来在 100 万附近就只能算差不多。这不是 bug是设计上必然的取舍。选择浮点就是选择了范围优先、精度随量级浮动这条路。2. IEEE 754 的三段式结构拆解2.1 符号位、阶码、尾数各管什么IEEE 754 把一个浮点数切成三段从高位到低位依次是符号位、阶码、尾数。以最常见的单精度binary32对应 C 语言的float、Java 的float为例总共 32 位分配如下字段位数位置作用符号位 S1 位第 31 位0 表示正1 表示负阶码 E8 位第 30 至 23 位存的是加了偏置的指数偏置为 127尾数 F23 位第 22 至 0 位存的是小数点后面的部分整数部分被隐含双精度binary64对应double则是 1 位符号、11 位阶码、52 位尾数偏置 1023。两者的结构完全同构只是位数不同。还有半精度binary161 位符号、5 位阶码、10 位尾数偏置 15常见于机器学习推理和图形格式里用来省内存和带宽。这三个字段的分工可以这样理解符号位决定方向阶码决定量级在哪一档尾数决定在这一档里具体是哪个数。要注意尾数的位数直接决定了精度——单精度 23 位尾数加上隐含的 1 共 24 位有效二进制位换算成十进制大约是 7 位有效数字双精度 52 位加隐含位共 53 位约 15 到 17 位十进制有效数字。这就是为什么打印double时前 15 位通常是可信的再往后就是看起来有意义其实没意义的数字了。2.2 阶码为什么要加偏置阶码这一段最反直觉的地方是它存的不是真实的指数而是指数 一个固定偏移量。单精度存的是真实指数 127双精度存的是真实指数 1023。这个偏移量叫偏置bias。比如真实指数是 3单精度阶码字段里存的就是 130二进制10000010。为什么要绕这一道原因有二。第一是让阶码字段变成无符号数方便硬件做大小比较。浮点数在正数区间内的大小关系恰好等同于把整个位模式当作无符号整数来比较时的大小关系符号位为 0 的情况下。如果阶码用补码存负数最高位是符号位比较逻辑就要分情况讨论电路会复杂不少。加了偏置之后阶码字段永远非负直接按无符号整数比大小就行这是一个非常漂亮的工程技巧。第二是让最小规格化数的指数不至于太极端。单精度阶码字段 8 位取值范围 0 到 255其中 0 和 255 被保留给特殊值实际可用的是 1 到 254。减去偏置 127真实指数范围是 -126 到 127。这个范围是经过权衡的太小了不够用太大了又会挤占尾数的位数。双精度同理11 位阶码保留 0 和 2047实际指数范围 -1022 到 1023。注意记公式的时候容易写成阶码 指数 - 127正确的是阶码字段 真实指数 127。方向搞反了后面所有手算都会错。2.3 规格化与那个藏起来的 1把任意一个正数写成二进制科学计数法形如1.xxxxx × 2^n的时候整数部分永远是 1只要这个数不是 0。既然是永远那就可以不存。这就是所谓的隐含位设计尾数字段只存小数点后面的那 23 位或 52 位前面的1.由解码时自动补上。这个设计凭空多赚了一位精度。单精度本来只有 23 位尾数加上隐含位变成 24 位有效精度双精度从 52 位变成 53 位。在硬件资源紧张的年代这是实打实的收益。代价是引入了一个例外——0 没办法用这个格式表示因为1.xxx × 2^n的形式永远得不到 0。所以零、非规格化数、无穷、NaN 这几类需要用阶码字段的特殊取值来单独编码这就是后面第 4 节要展开的内容。规格化的过程本身很简单把数不断除以 2 或乘以 2直到整数部分变成 1。比如12.375二进制是1100.011小数点左移三位得到1.100011 × 2^3规格化完成真实指数是 3。这一步是所有手算编码的基础多练几个数就熟了。2.4 单精度、双精度、半精度对照各种精度的关键参数整理成一张表方便查类型总位数符号位阶码位尾数位偏置有效十进制位最大指数半精度 binary1616151015约 3.315单精度 binary32321823127约 7.2127双精度 binary6464111521023约 15.91023扩展精度 binary80801156416383约 19.216383四精度 binary12812811511216383约 34.016383有效十进制位的算法是二进制有效位数 × log10(2)。单精度 24 位乘 0.301 约 7.22所以能保证 6 到 7 位十进制有效数字双精度 53 位乘 0.301 约 15.95所以能保证 15 位最多能撑到 17 位这部分是靠舍入碰运气的。在实际工程里选精度的思路不是越大越好。图形渲染用单精度甚至半精度是因为几何变换对 7 位有效数字已经够用而显存带宽和并行吞吐量才是瓶颈金融计算用定点或十进制类型是因为十进制小数的精确性比范围更重要科学计算里双精度是默认选项因为累积运算对误差非常敏感。这个选型后面第 6 节还会细讲。3. 手算一遍编码与解码3.1 十进制到二进制的三步走我把编码流程固定成三步练熟之后基本不会出错。第一步处理符号确定符号位是 0 还是 1然后只对绝对值做后续操作。第二步把绝对值转成二进制整数部分用除 2 取余小数部分用乘 2 取整直到小数部分变成 0 或者凑够需要的位数这一步是误差的来源后面细说。第三步规格化成1.xxxx × 2^n的形式得到真实指数 n 和尾数串然后阶码字段填n 127尾数字段填小数点后的部分不足 23 位右补 0超出就按舍入规则处理。这三步里最容易错的是第二步的小数转换。整数部分转换是有限步的一定结束小数部分是乘 2 取整数位如果这个小数在二进制下是无限循环的就永远结束不了。比如 0.1二进制是0.0001100110011……循环节是0011永远写不完。这就必然要求在某个位置截断截断就是误差。3.2 三个例子完整走一遍先拿一个干净的数12.375。符号为正S 0。整数部分 12 转二进制是1100小数部分 0.375 乘 2 得 0.75 取 0再乘 2 得 1.5 取 1再乘 2 得 1.0 取 1结束得到.011。合起来1100.011。规格化左移 3 位1.100011 × 2^3。真实指数 3阶码字段 3 127 130二进制10000010。尾数取小数点后100011右补 17 个 0 凑满 23 位。拼起来0 10000010 10001100000000000000000按四位一组0100 0001 0100 0110 0000 0000 0000 0000即0x41460000。这是个能被精确表示的数没有误差。再试一个负数-6.5。符号位 S 1。绝对值 6.5整数部分110小数 0.5 乘 2 得 1.0 取 1得.1合起来110.1。规格化1.101 × 2^2。阶码字段 2 127 129二进制10000001。尾数101补 20 个 0。拼起来1 10000001 10100000000000000000000四位一组1100 0000 1101 0000 0000 0000 0000 0000即0xC0D00000。最后啃硬骨头0.1。0.1 乘 2 得 0.2 取 00.2 乘 2 得 0.4 取 00.4 乘 2 得 0.8 取 00.8 乘 2 得 1.6 取 10.6 乘 2 得 1.2 取 10.2 乘 2 又回到 0.4……循环出现了循环节0011。所以 0.1 的二进制是0.0001100110011001100...规格化后1.10011001100110011001100... × 2^-4。真实指数 -4阶码字段 -4 127 123二进制01111011。尾数取 23 位10011001100110011001100但后面还有一位1第 24 位是 1后面不全为 0按就近舍入到偶数规则需要进位变成10011001100110011001101。拼起来0 01111011 10011001100110011001101四位一组0011 1101 1100 1100 1100 1100 1100 1101即0x3DCCCCCD。注意最后一位是 D 而不是 C这就是舍入进位上来的结果如果漏了这一步位模式就写成了0x3DCCCCCC两者相差一个 ulp。3.3 从十六进制位模式反解反过来解也按固定套路。拿到0x41460000先展开成 32 位二进制切出三段。符号位是 0正数。阶码10000010是 130减 127 得真实指数 3。尾数100011后补 0加上隐含位变成1.100011。把小数点右移 3 位得到1100.011转十进制 12 0.25 0.125 12.375。再看一个容易迷糊的0x3F800000。二进制展开0011 1111 1000 0000 ...符号 0阶码01111111 127真实指数 0尾数全 0隐含位补上就是1.000...等于 1。所以0x3F800000就是 1.0 的位模式。这个值在图形学和游戏开发里出现频率极高很多人的第一个魔数就是它。调试时手算位模式的价值在于你能立刻判断出一个可疑数值是哪一类问题。比如看到阶码字段全是 0就知道是非规格化数或者零看到阶码全 1 尾数全 0就是无穷看到 NaN就知道上游某处做了非法运算。这些都是靠肉眼看十六进制就能判断的比加日志快得多。3.4 用代码验证手算结果手算完之后一定要用代码验证一遍这是我养成的习惯。Python 的struct模块可以直接拿到位模式import struct def bits_of(value, fmtf): # f 单精度 4 字节d 双精度 8 字节 raw struct.pack( fmt, value) as_int int.from_bytes(raw, big) width len(raw) * 8 return format(as_int, 0{}b.format(width)), hex(as_int) for v in [12.375, -6.5, 0.1, 1.0]: b, h bits_of(v) s, e, m b[0], b[1:9], b[9:] exp int(e, 2) - 127 print(v, h, S s, E e, exp str(exp), M m)跑出来12.375是0x41460000、-6.5是0xc0d00000、0.1是0x3dcccccd、1.0是0x3f800000和手算完全对上。这一步看着多余其实是建立信心的关键——手算三次对两次、错一次你就不知道自己的理解到底哪里偏了跑一遍代码错的地方立刻暴露。C 语言里可以用联合体或者memcpy看位模式注意不要用指针强转那是未定义行为#include stdio.h #include string.h #include stdint.h int main(void) { float f 0.1f; uint32_t u; memcpy(u, f, sizeof u); /* 合法的类型双关写法 */ printf(%08x\n, u); /* 输出 3dcccccd */ return 0; }这两种写法我都常用。Python 适合快速验算C 适合在嵌入式或性能敏感的场合直接看内存。Java 可以用Float.floatToIntBits和Float.intBitsToFloat思路一样。4. 特殊值零、非规格化、无穷与 NaN4.1 为什么 0 和 -0 是两个不同的位模式因为隐含位的存在1.xxx × 2^n这个形式表示不出 0所以标准把阶码字段全 0、尾数字段全 0专门定义成零。又因为符号位是独立的于是产生了0位模式全 0和-0仅符号位为 1两个不同的编码。这两个零在数值比较上是相等的0 -0成立但它们的位模式不同用位比较或者哈希的时候会露馅。-0主要来自几个场景负数下溢、-1 * 0、某些舍入路径。它在数学上没什么问题但在做序列化、缓存键、去重的时候会带来麻烦。我遇到过一次线上问题一批缓存键里混进了-0.0和0.0哈希值不同导致缓存命中率异常排查了半天才发现是上游某个乘法算出了-0。处理办法很简单在生成键之前做一次归一化if (x 0) x 0;把-0强制转成0。这行代码看起来莫名其妙但确实有效。4.2 非规格化数就是为了渐进下溢假设没有非规格化数那么当数值小到比最小规格化数单精度约1.18×10^-38还小的时候只能直接变成 0。这会造成一个很讨厌的现象两个都很小的数相减结果从1.2×10^-38一步跳到 0中间是空的。在数值计算里这种突然归零会让误差突然放大甚至导致除以零。非规格化数的设计就是为了填上这段空白。规则是阶码字段全 0 时隐含位不再是 1 而是 0真实指数固定为1 - 偏置单精度是 -126双精度是 -1022尾数字段直接作为小数部分。这样数值可以平滑地从最小规格化数一路衰减到最小非规格化数单精度约1.4×10^-45中间没有断层这就叫渐进下溢。代价是精度损失。非规格化数的有效位数会随着数值变小而减少因为前导的 0 越来越多实际能用的有效位就少了。而且部分硬件对非规格化数的运算支持是慢路径性能会掉一个档次——这在做音频处理、物理模拟这类容易出现极小值的场景里是个需要考虑的性能因素。4.3 无穷与 NaN 的判定规则阶码字段全 1 是另一类特殊编码。此时若尾数字段全 0表示无穷符号位决定正负若尾数字段非 0表示 NaNNot a Number。NaN 还分安静 NaN 和信号 NaN区别在尾数最高位一般业务代码碰不到了解有这回事就行。无穷的运算规则比较符合直觉无穷加有限数还是无穷无穷乘正数还是无穷无穷减无穷是 NaN无穷乘 0 是 NaN有限数除以 0 是无穷不是异常。NaN 的最大特点是任何与 NaN 的比较都返回假包括NaN NaN也是假。这一条必须记住因为它会让用相等判断来去重的逻辑静默失效。我一般用这几条规则做快速判断x ! x成立说明 x 是 NaNx - x不等于 0 也可能是 NaN 或无穷x 0 x * 2 x说明是正无穷。实际工程里更推荐用标准库提供的判断函数比如 C 的isnan、isinf、isfinitePython 的math.isnan、math.isfinite语义清晰也不会被编译器优化掉。提示打开激进的浮点优化选项时编译器可能假设不存在 NaN 和无穷从而把x ! x优化成常量假。这类 bug 极难排查遇到判断逻辑突然失效的情况先去看编译选项。5. 精度问题的根源与排查实录5.1 0.1 0.2 到底错在哪一环这个经典例子值得拆开看。前面算过0.1 的单精度表示是1.10011001100110011001101 × 2^-4最后一位是舍入上来的。0.2 同理二进制是 0.1 的两倍指数 -3尾数一样也是舍入过的。两个都被截断过的数相加结果的低位必然带着两个误差的累积。用双精度算0.1 0.2得到0.30000000000000004。为什么不是0.30000000000000003或者0.3000000000000001因为真正的 0.3 在二进制下也是无限循环的能表示的最接近 0.3 的双精度数是0.29999999999999998889776975374843...而两个近似值相加的结果恰好落在它上面一格。这是舍入误差的必然结果不是随机现象——同样的输入在任何符合 IEEE 754 的平台上都会得到同样的输出。值得强调的是误差来自十进制小数转二进制不来自加减法本身。IEEE 754 规定加减乘除和开方必须正确舍入也就是说给定两个已经存在的浮点数运算结果就是数学真值舍入到最近可表示数的结果这一步没有额外误差。误差在进入运算之前就已经存在了。这个认识很重要它能帮你判断问题出在数据录入、格式转换还是运算环节。5.2 浮点数比较的正确写法绝对不能用比较两个浮点数除非你能保证它们来自同一条计算路径、经历了完全相同的运算序列。正确的做法是比较差值的绝对值是否小于一个容差但容差怎么选有讲究。用固定容差比如1e-6的问题是对于很大的数1e-6可能远小于一个 ulp比较永远为真对于很小的数1e-6可能远大于数值本身比较永远为真。所以工程上更常用相对容差把差值除以两个数的量级做归一化。import math def close(a, b, rel1e-9, abs_tol1e-12): # 组合相对与绝对容差同时照顾特别大和特别接近 0 的情况 diff abs(a - b) if diff abs_tol: return True return diff rel * max(abs(a), abs(b))Python 3.5 之后标准库直接给了math.isclose参数就是rel_tol和abs_tol思路完全一致可以直接用。Java 没有内置的等价物通常用Math.abs(a - b) Math.max(Math.abs(a), Math.abs(b)) * EPS这种写法自己封一个。容差取多少取决于业务能容忍多大误差。坐标计算通常取1e-6到1e-9物理仿真取1e-12图形学里有时候干脆用像素级容差把两个点投到屏幕上比像素距离。定容差没有放之四海皆准的值这本身就是个业务决策最好在代码里写成具名常量并加注释说明来源。5.3 金额与大整数最容易翻车的地方金额用浮点存是我见过最高频的翻车点。原因很简单金额是十进制语义而浮点是二进制语义两者天生对不齐。0.1 元、0.2 元这些在十进制里干净的数在二进制里全是无限循环。哪怕只是简单的累加几百笔之后误差就能积累到可见的程度。正确做法有三条路。第一条用最小货币单位的整数存比如分或者更小的单位运算全程用整数只在展示时除以 100 格式化。这是最稳的性能也最好绝大多数业务系统都该这么干。第二条用十进制类型Java 的BigDecimal、C# 的decimal、Python 的decimal.Decimal它们内部用十进制存储能精确表示 0.1。第三条用定点整数加约定小数位适合嵌入式或数据库字段受限的场景。用BigDecimal有个必须记住的坑构造时一定要传字符串不能传 double。// 错误写法0.1 传进来之前就已经是近似值了 new BigDecimal(0.1); // 0.1000000000000000055511151231257827... // 正确写法 new BigDecimal(0.1); // 精确的 0.1这个坑的隐蔽之处在于new BigDecimal(0.1).toString()打出来确实是0.1因为toString做了最短表示但内部实际存的是那一长串。用它去做精确比较、乘法、累加误差会慢慢显形。我在代码审查里见过不止一次这种写法每次都要专门提出来。大整数也有类似的陷阱。双精度能精确表示的整数上限是 2^53约 9.007×10^15。超过这个范围相邻两个整数可能映射到同一个浮点值x 1 x就会成立。用雪花算法生成的 64 位 ID、纳秒时间戳、某些加密场景的计数器都有可能超过这条线。跨语言传输时尤其危险——如果中间经过了 JSON 解析、JavaScript 数值处理高位的 ID 会在你不知情的时候被改掉。我以前处理过一个前端传回来的 ID 和后端不一致的 bug最后定位到就是这类精度截断。5.4 常见问题速查表把踩过的坑整理成一张表遇到症状先对照着查症状可能原因排查方法处理方式两个相同的数比较不相等十进制转二进制时的舍入或运算路径不同打印 17 位有效数字观察低位差异改用相对容差比较累加结果与期望值差一点每次加法的舍入误差累积用高精度类型重算一遍对照Kahan 求和或换高精度类型大数加小数没有变化小数小于该量级的一个 ulp计算x eps x是否成立换双精度、调整运算顺序、先累加小数金额出现 0.01 的偏差用浮点存金额并多次运算查所有金额字段类型改为整数分或十进制类型打印出来一堆 9999最短表示算法展示了近似值的真实形态对比%.17g与默认输出格式化时限定小数位排序结果不符合预期NaN 参与比较比较返回假检查数据里是否有 NaN 或无穷排序前过滤或替换缓存键命中率异常-0.0与0.0位模式不同打印哈希前的原始位模式生成键前归一化零值跨语言结果不一致中间层按单精度处理或做了截断逐层打印位模式统一精度明确序列化格式注意排查浮点问题时日志打印的位数很关键。默认的toString往往只打最短可回读表示看起来很正常。要定位问题必须打到 17 位有效数字或者直接打十六进制位模式。6. 工程实践中的选型与避坑清单6.1 float、double、定点、Decimal 怎么选选型的判断标准不是精度越高越好而是精度够用、成本可接受、语义正确。我一般按这几个维度走数据本身是不是十进制语义、范围跨度有多大、运算量大不大、是否需要跨语言交换。金额、税率、折扣这类十进制语义的数据一律用整数最小单位或者十进制类型不要碰浮点。科学计算、物理仿真、统计计算默认双精度因为累积运算对误差敏感而且现在 64 位平台的双精度运算并不比单精度慢多少。图形渲染、机器学习推理、大批量向量运算用单精度甚至半精度因为吞吐量和内存带宽是瓶颈7 位有效数字对这种场景足够。坐标、时间间隔、传感器读数这类看业务精度要求一般双精度比较保险。数据类型推荐类型理由典型场景金额、账目整数最小单位 / Decimal十进制语义必须精确交易、结算、报表坐标、尺寸双精度累积运算多范围跨度大地图、几何、仿真模型权重、像素单精度 / 半精度吞吐优先精度够用推理、渲染、图像处理大整数 ID字符串 / 整数超过 2^53 会失真分布式 ID、时间戳比例、概率双精度需要较宽动态范围统计、风控评分这张表不是教条。真正做决定的时候我会先问一句这个数如果差了 1e-9业务会不会出事会就往精确里走不会就往性能里走。这个问法比纠结类型本身有效得多。6.2 累加误差与 Kahan 求和有一类问题特别隐蔽单次运算的误差小到可以忽略但几百万次累加之后误差变成肉眼可见。最典型的场景是求和——如果一个大数先加进去后面再加一堆小数那些小数可能全部被吃掉。举个具体例子。用单精度把 0.01 累加一万次理论结果 100实际可能是 99.99 附近误差接近 0.01。原因是一旦当前和涨到 100 左右ulp 大约是 7.6×10^-6每次加 0.01 勉强还能体现但舍入误差会一点点累积。如果累加次数到一千万次误差会更明显。应对办法有三个层次。最简单的是换双精度误差直接小几个数量级成本几乎为零能用就用。其次是调整求和顺序把数值按绝对值从小到大排序后再加让小数先自己攒成大数减少被大数吞掉的概率。最讲究的是Kahan 求和用一个补偿变量记录每次加法丢掉的那部分下一次加回去def kahan_sum(values): total 0.0 comp 0.0 # 补偿项记录丢掉的低位 for v in values: y v - comp t total y comp (t - total) - y total t return total这段代码只有五行但能把累加误差从 O(n) 降到基本与 n 无关。它的原理是利用浮点减法提取出加法时被舍入掉的部分再在下一次迭代补偿回去。代价是每次迭代多了三次运算在需要极高精度的累加场景比如统计大量小数值的总和值得用。Neumaier 变体还额外处理了新加的数比当前和大的情况更稳妥。6.3 序列化、打印与跨语言传输浮点数一旦离开内存进入文件、网络、数据库就多了一层风险。文本序列化时如果精度位数不够读回来就不是原来的数如果位数太多又会带出一堆看着像噪声的尾数。我的习惯是二进制场景按位模式传用 4 字节或 8 字节的原始位跨语言完全无损只要双方约定好字节序。文本场景下输出用能往返的最短表示现代语言的默认toString基本都实现了这一点需要人读的地方再单独格式化到业务需要的小数位。这两件事要分开不要用同一个函数既做序列化又做展示。跨语言传输还有几个高频坑。JSON 的数字是双精度语义超过 2^53 的整数会在某些解析器里被改掉所以大整数 ID 应该序列化成字符串。某些语言或框架在反序列化时会把所有数字先当双精度再转即使原字段是单精度也会引入一次多余的舍入。还有些老系统用 ASCII 文本存浮点格式化时只保留 6 位小数精度直接损失一大截。数据库字段也要留意。很多数据库的FLOAT是单精度DOUBLE或DOUBLE PRECISION是双精度选错了字段类型前面的努力全白费。金额字段更要注意某些数据库的DECIMAL默认精度是 10 位存大额金额加多位小数可能溢出建表时显式写清楚精度和标度。6.4 我自己攒下来的一份检查清单最后把这几年积累的检查项列出来写代码和做代码审查的时候过一遍能挡掉大部分问题。金额、税率、折扣字段确认类型是整数最小单位或十进制类型不是float/double。所有浮点相等比较确认改成了容差比较容差有明确来源和注释。需要精确表示整数的字段确认值域上限小于 2^53或者已经改成字符串传输。累加求和的地方如果次数可能上万确认用的是双精度或 Kahan 求和。生成缓存键、去重键的地方确认对-0.0、NaN做了归一化或过滤。日志打印浮点数的地方排查阶段打到 17 位有效数字或十六进制位模式。跨语言接口的数值字段确认双方精度约定一致序列化格式明确。编译选项里如果有激进的浮点优化确认代码里没有依赖NaN判断或依赖结合律的写法。从十进制字符串转浮点的位置确认用的是字符串构造而不是先转double再构造。展示层格式化单独实现不与序列化共用同一个函数。这份清单不長但每一条都是踩过之后补上的。浮点数这个东西的特点就是平时不声不响出问题的时候又特别隐蔽往往要一路追到最低位才能看清。把表示方式搞清楚把这几条防线立起来绝大多数坑都能提前绕过去。我现在写涉及小数的代码第一反应已经不是能不能算对而是这个数从哪来、经过哪些环节、在哪一步开始不准——这个思维习惯本身比记住任何一条具体规则都管用。
企业数字化 ERP 产品动态
相关推荐
Univer开源Office引擎:可编程的表格与文档能力底座 1. 项目概述:Univer 是什么,它解决的到底是什么问题?Univer 这个名字最近在前端技术圈里出现得越来越频繁,尤其在搜索“univer”“spreadsheets”“documents”“presentations”这些词时,几乎都会被导向同一个开源项目… · 2026/11/3 12:32:55
测试数据资产化:从成本中心到利润引擎的转型实践 1. 项目背景与行业痛点 在传统企业架构中,测试部门往往被视为成本中心。每次项目预算会议上,测试团队总是面临人力压缩、设备削减的困境。我在某跨国企业担任质量总监时,曾经历过一次令人窒息的对话:"你们测试部去年花了800万… · 2026/11/3 12:32:55
Firefox标签页深度定制指南:从视觉布局到行为逻辑的完整改造 1. 为什么值得花时间折腾标签页 浏览器每天陪我们十几个小时,标签页是其中出现频率最高的界面元素。默认状态下,Firefox 的标签栏高度、字体、关闭按钮位置、新标签页内容都是官方团队按"大多数人的平均水平"定的,可一旦你同时开二… · 2026/11/3 12:31:55
单畴IPS液晶显示技术与TechWiz LCD 2D仿真实践 1. 单畴IPS液晶显示技术概述单畴IPS(In-Plane Switching)液晶显示技术是当前高端显示面板的主流方案之一。与传统的TN(Twisted Nematic)模式相比,IPS液晶分子在电场作用下始终保持平行于基板平面旋转,这使得… · 2026/11/3 13:22:05
1553B总线入门:命令字、消息调度与BC/RT联调 简介:《1553B总线入门教程》PPT面向航空航天、导弹、无人机及装甲车辆等领域的总线通信初学者与工程技术人员,系统梳理GJB289A/1553B协议的基础概念、标准来源与工程应用要点。压缩包内仅含1个pptx文件,大小约5.26MB,以图文幻灯片… · 2026/11/3 13:22:05
ISO 15118-20 入门:车桩 V2G 通信协议与报文解析 简介:这份ISO 15118-20:2022英文原版标准面向电动汽车充电通信协议开发、车网互动(V2G)研究与测试认证人员,聚焦第二代网络层与应用层要求,为不同品牌车辆与充电设施之间的互操作、安全通信和智能电网调度提供统一技术… · 2026/11/3 13:22:05
ISO 15118-20充电通信入门:分层、SDP发现与TLS证书实战 简介:2022年发布的ISO 15118-20国际标准正式文本,聚焦道路车辆与电网之间通信接口的第二代网络层与应用层要求,面向电动汽车充电协议开发、车桩互联互通测试及智能电网研究领域的中高级技术人员,帮助解决不同品牌车型与充电设施之… · 2026/11/3 13:22:05
PPT制作全攻略:从学术到商业的演示设计 1. 项目概述"从学生到CEO"这个标题精准捕捉了不同职业发展阶段人群的核心痛点——如何快速生成符合场景需求的演讲PPT。作为一位经历过学术汇报、职场晋升到商业路演的过来人,我深刻理解每类场景对演示文档的差异化要求。学生党需要严谨的学术框架&#x… · 2026/11/3 13:22:05
Anaconda3 安装与首配实战:PATH、虚拟环境与依赖验证避坑指南 简介:面向Python数据科学初学者、高校学生与自学者,这份Anaconda安装指南围绕三大主流操作系统,梳理从官网获取安装包到完成基础环境配置的关键环节。压缩包内仅含1个docx文件,大小约14KB,篇幅轻量,便于本地… · 2026/11/3 13:21:04
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46