数据精度问题自查手册

前言

在数据敏感的业务场景中,经常会碰到数据精度问题,尤为在金额显示、占比统计等地方,该问题尤其显著。因为数据的每一位有效数字都包含真实的业务语义,一点点误差甚至可能影响业务决策,这让问题的严重性上升了几个阶梯。javascript

那,什么是精度丢失?

一言以概之,凡是在运行过程当中,致使数值存在不可逆转换时,就是精度丢失。前端

诸如:java

  • 人均交易额、占比这类计算得出的除法得到的指标(分子/分母)时,若是盲目的直接从该结果去推算分子数值时,极可能就存在精度丢失
  • 浮点数计算结果,会出现很长尾的小数

这两种广义上来讲都是精度丢失,但第一种状况能够经过更改技术方案等方式进行规避。更多时候,所谓的精度问题,单指第二类问题。而面对这类问题时,若是没有掌握原理,每每会只知其一;不知其二,对结论印象不深,再次碰到问题只能一查再查。git

计算机原理真香

数值的精度问题,实际上是很是基础的计算机原理知识。一般,js的系统知识书籍(基础类型章节)通常也会提到,但像我这样的非科班前端开发,每每在这方面的知识储备很是薄弱;并且,即便学习过了,也会由于第一次学习时没体感,没有实际场景去强化认知,掌握的也不深入。github

因此,在后续的业务开发中,有必要从新整理下遇到的问题,从遇到的问题出发,追根溯源,才能更深入地掌握知识点。面试

真实的Number

(本章节为基础的规范介绍,有助于加深认知,非必要知识,尤为是存储形式,大部分问题的解答只需有概念便可。)算法

有别于其余语言会出现各种int、uint、float,JS语言只有一种数值类型——Number,它的背后是标准的双精度浮点数实现(其余语言通常称该类型为double或float64),这也就意味着,前端全部出现的数值,其实背后都是小数。bash

看一下双精度浮点数的内存模型(这幅维基百科的示意图真是每篇精度文章都会引用~):
post


总共64位,分红了三部分:符号(sign)、指数(exponent)、尾数(fraction)。即,最终每个数值均可以表示成: (-1)^S * 2^E * M

存储形式

这篇文章介绍了一个很是简单的转换方式,拿一个数值实际体验一下过程,例如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为非规约形式,有特殊意义(详见wiki,不作展开了),那剩余的范围是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)的如何存储呢?我没有太深刻思考,原理上应该也是经过舍入规则去理解,不过仍是不展开了,留个坑位~

题外话:
不少面试题里都包含了大整数的考点。考的是两处,第一点是,是否意识到了面试题中存在大整数问题;第二点是,如何用程序模拟手算过程。

不过我比较好奇的是,假如面试者使用了BigInt来完成大整数的四则运算(跳过第二个考点)是否是也算合格?【笑

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在wiki中,有不少例子来证实这个结果是没法预期的。

选取一种:

0 * X = 0,那 0/0 = X。也就是0/0能够是任意值,这个结果是没法成立的。

能精确表示的十进制有效位数

通常来讲,double类型的有效位数,结论是16位。不过,目前我还没看到很是严谨的说明过程,现有的解释方式略做搬运:

  1. MAX_SAFE_INTEGER是9007199254740991,它的位数就是16
  2. EPSILON它能精确到小数点后15位,再加上整数位,因此,有效位数是16

为何不推荐使用位运算

lint规则中通常是不建议在JS代码中使用位运算的。

第一点是,不便于维护,考虑到前端开发广泛对位运算不感冒;
第二点是,如两次取反(~~3.11)、或0(3.11 | 0)这种取整操做,其背后,其实是将64位的双精度浮点数转成了32位整数。若是对此没有明确的认知,能确保程序运行时的入参一定是32位整数范围内的话,就很容易埋坑,不如老老实实的使用Math.floorMath.round

const n = 2**32 + 0.1 // 4294967296.1

~~n // 指望是2^32,但其实结果是0

Math.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”,仍是一个很是近似的小数?若是是精确的,丢失精度的小数是如何转为精确的整数的呢?

浮点数的乘法有特别算法(Booth算法)能够细讲的,不过在此也不作具体展开。

基本原理上来讲,就是将乘法简化为“移位 + 加减法”。在本例中,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.0049999999999998934
n = 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.1
Math.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'
复制代码

要解决这类问题,通常须要转整数计算,不只能够保证精度,也能输出符合业务预期的位数。这也是绝大部分轻量库的方案,基本原理是:

  1. 求出入参的最大位数
  2. 转为整数计算
  3. 最后输出结果时再除去最大位数

固然,这种方案的缺陷是,过程当中通常没法顾及超出范围的大数。

类库

一步步了解了各类场景下出现的问题,这时候再去选择类库,就有底气的多,毕竟对于各类问题的解决已初步具有思路,不会只停留在知其然而不知其因此然的境界。而使用成熟类库的好处是,它考虑的边界条件更多、逻辑更完备,运行时的稳定性更高。

我列举几个类库,不过使用不深,就请自行查阅啦~






参考

相关文章
相关标签/搜索