从标准原理出发理解 JavaScript 数值精度

从标准原理出发理解 JavaScript 数值精度学过前端的开发人员在项目开发的时候,都会遇到 0.1+0.2!=0.3 的诡异问题。按照常规的逻辑来思考,这肯定是不符合我们的数学规范。那么JavaScript中为啥会出现这种基本运算错误呢,其中的原理又是什么。这篇文章将从原理给大家梳理此问题的缘由 在进入原理解析之前,笔者先…

学过前端的开发人员在项目开发的时候,都会遇到 0.1+0.2!=0.3 的诡异问题。按照常规的逻辑来思考,这肯定是不符合我们的数学规范。那么JavaScript中为啥会出现这种基本运算错误呢,其中的原理又是什么。这篇文章将从原理给大家梳理此问题的缘由

JavaScript数值问题

在进入原理解析之前,笔者先抛出三个基本问题,大家可以先思考一下。

问题一:

JavaScript规范中的数量值如何计算,出现NaN的原因,以及NaN的数量值

The Number type has exactly 18437736874454810627 values…(为什么是这个数)

问题二:

Number.MAX_SAFE_INTEGER === 9007199254740991 //为什么是这个数

Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2  //true

问题三:

0.1 + 0.2 != 0.3 //原因是什么?

计算机中的二进制

接下来进入正文,学过计算机基础的人都知道,计算机底层是通过二进制来进行数据之间的交互的。其中我们应该要明白为什么计算机通过二进制来进行数据交互,以及二进制是什么

1. 计算机为什么要通过二进制来进行数据交互?

在我们日常使用的电子计算机中,数字电路组成了我们计算机物理基础构成,这些数字电路可以看成是一个个门电路集合组成,门电路的理论基础是逻辑运算。那么当我们的计算机的电路通电工作,每个输出端就有了电压。电压的高低通过模数转换即转换成了二进制:高电平是由1表示,低电平由0表示。

说得简单点,就是计算机的基本运行是由电路支持的,电路容易识别高低电压,即电路只要能识别低、高就可以表示“0”和“1”。

2. 二进制是什么

二进制就跟我们的十进制一样,十进制是逢十进一,二进制就是逢二进一。

比如001如果增加1的话,在十进制中就是002,在二进制中则变成了010,因为002的2需要进一位。

那么我们平常在计算机中的计算都是十进制的,所以计算机在处理我们的运算的时候,会把十进制的数字转化为二进制的数字之后,再进行二进制加法,得到的结果转化为十进制,从而呈现在我们的屏幕中。这些转化都是通过计算机内部操作的,平常我们是看不到他们转化的过程。那么机智的你肯定就明白了0.1 + 0.2 != 0.3 这个问题,肯定跟十进制转二进制,然后二进制转回十进制的处理(精度丢失)有关系。

计算机的十进制运算

从上面可知,我们已经定位到了问题所在,不着急,我们先确定二进制转十进制、十进制转二进制怎么实现,才能分析精度丢失的原因。

十进制转二进制

十进制整数转二进制

示例:将十进制的21转换为二进制数。

方法:将整数除于2,反向取余数

21 / 2  =   10  -- 1 ⬆
10 / 2  =   5   -- 0 ⬆
5 / 2   =   2   -- 1 ⬆
2 / 2   =   1   -- 0 ⬆
1 / 2   =   0   -- 1

二进制(反取余数):10101

十进制小数转换为二进制

示例:将0.125换算为二进制

方法:将小数部分乘以2,然后取整数部分,至到小数部分为0截止。若小数部分一直都无法等于0,那么就采用取舍。如果后面一位是0,那么就舍去。如果后面为1,那么就进一。读数要从前面的整数读到后面的整数

0.125 * 2 = 0.25  -- 0 ⬇
0.25  * 2 = 0.5   -- 0 ⬇
0.5   * 2 = 1.0   -- 1

二进制:0.001

二进制转化为十进制

二进制转化为十进制,整数部分和小数部分的方法都是相同的。

示例:将二进制数101.101转换为十进制数

方法:将二进制每位上的数乘以权,然后相加之和即是十进制数

1*2^2 + 0*2^1 + 1*2^0 + 1*2^{-1} + 0*2^{-2} + 1*2^{-3} = 0.625

计算机中将十进制转化为二进制之后,进行了二进制的相加。

注意:在计算机的运算中,只有加法运算。如5 – 5会变成5 + (-5)

在二进制的运算中,为了防止运算不正确,以及最高位溢出问题。引入了原码、反码、补码等概念。由于篇幅有限,在这里就不展开对原码、反码、补码的概念,有兴趣的读者可以自行查阅资料。

JavaScript中的数值–浮点数IEEE 754

那么讲完基础内容,回归到我们的JavaScript中来。众所周知JavaScript仅有Number这个数值类型,而Number采用的是IEEE 754 64位双精度浮点数编码。所以在JavaScript中,所有的数值都是通过浮点数来表示,那么IEEE 754标准是怎么样的呢,在JavaScript中又是怎么约定Number值的。

IEEE 754的标准,个人理解就是通过科学计数法的方式控制小数点的位置,来表示不同的数值。

在wiki中,IEEE 754规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现),通常我们只会使用到单精确度(32位)、双精确度(64位)

单精确度(32位)表示

从标准原理出发理解 JavaScript 数值精度

双精确度(64位)表示

从标准原理出发理解 JavaScript 数值精度

从上面两张图,可以看出数值用IEEE 754标准表示时,被划分为三个区段,有sign、exponent以及fraction。而理解这三个区段是学习IEEE 754标准的重点所在。那么这三个区段分别表示什么呢?不急,我们先了解一下经过IEEE 754标准之后,我们的二进制的数值应该怎么表示,然后再来学习这三个定义。

在国际规定的IEEE 754的标准中,不管是32位单精确度,还是64位双精确度,任何一个二进制浮点数V都可以有如下图的表示,图源自于阮一峰老师博客

从标准原理出发理解 JavaScript 数值精度

其中:

  1. (-1)^s 表示符号位,当s=0,V为正数;当s=1,V为负数。
  2. M表示有效数字,大于等于1,小于2。
  3. 2^E中的E表示指数位。

举个例子,十进制的7转二进制就是111,就相当于1.11*2^2,那么此时s = 0,M = 1.11,E = 2;

如果十进制的-7转二进制就是-111,就相当于-1.11*2^2,那么此时s = 1,M = 1.11, E = 2;

其实,在公式中的s就相当于sign(符号位)判断数值正负,M就相当于fration(有效数字),E就相当于exponent(指数)。

在32位单精确度下,符号位sign是最高位,占一位大小,接着的8位是指数E,剩下的23位为有效数字M。

在64位单精确度下,符号位sign是最高位,占一位大小,接着的11位是指数E,剩下的52位为有效数字M。

那么我们接下来讨论,指数E以及有效数字M是怎么定义的。前面提及了有效数字M是大于等于1,小于2的。其实这很好理解,在我们的科学计数法中,有效数字开头通常都是1,即1.XXXX的形式,其中XXXX就是小数部分,那么在32位精确度中,有效数字M占了23位,那么是否XXXX只能占22位呢,其中1位留给整数部分1。聪明的标准制定者们为了使32位精确度能够表示更多的有效数字,决定整数部分的1不占有效数字M的一位。于是XXXX能够占23位,这样等到读取的时候,再把第一位的1加上去,那么就等于可以保存24位有效数字了。IEEE 754规定,在计算机内部保存M时,默认这个数的第一位总是1,因此可以被舍去,只保存后面的xxxxxx部分。 同样,64位精确度的M也相当于可以保存53位有效数字

那么指数E就比较复杂了,由于E是一个无符号的整数,那么在32位精确度中(E占8位),可以表示的取值范围为0 ~ 255,在64位精确度中可以表示的取值范围为0 ~ 2047。但是其实我们的科学计数法指数部分是可以出现负数的。那么如何使用E来表示负数呢,可以将E取一个中间值,左边的就为负指数,右边就为正指数了。于是IEEE 754就规定,E的真实值(即在exponent中表示的值)必须再减去一个中间数,32位精确度中的中间数是127,64位精确度中的中间数是1023;,看到加粗的字就可以明白,指数范围其实表示的是-127~128; 这样我们就可以在32位精确度中表示从

从标准原理出发理解 JavaScript 数值精度

例子:十进制的7转二进制就是111,就相当于1.11*2^2,此时E = 2,那么这时候的E其实已经减了中间值了,所以E的真实值为2 + 127 = 129,二进制为10000001;

同时指数E还可以根据规定分为三种情况讨论(以32位精确度作为讨论)

  1. E不全为0或不全为1 这个阶段就是正常的浮点数表示,通过计算E然后减去127即为指数

  2. E全为0 浮点数的指数E等于0-127 = -127,当指数为-127时,有效数字M不再加上第一位的1,而是还原为0.xxxxxx的小数。这样做是为了表示±0,以及接近于0的很小的数字

  3. E全为1 此时如果有效数字M全为0,那么就表示+∞或者-∞,取决于第一位符号位。但是如果有效数字M不全为0,则表示这不是一个数(NaN)

回到JavaScript

在上面的讨论中,我们很少提及JavaScript,似乎跟我们的文章主题不搭边,但是在了解了上述的原理之后,你将会对JavaScript中的数字的理解有质的飞跃。

接下来的内容将会带领大家一步一步解决上面提出的这些疑问:

1. JavaScript规范中的数值量,为什么是这个数?

首先需明白在JavaScript中的数字是64-bits的双精度,所以有2^64种可能性,在上述中提到,当E全为1的时候,表示的要么为无穷数,要么为NaN。所以不是数值的可能为2^53种,同时JavaScript中把+∞和-∞、NaN定义为数值。所以JavaScript数值的总量为

从标准原理出发理解 JavaScript 数值精度

同时我们也可以直接推算出JavaScript中NaN的具体数量有多少,因为上述中NaN的定义为在E全为1的情况下,如果有效数字M不全为0,则表示这不是一个数。即排除掉有效数字M全为0的情况就行(+∞、-∞)

从标准原理出发理解 JavaScript 数值精度

2. JavaScript中的最大安全整数值为什么为9007199254740991

上述提及,有效数字有53个(包括最前面一位的1.xxxx中的1),如果超出了小数点后面52位以外的话,就遵从二进制舍0进1的原则,那么这样的数字就不是一一对应的,会有误差,精度就丢失了。也就不是安全数了。所以JavaScript中的最大安全整数值为

从标准原理出发理解 JavaScript 数值精度

3. 0.1 + 0.2 != 0.3?

这个问题也许是大家最关心的问题,也是最经典的JavaScript面试问题。不过学习了上面的知识之后,大家已经明白了问题产生的原因(精度丢失),那么具体是如何丢失的呢?

首先,0.1 + 0.2 这个运算是十进制的加法,上述提及,计算机处理十进制的加法其实是先将十进制转化为二进制之后再运算处理。那么我们需要计算出0.1的二进制、0.2的二进制以及0.3的二进制来进行对比校验。

根据上述的计算方法,我们很容易得出0.1的二进制是无限循环的,即

0.1D = (-1)^0 * 1.1001..(1001循环13次)1010B * 2^-4
0.2D = (-1)^0 * 1.1001..(1001循环13次)1010B * 2^-3
0.3D = (-1)^0 * 1.0011..(0011循环13次)0011B * 2^-2

可以看出,当0.1,0.2转化为二进制的时候,有效数字都是52位(4 * 12 + 4),因为在64位精确度中,只能保持52位有效数字,如果没有52位有效数字的约束,其实在第53位中,0.1转二进制本来是1,但是有了52位约束之后,根据二进制的取舍 ,最后五位数就从1001 1(第53位) 变成了 1010。

我们可以手动计算一下0.1的二进制加上0.2的二进制

从标准原理出发理解 JavaScript 数值精度

那么相加结果转换为十进制其实等于0.30000000000000004,这就是为什么0.1 + 0.2 != 0.3 的原因了。

结尾

从一个诡异的问题出发,去理解为什么会出现这样的现象,以及里面的原理,想必这就是一个程序员的执着,实事求是,刨根问底,就会得到更多的收获。相信大家看完文章之后,对JavaScript的数值也会有更深的理解。

今天的文章从标准原理出发理解 JavaScript 数值精度分享到此就结束了,感谢您的阅读。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/19864.html

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注