前言
在数据敏感的业务场景中,常常会碰到数据精度问题,尤其在金额显示、占比统计等地方,该问题尤为显著。由于数据的每一位有效数字都包含真实的业务语义,一点点偏差甚至可能影响业务决策,这让问题的严重性上升了几个阶梯。
那,什么是精度丢失?
一言以概之,凡是在运行过程中,导致数值存在不可逆转换时,就是精度丢失。
诸如:
- 人均交易额、占比这类计算得出的除法获得的指标(分子/分母)时,如果盲目的直接从该结果去推算分子数值时,很可能就存在精度丢失
- 浮点数计算结果,会出现很长尾的小数
这两种广义上来说都是精度丢失,但第一种情况可以通过更改技术方案等方式进行规避。更多时候,所谓的精度问题,单指第二类问题。而面对这类问题时,如果没有掌握原理,往往会一知半解,对结论印象不深,再次碰到问题只能一查再查。
计算机原理真香
数值的精度问题,其实是非常基础的计算机原理知识。通常,js的系统知识书籍(基础类型章节)一般也会提到,但像我这样的非科班前端开发,往往在这方面的知识储备非常薄弱;而且,即使学习过了,也会因为第一次学习时没体感,没有实际场景去强化认知,掌握的也不深刻。
所以,在后续的业务开发中,有必要重新整理下遇到的问题,从遇到的问题出发,追根溯源,才能更深刻地掌握知识点。
真实的Number
(本章节为基础的规范介绍,有助于加深认知,非必要知识,尤其是存储形式,大部分问题的解答只需有概念即可。)
有别于其他语言会出现各类int、uint、float,JS语言只有一种数值类型——Number,它的背后是标准的双精度浮点数实现(其他语言一般称该类型为double或float64),这也就意味着,前端所有出现的数值,其实背后都是小数。
看一下双精度浮点数的内存模型(这幅维基百科的示意图真是每篇精度文章都会引用~):
总共64位,分成了三部分:符号(sign)、指数(exponent)、尾数(fraction)。即,最终每一个数值都可以表示成: 。存储形式
这篇介绍了一个非常简单的转换方式,拿一个数值实际体验一下过程,例如34.1:
第一步,取整数部分——34,通过除2取余数:
计算过程 | 结果 | 余数 |
---|---|---|
34/2 | 17 | 0 |
17/2 | 8 | 1 |
8/2 | 4 | 0 |
4/2 | 2 | 0 |
2/2 | 1 | 0 |
1/2 | 0 | 1 |
第二步,取小数部分——0.1,通过乘2取整数。如果结果大于1,则取1,否则取0:
计算过程 | 结果 | 整数 |
---|---|---|
0.1*2 | 0.2 | 0 |
0.2*2 | 0.4 | 0 |
0.4*2 | 0.8 | 0 |
0.8*2 | 1.6 | 1 |
0.6*2 | 1.2 | 1 |
0.2*2 | 0.4 | 0 |
... | ... | ... |
第三步,拼接结果,整数部分结果是从下往上取,小数部分则是从上往下取。结果为:(34.1)10 = (100010.0_0011_0011_0011...)2。
ps:为了阅读清晰,使用下划线分隔符~该特性将在Chrome75到来,诸如Rust已经具备
第四步,转换为科学计数法(二进制版),(34.1)10 = 1.00010_0_0011_0011... * 2(5)10 。到此,已经可以获取到公式中各个值所对应的结果了:
- S = 0
- E = (5 + 1023)10 = (100_0000_0100)2
- M = (00010_0_0011_0011...)2
最终的34.1的内存存储为:0 100_0000_0100 00010_0_0011_0011_0011_0011_0011_0011_0011_0011_0011_00 11_0011_01。(我反正是瞎了)
对于这个结果,还需要几点补充说明:
为什么指数E的结果需要+1023?
指数部分有11位bit。使用无符号表示,可以表示范围0~2047,其中0和2047为非规约形式,有特殊意义(详见,不做展开了),那剩余的范围是1~2046;如果使用带符号表示,可以表示范围-1024~1023。因为实际指数是可以存在负值的,为了避免使用符号表示法,就加入了这个偏移量。
至于,为什么不使用符号?我没什么太深刻的体感。不过可以肯定的是,目的一定是为了后续的计算处理方便。比如:如果无符号,可以直接比较大小?
为什么尾数M的结果省略了整数部分?
这是因为,既然数值一定可以表示成科学计数法,那尾数M的整数部分必然是1。
为什么?如果实在想不明白,可以参考十进制的科学计数法,整数部分一定是1~9,因为一旦超过9,就会归入指数,即,整数部分为1~【进制-1】。那在二进制的科学计数法中,整数部分为1~1,则必然是1。
此外,这里还有另一点好处,通过省略整数部分,这个“1”就不需要占用存储了,相对的,小数部分可以多一位有效数字。
如何表示无限循环的尾数部分?
正如上例中的34.1,它的尾数部分就是无限循环,如果超出了存储位数,则势必要进行舍入。
实际上,存在多种舍入规则:
- 舍入到最接近
- 朝+∞方向舍入
- 朝-∞方向舍入
- 朝0方向舍入
也不做展开了,具体可以继续查阅wiki。默认理解下,“0舍1入”的规则够用了。
举一反三
Number.MAX_SAFE_INTEGER
Number类上的一个静态属性,值为9007199254740991。这个数是怎么来的呢?
因为Number的尾数有53位,理论上能表示的、精确的最大整数即为2-1,这也正是MAX_SAFE_INTEGER。超过这个值的数值,因为有效数字有限,Number已经无法精确表示了。
然而指数部分最大值是1023,所以理论上Number能表示的最大值应该至少达到2才对,那这个区间(2~2)的如何存储呢?我没有太深入思考,原理上应该也是通过舍入规则去理解,不过还是不展开了,留个坑位~
题外话:
很多面试题里都包含了大整数的考点。考的是两处,第一点是,是否意识到了面试题中存在大整数问题;第二点是,如何用程序模拟手算过程。不过我比较好奇的是,假如面试者使用了来完成大整数的四则运算(跳过第二个考点)是不是也算合格?【笑
Number.EPSILON
同样是Number类上的一个静态属性,值为2.220446049250313e-16。这个数又是怎么来的?
同样和尾数相关,理论上能表示的最小尾数是1.00000000_00000000_00000000_00000000_00000000_00000000_0001,也就是EPSILON。
1/0和0/0的不同结果
使用浮点数的语言,不仅仅是JS,对于这两个结果返回都是Infinity和NaN,1/0比较好理解,0/0在中,有很多例子来证明这个结果是无法预期的。
选取一种:
0 * X = 0,那 0/0 = X。也就是0/0可以是任意值,这个结果是无法成立的。
能精确表示的十进制有效位数
一般来说,double类型的有效位数,结论是16位。不过,目前我还没看到非常严谨的说明过程,现有的解释方式略作搬运:
- MAX_SAFE_INTEGER是9007199254740991,它的位数就是16
- EPSILON它能精确到小数点后15位,再加上整数位,所以,有效位数是16
为什么不推荐使用位运算
lint规则中一般是不建议在JS代码中使用位运算的。
第一点是,不便于维护,考虑到前端开发普遍对位运算不感冒;
第二点是,如两次取反(~~3.11)、或0(3.11 | 0)这种取整操作,其背后,实际上是将64位的双精度浮点数转成了32位整数。如果对此没有明确的认知,能确保程序运行时的入参必定是32位整数范围内的话,就很容易埋坑,不如老老实实的使用Math.floor
或Math.round
。 const n = 2**32 + 0.1 // 4294967296.1~~n // 期望是2^32,但其实结果是0Math.floor(n) // 符合预期复制代码
Number的计算
明白了真实的Number,很容易就理解了——由于一个小数无法用二进制精准表示,势必存在精度丢失,也就很自然地会出现诸如经典的“0.1+0.2 ≠ 0.3”问题。但与此同时,我产生了一个疑问,两个精度丢失的纯小数是否能得出一个精准表示的数值?
(由于双精度浮点数实在位数太多了。。。写得累,下面都使用单精度浮点数表意,双精度的情况可以同理类推。)
严格来说,浮点数计算需要经过:对阶、尾数求和、规约化、舍入、溢出判断(详细内容,可以参阅)。如果严格按照步骤进行,有些过于死板,而且其中有更多的概念需要消化,这里仅仅是为了加深体感,所以使用更“小学”的方式来解决这个问题。
在进行具体计算前,需要先掌握:
- 如何将十进制转为二进制,上一章介绍过了
- 有效数字位数,单精度浮点数尾数部分为23位,相应的,能表示的有效位数为24位(为什么?),上一章也介绍过了
- 手算加法
0.1 + 0.4
将0.1和0.4转为二进制(不需要转为科学计数法,即可跳过对阶步骤),结果是:
- 0.1 = 0.0_0011_0011_0011_0011_0011_0011_01,保留24位有效数字,根据“0舍1入”进位
- 0.4 = 0.0_1100_1100_1100_1100_1100_1101,保留24位有效数字,根据“0舍1入”进位
可以看到,0.1和0.4都是存在进位的,它的存储值比真实值都要大,那两个比真实值大的数的是如何恰好相加得出0.5的呢?
核心关键点,其实在于这个**“有效位数”**,我们手算一下,把这两个值直接相加,现在位数已经对齐了:
0.0_0011_0011_0011_0011_0011_0011_01+ 0.0_1100_1100_1100_1100_1100_1101----------------------------------------------- 0.1_0000_0000_0000_0000_0000_0000_(01)复制代码
0.1就是0.5,实在是太巧了!误差正好被排除在有效位数之外!也就是,两个丢失精度的数值计算后恰好精度复原了。
好奇心如我,觉得这里应该是可以用数学方式去证明,无整数部分的小数计算,误差一定会控制在相对小的范围之内的。否则,如果按照常规理解,随着计算进行,误差会无休止的膨胀下去。
当然,这种证明过程肯定很专业,估计真展示在我面前,我也看不懂。我等普通吃瓜开发,还是只管喊666就成了~
0.1 * 10
掌握了加减法,就自然会对乘法产生新的疑惑(主要是解决精度问题中很常见的办法是转为整数)。既然,0.1是无法精确表示的,而1和10作为整数又是可以精确表示的,那这里的结果“1”是精确的“1”,还是一个非常近似的小数?如果是精确的,丢失精度的小数是如何转为精确的整数的呢?
浮点数的乘法有特别算法()可以细讲的,不过在此也不做具体展开。
基本原理上来说,就是将乘法简化为“移位 + 加减法”。在本例中,10可以拆为2 + 2,继续手算:
0.1 * 10 = 0.1 * 2^3 + 0.1 * 2 0.1100_1100_1100_1100_1100_1101+ 0.0011_0011_0011_0011_0011_0011_01--------------------------------------------------- 1.0000_0000_0000_0000_0000_0000_(01)复制代码
是不是又一次感慨世界的奇妙?和上一例结果一样,误差再一次被命运排除在有效位数之外,amazing~~
不过,需要注意的是,这两个示例都限定在了无整数部分的小数计算(也可能是整数部分需要满足什么条件才可以)。如果整数部分存在有效数字,会不同程度的挤压小数部分可用的尾数有效位数,就有可能导致无法出现这些神奇结果了。
/10 和 *0.1 的区别
这个区别可以简单的进行求证。只需提高结果的精度表示,就可以看到差异:
(6 / 10).toPrecision(17) // "0.59999999999999998"(6 * 0.1).toPrecision(17) // "0.60000000000000009"复制代码
究其原因,0.1是无法精确表示的,而10是可以精确表示的,所以和一个可以准确表示的数进行计算,势必精度会高于和无法准确表示的数进行计算。
这就是典型的误差累计,当结果是无法精确表示的时候,之前那神奇的误差清除似乎就没那么灵验了。所以,如果有必要,计算过程中,可以有意识的尽量使用整数。
解决方式
toFixed
这是最基础的解法。不过需要注意的是,当尾数是5的时候,它的结果往往不符合预期。
这篇里,举了个例子:
(1.005).toFixed(2) // 结果是1.00,而不是1.01// 文中给出的解释是将该数值进行更高精度展示,确实该数值的四舍五入确实是1.00(1.005).toPrecision(17) // '1.0049999999999999'复制代码
然而,评论中,被人锤了:
(1.105).toPrecision(17) // '1.1050000000000000'(1.105).toFixed(2) // 结果是1.10复制代码
这是为什么?
思路上没有问题,只是,精度还不够。如果我们按照理解toFixed
,那核心在于这一步骤:
Let n be an integer for which the exact mathematical value of n ÷ 10 – x is as close to zero as possible. If there are two such n, pick the larger n.
套用在这个例子中就是:
n / 100 - 1.105 // n为整数,尽可能让结果趋于0,最终计算误差取17位精度n = 110, // -0.0049999999999998934n = 105, // 0.0050000000000001155复制代码
确实n = 110时,结果更接近0,也就是toFixed的结果是1.10。
当然,使用取高精度方式去求解也未尝不可,只是,实际规范过程中,可以注意到,这一步计算会把整数部分以及小数点后的n(toFixed参数)位全部归0,所以如果需要正确的观测当前值,需要toPrecision(17 + n),也就是:
(1.105).toPrecision(19) // 1.104999999999999982// 也就可以正确推出toFixed(2)的结果是1.10了复制代码
Math.round
这里补充一点,一般场景中,如果想获取四舍五入的整数,往往会使用Math.round
。但需要注意,这里依然有不符合预期的结果:
Math.round(1.005 * 100) / 100 // 结果是1,而不是期望的1.1Math.round(-0.5) // 结果是0,而不是期望的-1复制代码
第一例的问题其实是1.005无法转为精确的整数导致的:1.005 * 1000 = 1004.9999999999999。所以只需要额外的多进行一次转换即可。
第二例的问题其实是符合规范的,Math.round
的结果是取更靠近+∞方向,而不是常规理解的远离0,所以碰到负数,更保险的做法应该是使用绝对值再加符号位。
toPrecision
上文提过双精度浮点数能精确表示的位数是16位。如果toFixed使用时没有注意整数部分,也会导致预期之外的错误:
(1234123412341234.3).toFixed(2) // 1234123412341234.25复制代码
既然toFixed有种种问题,而Number本身能达到的精度是16位,那其实,数值运算后的最终结果只要进行Number.parseFloat(num.toPrecision(16))
处理即可。
转整数计算
toPrecision可以避免绝大部分的小数点位数过长的问题。但,这可能导致结果和业务输入的位数不一致,例如:
add(0.11, 0.19) => '0.30'add(0.11, 0.100) => '0.210'复制代码
要解决这类问题,一般需要转整数计算,不仅可以保证精度,也能输出符合业务预期的位数。这也是绝大部分轻量库的方案,基本原理是:
- 求出入参的最大位数
- 转为整数计算
- 最后输出结果时再除去最大位数
当然,这种方案的缺陷是,过程中一般无法顾及超出范围的大数。
类库
一步步了解了各种场景下出现的问题,这时候再去选择类库,就有底气的多,毕竟对于各种问题的解决已初步具备思路,不会只停留在知其然而不知其所以然的境界。而使用成熟类库的好处是,它考虑的边界条件更多、逻辑更完备,运行时的稳定性更高。
我列举几个类库,不过使用不深,就请自行查阅啦~
- (同一位大师)
- (我不知道这位大师的三个库具体区别是什么。。。)
- ,轻量级方案