神秘情况
console.log(0.2 + 0.3 == 0.5) // true
console.log(0.1 + 0.2 == 0.3) // false
复制代码
为何会如此?我们来看看前置知识。
阅读本文您将收获
- 十进制与二进制间的转换
- 计算机的相关知识:源码、补码、反码
- IEEE754标准、浮点数
- 计算机存储和计算浮点数的原理
二进制
大家都知道所有的代码执行时都将被编译为类似 01010 的二进制代码,也叫机器码。 JavaScript 中的变量也是如此。JavaScript 会将 数值 类型转化为二进制存储在计算机中, 这就涉数值的进制转换问题。
我们来看看 10 进制 与 2 进制的转换规律
10进制转2进制
一、正整数的十进制转换二进制:
要点:除二取余,倒序排列
解释:将一个十进制数除以二,得到的商再除以二,依此类推直到商等于一或零时为止,倒取将除得的余数,即换算为二进制数的结果
例如把52换算成二进制数,计算结果如图:
\
52除以2得到的余数依次为:0、0、1、0、1、1,倒序排列,所以52对应的二进制数就是110100。
由于计算机内部表示数的字节单位都是定长的,以2的幂次展开,或者8位,或者16位,或者32位….。
于是,一个二进制数用计算机表示时,位数不足2的幂次时,高位上要补足若干个0。本文都以8位为例。那么:
(52)10=(00110100)2
二、负整数转换为二进制
要点:(正数除二取余,倒序排列)取反加一
解释:将该负整数对应的正整数先转换成二进制,然后对其取补”,再对取补后的结果加1即可
例如要把-52换算成二进制:
1.先取得52的二进制:00110100
2.对所得到的二进制数取反:11001011
3.将取反后的数值加一即可:11001100
即:(-52)10=(11001100)2
)
三、小数转换为二进制
要点:(小数)乘二取整,正序排列
解释:对被转换的小数乘以2,取其整数部分(0或1)作为二进制小数部分,取其小数部分,再乘以2,又取其整数部分作为二进制小数部分,然后取小数部分,再乘以2,直到小数部分为0或者已经去到了足够位数。每次取的整数部分,按先后次序排列,就构成了二进制小数的序列
例如把0.2转换为二进制,转换过程如图:
0.2乘以2,取整后小数部分再乘以2,运算4次后得到的整数部分依次为0、0、1、1,结果又变成了0.2,
若果0.2再乘以2后会循环刚开始的4次运算,所以0.2转换二进制后将是0011的循环,即:
(0.2)10=(0.0011 0011 0011 …..)2
循环的书写方法为在循环序列的第一位和最后一位分别加一个点标注
源码
一个数在计算机中是以二进制的形式存在的,其中第一位存放符号, 正数为 0, 负数为 1。原码就是用第一位存放符号的二进制数值。
例如 2 的原码为 00000010,-2 的原码为10000010。
反码
正数的反码是其本身。负数的反码是在其原码的基础上,符号位不变,其余各位取反,即 0变 1,1 变 0。
[+3]=[00000011]原=[00000011]反
[-3]=[10000011]原=[11111100]反
可见如果一个反码表示的是负数,并不能直观的看出它的数值,通常要将其转换成原码再计算。
补码
正数的补码是其本身。负数的补码是在其原码的基础上,符号位不变,其余各位取反,最后+1。(即负数的补码为在其反码的基础上+1)。
[+3]=[00000011]原=[00000011]反=[00000011]补
[-3]=[10000011]原=[11111100]反=[11111101]补
可见对于负数,补码的表示方式也是让人无法直观看出其数值的,通常也需要转换成原码再计算。
计算机中数值都是一补码的方法存储, 需要运算时再转化为源码进行运算
所以 +3 在计算机中实际上是以 [00000011] 存储的
IEEE754
JavaScript中只有一种数字相关类型 –
Number
,并使用双精度浮点数存储,以 64 位固定长度来表示,相当于标准的double
双精度浮点数
二进制浮点数算术标准IEEE中的存储格式为:
V = (-1)^S × M × 2^E
复制代码
-
(-1)^s表示符号位,当s=0,V为正数;当s=1,V为负数。
-
M表示有效数字,尾数位,大于等于1,小于2。
-
2^E表示指数位。
-
举例说明
- 十进制下
5.0 = 5 * 10^(0)
- 二进制下
5.0 = 101.0 = 1.01 * 2^(2) --> (-1)^0 * 1.01 * 2^(2) --> S = 0, M = 1.01, E = 2 复制代码
存储格式
-
对于单精度浮点数即32位的浮点数,最高的1位是符号位 S ,接着的8位是指数 E ,剩下的23位为有效数字 M。
-
对于双精度的浮点数即64位的浮点数,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M。
JavaScript 采取双精度还是单精度一般取决于硬件条件, 现代计算机一般都采用的 双精度浮点数。
几个存储时需要注意的点
-
尾数位M(有效数字位)
- IEEE 754 标准规定,在 计算机内部保存 M 时,默认这个数的第一位总是1,因此可以被舍去,只保存后面的部分 。比如保存 1.01 的时候,只保存 01,等到读取的时候,再把第一位的 1 加上去。这样做的目的,是节省 1 位有效数字。以 32 位浮点数为例,留给 M 只有 23 位,将第一位的 1 舍去以后,等于可以保存 24位有效数字。
-
指数位E
-
首先,E为一个无符号整数。这意味着,如果 E 为 8 位,它的取值范围为 0~2^8-1 = 0~255;如果 E 为 11 位,它的取值范围为 0~2^11-1 = 0~2047。
-
但是,我们知道,科学计数法中的 E 是可以出现负数的,所以 IEEE 754 规定,E的真实值必须再减去一个中间数,对于8位的E,这个中间数是127(-127~128);对于11位的E,这个中间数是1023。
-
举个栗子,2^10 的 E 是10,当保存成 32 位浮点数时,必须保存成 10 + 127 = 137,即 10001001。如果要保存成 64 位浮点数的时候,就会保存成 10 + 1023 = 1033,即 10000001001。
-
-
指数E还可以再分成三种情况:
-
E 不全为 0 或不全为 1。这时,浮点数就采用上面的规则表示,即指数 E 的计算值减去 127(或1023),得到真实值,再将有效数字 M 前加上第一位的 1。
-
E 全为 0。这时,浮点数的指数E等于 1-127(或者1-1023),有效数字M不再加上第一位的 1,而是还原为 0.xxxxxx 的小数。这样做是为了表示 ±0,以及接近于 0 的很小的数字
-
E 全为 1。这时,如果有效数字 M 全为0,表示±无穷大(正负取决于符号位s);如果有效数字M不全为0,表示不是一个数(NaN)。
存储详情
以
0.1 + 0.2
为例
- 转换为二进制:
// 0.1
0.00011 0011 0011...(0011循环)
// 0.2
0.0011 0011 0011...(0011循环)
复制代码
- 转换为 IEEE754 存储:
// 0.1
(-1)^0 * 1.1 0011...(0011循环) * 2^(-4)
=> S = 0, M= 1.1 0011...(0011循环), E = -4
=> 实际存储指数位:-4 + 1023 = 1019 => 11 1111 1011
//0.2
(-1)^0 * 1.1 0011...(0011循环) * 2^(-3)
=> S = 0, M= 1.1 0011...(0011循环), E = -3
=> 实际存储指数位:-3 + 1023 = 1020 => 11 1111 1100
复制代码
- 实际存储:
// 0.1 的存储
0 011 1111 1011 1001100110011001100110011001100110011001100110011010
--------------------------------------------------------------------
S M(11位) E(52位)
// 0.2 的存储
0 011 1111 1100 1001100110011001100110011001100110011001100110011010
--------------------------------------------------------------------
S M(11位) E(52位)
复制代码
浮点数在计算机中的运算
运算步骤
计算分为六个步骤
-
检查操作数
- 对0、Infinity 和 NaN 操作数作检查
-
对阶
- 是指将两个进行计算的浮点数的阶码对齐的操作。
- 具体操作是:求出两个浮点数阶码的差,按照 小阶对齐大阶 的方式,将两浮点数的阶码对齐,同时将小阶码对应的浮点数的尾数相应右移,以保证浮点数的值不变。
- **注意:**对阶的原则是小阶对大阶。因为若大阶对小阶,则尾数的数值部分的高位需移出,而小阶对大阶移出的是尾数的数值部分的低位,这样损失的精度更小。
- **注意:**由于尾数右移时是将最低位移出,会损失一定的精度,为减少误差,可先保留若干移出的位,供以后舍入处理用。
-
尾数计算
-
主要为进行对阶后的尾数相加减的相关操作,并采用双符号法判断是否溢出。
-
-
结果规格化
- 第一种:向右规格化:若上一步出现溢出,则尾数右移1位,阶码+1。
- 第二种:向左规格化:若上一步没有出现溢出,且数值域最高位与符号位数值相同,则尾数左移1位且阶码-1,直到数值域最高位为1为止。
-
舍入处理
- 由于浮点数无法精确表示所有数值,因此在存储前必须对数值作舍入操作。
- 具体有五种方式,IEEE 754 默认的舍入模式:就近舍入(与四舍五入有一点点不同) Round to nearest, ties to even: 当存在两个数一样接近时,取偶数值(Round(0.5) = 0; Round(1.5) = 2; Round(2.4) = 2; Round(2.5) = 2; Round(3.5) = 4)
-
溢出判断
运算详情
1、原本 0.1 和 0.2 的二进制表示
0.1 = 1.1001100110011001100110011001100110011001100110011010 * 2^-4
0.2 = 1.1001100110011001100110011001100110011001100110011010 * 2^-3
复制代码
2、可以看到,0.1 的阶码为 -4,0.2 的阶码为 -3,依照小阶对大阶的原则,我们需要将 0.1 的阶码变为 -3,因此其尾数部分需要右移一位。对阶之后 0.1 的存储为:
0.1 = 0.11001100110011001100110011001100110011001100110011010 * 2^-3
0.2 = 1.1001100110011001100110011001100110011001100110011010 * 2^-3
复制代码
3、对阶之后 0.1 的尾数溢出一位,正好是0,我们舍弃掉,所以最终的计算前的存储为
// 0.1
0 01111111100 1100110011001100110011001100110011001100110011001101
// 0.2
0 01111111100 1001100110011001100110011001100110011001100110011010
复制代码
4、两个二进制进行加法计算
0 01111111100 1100110011001100110011001100110011001100110011001101
+ 0 01111111100 1001100110011001100110011001100110011001100110011010
-------------------------------------------------------------------
= 0 01111111100 10110011001100110011001100110011001100110011001100111
复制代码
5、结果中尾数部分已经发生了进位,超过了52位,因此阶码部分加1(乘以2),即阶码由原来的 -3 变为 -2,所以阶码部分为 01111111101。而尾数部分右移一位(除以2),进行舍入(最后一位是1因此最低位进位),得到最终的存储:
// 这一步就是造成 JS 中 0.1 + 0.2 !== 0.3 的原因
0 1111111101 1011001100110011001100110011001100110011001100110100
复制代码
6、将最终结果转换为十进制数为
2^-2 + (1+(1*2^-1 + 0 * 2^-2+1*2^-3+1*2^-4+... )
=
0.3000000000000000444089209850062616169452667236328125
复制代码
7、最终由于精度问题,只取到 0.30000000000000004(这一步是我们看到最终计算结果)
总结
好了,我们已经知道为什么 0.1 + 0.2 !== 0.3 的原因了,主要由于 0.1 和 0.2 转为二进制的时候为无限循环小数,而计算机的存储位置有限因此会做一定的截取舍入处理,再进行加减就有一定的误差了。
参考文章:
754-1985 – IEEE Standard for Binary Floating-Point Arithmetic | IEEE Standard | IEEE Xplore