星期日, 十一月 22日 2020, 10:00 晚上

  6k 字     22 分钟       

「硬核 JS」数字之美

写在前面

一直都在佛系更新,这次佛系时间有点长,很久没发文了,有很多小伙伴滴我,其实由于换工作以及搬家的原因,节奏以及时间上都在调整,甚至还有那么一小段时间有点焦虑,你懂的,现已逐渐稳定,接下来频率应该就会高了,奥利给~

可能大家对一些看了能立即上手或者是面经类文章的更为倾向一些,说实话,你可能能瞒过面试官,终究瞒不过自己,应牢记 技术!=面试题 ,应该有很多人会忽略一些基础的东西吧,殊不知决定楼有多高的是地基

前几天有朋友问我位运算相关的东西,其实本来是打算写篇位运算的文章,但描述位运算的前提是需要大家能够清晰的了解计算机中的 数字,数字和位运算又是不同的两个点,所以直接淦位运算可能并不太好,就拿出了此文修补一番发一下,也算是来补一补之前写一半就罢工的文章,随后再补发一篇位运算的文章

数字,很普通的东西,所有语言都有数字,本文的大部分知识点并不仅仅适用于 JavaScript ,其他语言也都类似,数字大家表面看来可能很简单,其实从计算机到语言本身对数字的处理还是比较复杂的,望本文能够体现出数字的精妙,故而取名 数字之美

二进制

对于计算机只能存储二进制,想必是大家耳熟能详的知识了

我们都知道在计算机内部数据的存储和运算都采用二进制,是因为计算机是由很多晶体管组成的,而晶体管只有2种状态,恰好可以用二进制的 0 和 1 表示,并且采用二进制可以使得计算机内部的运算规则简单,稳定性高,但是由于我们平常并不直接使用二进制,所以可能有小伙伴能给十进制转二进制都忘了,这里就简单介绍一下,当作回顾

整数转二进制

关于十进制整数转二进制,其实很简单,记住一个秘诀,就可以了

除 2 取余,逆序排列

就是用 2 整除十进制数,得到商和余数,再用 2 整除商,得到新的商和余数,一直重复直至商等于 0,将先得到的余数作为二进制数的高位,后得到的余数作为二进制数的低位,依次排序即可

例如,我们将十进制 55 转换为 2 进制

55 % 2 // 商 27 余 1
27 % 2 // 商 13 余 1
13 % 2 // 商  6 余 1
6  % 2 // 商  3 余 0
3  % 2 // 商  1 余 1
1  % 2 // 商  0 余 1

取余逆序,那么十进制 55 转 2 进制的结果就是 110111

二进制一个数值是 1 位,也就是 1 比特(bit),那么如果我们需要得到 8 位二进制,那就在转换结果前补 0 即可

如十进制 55 的 8 位二进制即 00110111,那么可能还会有人为如果是 4 位怎么办呢,4 位是存不了 55 这么大值的,溢出了

小数转二进制

可能还有人不了解十进制小数是怎么转二进制的,其实也有方法口诀

乘 2 取整,顺序排列

用 2 乘十进制小数,可以得到积,将积的整数部分取出,再用 2 乘余下的小数部分,又得到一个积,再将积的整数部分取出,如此进行,直到积中的整数部分为零,或者整数部分为1,此时 0 或 1 为二进制的最后一位或者达到所要求的精度为止,然后把取出的整数部分按顺序排列起来,先取的整数作为二进制小数的高位有效位,后取的整数作为低位有效位

例如,将十进制小数 0.625 转二进制

0.625 * 2 = 1.250 // 取整数 1
0.25  * 2 = 0.50  // 取整数 0
0.5   * 2 = 1            // 取整数 1 并结束

取整顺序,那么十进制小数 0.625 的二进制即为 0.101

如果该十进制值是一个大于 1 的小数,那么整数部分和小数部分分别取二进制再拼接即可

例如,将十进制小数 5.125 转二进制

我们先计算整数 5 的二进制

5 % 2 // 商  2 余 1
2 % 2 // 商  1 余 0
1 % 2 // 商  0 余 1

那么 5 的二进制即 101,再来看小数部分

0.125 * 2 = 0.250 // 取整数 0
0.25  * 2 = 0.50  // 取整数 0
0.5   * 2 = 1            // 取整数 1 并结束

那么小数部分 0.125 的二进制即 001,拼接可得出十进制数字 5.125 的二进制为 101.001

还会有一种情况,例如十进制小数 0.1 取其二进制

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
0.4 * 2 = 0.8 // 取整数 0
...

那么它的二进制就是 0.0001100...... 这样反复循环,这也引出了我们在语言层面的问题,例如 JS 中被人诟病的 0.1 + 0.2 != 0.3 的问题,我们后面再说

原码、反码和补码

再说 JS 中的数字问题前,我们还需要补充了解下原码、反码和补码的概念,这里暂先不说结论,我们一步一步的来看,最后在总结什么是原码、反码和补码

起源

计算机里保存的是最原始的数字,也就是没有正和负的数字,我们称之为无符号数字

假如我们在内存中用 4 位(也就是4bit)去存放表示无符号数字,是下面这样子的

PS: 这里也说了是假如,当然你也可以用 32 位来理解,这里只是为了解释原码、反码、补码的概念,多少位只有一个区别,那就是可存储的值范围大小不同,可存储位数越大,可以存储的值范围就越大,这点后面会说到,这都不重要,主要是 32 位画图太累。。。

我们可能注意到了,这样好像没办法表达负数

So,为了表示正与负,先辈们就发明了 原码,把左边第一位腾出来,存放符号,正数用 0 来表示,负用 1 来表示

上图就是正负数的 原码,你可能在疑惑为什么上面表里我只画到了数字 7,上面也说了,我们这里使用的示例是 4 位(bit)的存储方式,只存 4 位,还有一位是符号位,十进制 7 的二进制表达方式就是 0111 了,数字 8 二进制算上符号为是 01000,这就 5 位了,就不是 4 位二进制能存下的了,所以,在只有 4 位存储二进制时,原码的取值范围只有 -7 ~ +7

原码 这种方式对人来说是很好理解的,但是机器不了解啊,表达值没问题,但是正负相加怎么加呢?

假如我们要用 (+1) + (-1) ,这个我们上过小学就知道等于 0,但是按照计算机的二进制计算方式,0001 + 1001 = 1010 ,我们对比下原码表,也就是 -2

很明显,这样计算是不对的,还有就是我们会看到,原码中的 0 有两种表示:+0 和 -0,这明显也是不对的

为了解决正负相加等于 0 的问题,先辈们又在 原码 的基础上发明了 反码

正数的反码还是等同于原码,反码 的表示方式其实就是用来处理负数的,也就是除符号位不变,其余位置皆取反存储,0 就存 1,1 就存 0

那么我们再来看

同上,4 位反码的值存储范围也是 -7 ~ +7

原码 变成了 反码 ,我们看之前的(+1)和(-1)相加,变成了 0001 + 1110 = 1111,相加结果对比反码表, 1111 也就是 -0 ,就完美的解决了正负相加等于 0 的问题

但是,如果使用 反码 存储数值,还是存在那个问题,即 (+0)和(-0)这两个相同的值,存在两个不同的二进制表达方式

于是先辈们为了解决这个问题,又提出了 补码 的概念,也是针对 负数 来做处理的,即从原来 反码 的基础上,补充一个新的代码 1

如上图所示,处理 反码 中的 -0 时,给 1111 再补上一个 1 之后,就变成了 10000,由于我们是 4 位存储,所以要丢掉除符号位的最左侧高位,也就是进位中的那一位,也就变成了 0000,刚好和左边正数的 0 相等

完美解决了(+0)和(-0)同时存在的问题

我们看补码表中由于 -0 的补码是 0000 等同于 +0,因为它补了 1嘛,我们发现 -0 就没有了意义,所以去掉了 -0 这个数字

我们再看负 7 的补码也就是反码加了 1 后的二进制表达方式为 1001 ,以 4 位存储的方式我们发现补码表 1001 还可以再小一位,也就是 1000 即 -8,如下图

于是补码的最后补上了一个 -8,也就是在 4 位存储中补码的值表达范围是 -8 ~ +7

同时,我们在使用 补码 时,正负相加等于 0 的问题也同样可以解决

例:

我们把(+4)和(-4)相加,0100 + 1100 =10000,有进位,把最高位丢掉,也就是 0000(0)

接下来我们就可以梳理总结下什么是原码、反码、补码了

原码

原码其实就是数值前面增加了一位符号位(即最高位为符号位),正数时符号位为 0

负数时符号位为 1(0有两种表示:+0 和 -0),其余位表示数值的大小

例:

我们这次使用 8 位(bit)二进制表示一个数,那么正 5 的原码为 0000 0101,负 5 的原码就是 1000 0101,区别只有符号位

反码

正数的反码与其原码相同

负数的反码是对其原码除符号位外,皆取反

例:

使用 12 位(bit)二进制来表示一个数值,那么正 5 的反码等同于原码即为 0000 0000 0101,负 5 的反码符号位为 1 ,其余取反即为 1111 1111 1010

补码

正数的补码与其原码相同

负数的补码是在其反码的末位加 1去掉最高进位

例:

使用 32 位(bit)二进制来表示,那么正 5 的补码等同于原码即为 0000 0000 0000 0000 0000 0000 0000 0101,负 5 的补码在反码末位补 1 去掉最高进位,由于负 5 的反码加 1 无进位,即为 1111 1111 1111 1111 1111 1111 1111 1011

根据补码求原码

上文我们知晓了原码、反码、补码的概念后,应该已经了解了由原码转换为反码的过程,但是,若已知一个数的补码,求原码的操作呢?

其实,已知补码求原码的操作就是对这个补码再求补码

如果补码的符号位为 0,表示是一个正数,那么它的原码就是它的补码

如果补码的符号位为 1,表示是一个负数,那就直接对这个补码再求一遍它的的补码就是它的原码

例:

求补码 1001 即十进制 -7 的原码

我们对补码再求补码,也就是先取反再补 1 ,取反得 1110 ,再补一得 1111,我们对照上文中 -7 的原码,正是 1111

二进制在内存中以补码存储

如上述,此时再和大伙说最终结论,二进制数在内存中最终是以补码的形式存储的,现在知道为什么用补码存储了吗,你 GET 到了吗?

使用补码,我们可以很方便的将减法运算转化成加法运算,运算过程得到简化,正数的补码即是它所表示的数的真值,而负数的补码的数值部份却不是它所表示的数的真值,采用补码进行运算,所得结果仍为补码

与原码、反码不同,数值 0 的补码只有一个,4 位为例,即为 0000

再次补充,32 位、12位、8 位和 4 位等的不同就是存储的值范围,就像 8 位存储原码和反码的有效值范围是 -127 ~ +127,补码范围是 -128 ~ +127,而 4 位原码和反码范围是 -7 ~ +7,补码范围是 -8 ~ +7,这下你大概了解到为什么 JS 会有最大和最小有效数字这个概念了吧

当然我们现在只考虑了整数,并没有说小数,是为了方便我们理解原码、反码和补码,接着来道

JavaScript中数字存储

JavaScript 不是类型语言,它与许多其他编程语言不同,JavaScript 没有不同类型的数字,比如整数、短、长、浮点等等

JavaScript 中,数字不分为整数和浮点型,也就是所有的数字都是使用浮点型类型来存储,它采用 IEEE 754 标准定义的 64 位浮点格式表示数字,如下图

  • 第 63 位即 1 位符号位 S (sign)

  • 52 ~ 62位即 11 位阶码 E (exponent bias)

  • 0 ~ 51 位即 52 位尾数 M(Mantissa)

符号位也就是上文说的,表示正负,0 为正,1 为负

符号位我们比较好理解,那么什么是尾数什么又是阶码呢?

什么是尾数

为了方便解释,我们直接使用例子,来看十进制数 5.2 的尾数

首先,我们把它整数部分和小数部分依次转为二进制,不过多重复这个过程,结果如下

101.00110011... // 小数部分 0011 无限循环

一个浮点数的表示方式其实有很多,但规范中一般使用科学计数法,就像上面的 101.00110011... ,我们会使用 1.0100110011.. * 2^2 这种只留一位整数的表达方式,我们称之为规格化

二进制中只有 0 与 1,按照科学计数法,除了数字 0 ,其余所有规格化的数字首位只可能是1,对此 IEEE 754 直接省略了这个默认的 1 用来增加存储值的范围,所以有效尾数实际上是有 52 + 1 = 53 位的

上文说尾数即表达的是数字的小数部分,也就是说二进制数值 1.0100110011.. * 2^2 的尾数是 0100110011...,因为它是个无限循环小数,所以我们取最大 52 即可,剩余的就截断了,这样就会造成一定的精度损失,这也是为什么 JS 中 0.1 + 0.2 != 0.3 的原因,如果尾数不足 52 位则在后面补 0 即可

我们可能会疑惑,为什么除了 0 之外的数字转二进制后首位都是 1,比如 0.0101 这种 0 < 值 < 1 的二进制小数首位不就是 0 吗,我们说了是 规格化之后的,二进制小数 0.0101 在规格化之后是 1.01 * 2^-2 ,所以省略首位 1 并不会混淆

什么是阶码

首先,我们要知道

阶码 = 阶码真值 + 偏移量 1023,偏移量 = 2^(k-1)-1,k 表示阶码位数

阶码真值即为科学记数法中指数真实值的 2 进制表达,它表明了小数点在尾数中的位置

那么为什么阶码真值与偏移量相加得到阶码呢?

简单理解,阶码真值是实际指数中的二进制值,而阶码是指数偏移之后保存起来的二进制数值

还拿上面数值 5.2 来说,它的规格化二进制为 1.0100110011.. * 2^2 ,2 的 2 次方,也就是说它的阶码真值为 2 ,那么加上偏移量 1023 即 1025,转二进制后的 11位阶码即为 10000000001

那么为什么要偏移呢?

为什么阶码有偏移量 1023?

此时你可能会比较好奇为什么阶码会有偏移量这个概念,我们来推导一遍即可

11位的阶码,那么阶码可以存储的二进制值范围为 0~2047,除去 0 与 2047 两个非规格化情况(非规格化下面会说),变成 1~2046,这里指的是正数,因为还有负数,那指数范围就是 -1022~1023,如果没有偏移量的存在,指数就需引入符号位,因为有负数,还需要引入补码,无疑会使计算更加复杂,为了简化操作,才使用无符号的阶码,并引入偏移量的概念

不同情况下的阶码 E

我们上面提到过规格化和非规格化的概念,那么它们是什么呢

规格化的情况其实就是上面我们说的一般情况,因为阶码不能为 0 也不能为 2047,所以指数不能为 -1023,也不会为 1024,只有这种情况尾数才会有隐含位 1 即默认忽略的那一位,如下

S + (E!=0 && E!=2047) + 1.M

那么非规格化就是阶码全为 0,指数为 -1023 的特殊情况了,如果尾数全为 0,则浮点数表示正负 0,否则表示那些非常的接近于 0.0 的数,如下

S + 00000000000 + M

非规格化指的是阶码全为 0 ,那么表示了还有一种情况阶码全部为 1,指数就是 1024,在这种情况下,如果尾数全部为 0 ,那就是无穷大,若尾数不等于 0,那就是我们常说的 NaN 了

无穷大:S + 111 11111111 + 00000000...

NaN:S + 111 11111111 + (M!=0)

测试一哈

可能大家还是有些迷惑,最好反复看一看,那么歇一歇脑子,接下来我们来一个小测试,计算一下十进制数 -15.125 在 JS 内存中的二进制表达方式是多少,动手试一试吧,做完再看答案

都看到这了,动动小手,点个赞吧 😄

如上,求十进制数 -15.125 在 JS 内存中的二进制

首先,由于是负数,那么符号为就是 1

接着,将 15.125 的整数部分 15 和小数部分 0.125 分别转为二进制,计算过程不叙述了,整数除 2 取余逆序排列,小数乘 2 取整顺序排列,结果合到一块为 1111.001

按照科学技术法规格化结果为 1.111001 * 2^3

再接下来,计算阶码,3(阶码真值)+ 1023(偏移量)= 1026

将 1026 转为 11 位二进制 100 0000 0010 ,即为阶码

尾数即规格化结果数去掉整数 1 的小数部分 1110 01,不足 52 位后补 0 尾数结果为 1110 0100 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

最后,拼接即可

符号位 + 阶码 + 尾数
1 10000000010 1110010000000000000000000000000000000000000000000000

JS中数字范围

如果大家真的理解了上文,那么就会发现数字的范围其实有两个概念,最大正数和最小负数,最小正数和最大负数

而最终的数字范围即 最小负数~最大负数 并上 最小正数~最大正数

从S、E、M即数符、阶码、尾数三个维度看,S 代表正负,阶码 E 的值远大于尾数 M 的个数,所以阶码 E 决定大小,尾数 M 决定精度

So,我们从阶码 E 入手分析

规格化下,当 E 最大值时,2046(最大阶码) - 1023(偏移量) = 1023(阶码真值)即 011 11111111

从阶码 E 的最大值求出的指数(阶码真值)来看,我们可以得到的数值范围是 -2^1023 ~ 2^1023,使用 JS 的求指函数 Math.pow(2,1023) 得出结果是 8.98846567431158e+307,那么如果尾数是 1.11111111...,则它就无限接近于 2,我们不算这么准确,就用 8.98846567431158 x 2 再合上原来的指数,约等于 1.797693134862316e+308

大家还记得我们用 JS 常量 Number.MAX_VALUE 求到的最大数字值吗,现在就可以在控制台输出一下,即 1.7976931348623157e+308,和我们估算出来的值非常相近(因为为了简单我们把规格化的数字约等于了 2 来计算,算出的数值其实是大了一点的)

所以数字的最大正数和最小负数范围如下

1.7976931348623157e+308 ~ -1.7976931348623157e+308

如果超过这个值,则数字太大就溢出了,在 JS 中会显示 Infinity-Infinity,即无穷大与无穷小,学名叫做正向溢出

上面说的是规格化下,那么非规格化下,也就是指数为 0(最小阶码) - 1023 (偏移量) = - 1023,即 10000000001

从指数来看,我们可以得出最小值是 2^-1023 ,当如果尾数是 0.00000...001

也就是尾数不为 0 的情况,52 位尾数相当于小数点还能虚拟化的向右移动51,可以取得更小的 2^-51 , 所以最小值为为 2^-1074,我们再来计算下 Math.pow(2,-1074) 结果约等于 5e-324

而 JS 最小值常量 Number.MIN_VALUE 得出的值就是是 5e-324

所以数字的最小正数和最大负数范围即如下

5e-324 ~ -5e-324

如果存了一个数值比可表示的最小数还要小,就显示成 0,学名反向溢出

JS中整数的范围

和数字大小不同,数字可以有小数,但是整数就只是单纯整数

我们从尾数 M 来分析,精度最多是 53 位(包含规格化的隐含位 1 ),精确整数的范围其实就是 M 的最大值,即 1.11111111...111 ,也就是 2^53-1 , 使用 JS 函数 Math.pow(2,53)-1 计算得到数字 9007199254740991

所以整数的范围其实就是

-9007199254740991 ~ 9007199254740991

我们也可以使用 JS 内部常量来获取下最大与最小安全整数

Number.MIN_SAFE_INTEGER  // -9007199254740991
Number.MAX_SAFE_INTEGER  //  9007199254740991

恰好与我们所求一致

那么我们说如果整数是这个范围内,则是安全整数

一个整数是否是安全整数可以使用 JS 的内置方法 Number.isSafeInteger() 来验证

最后

开发过程中不乏有找过安全范围的计算,这个时候我们就得要转为字符串计算了,当然不想自己转也可以使用开源库来计算,如 bignumber.jsMath.js 等等

感谢大家的阅读,此文在之前最开始写的时候之所以停了就是因为写着写着让二进制搞得有点懵,所以大家一遍如果不太懂可以多看看,不要气馁,如果此文描述的不太恰当也可以看下文末参考链接中的文章辅助理解,如有不正,望指出,谢谢

也欢迎大家关注公众号「不正经的前端」,来个三连吧,感谢

更多精彩尽在 github.com/isboyjc/blog

参考文章

原码、反码、补码的产生、应用以及优缺点有哪些?

原码、反码、补码之间的相互关系

[算法]浮点数在内存中的存储方式

0.1 + 0.2不等于0.3?为什么JavaScript有这种“骚”操作?

JS中如何理解浮点数?



硬核JS系列      JavaScript

水平有限,欢迎指错!
联系邮箱:214930661@qq.com
本站文章除特别声明外,均采用 CC BY-SA 3.0协议 ,商业转载请联系作者获得授权,非商业转载请注明出处!

 目录

更多精彩,请关注公众号【不正经的前端】
回复【加群】加入技术交流群,回复【资料】获取精选学习资料