JavaScript中的Number

Clloz · · 722次浏览 ·

前言

由于 JavaScript 是个动态弱类型语言,入门也比较简单,所以刚开始大家不是那么关心类型的细节。今天这篇文章来说一说 JavaScript 中的 Number

定点数和浮点数

我在 原码,反码和补码 这篇文章中介绍了关于计算机中如何存储和计算整数。不过我们的现实需求中显然不止又整数,小数的计算可能才是更加频繁的。但是我们知道计算机只能进行二进制的存储和计算,显然要加入小数点就和符号位不参与计算一样需要设计非常复杂的电路,这是没意义的。计算机科学家们自然要设计一种用二进制计算小数的方法,最终确定了两种方案,定点数和浮点数。

定点数其实很好理解,就是小数点后位数固定,比如一个 8 位存储单元,我们规定精度为 4,也就是说小数点后 4 位,1100100100110101 分别表示 1100.100111.0101 两个数字。

浮点数借用了我们日常使用的科学计数法的思维,在字长中截取一段作为偏移量,剩下的部分表示有效数字 1.xxxxxxx,有效数字的第一位为1,通常省略。结构如下图所示

float-point

我们可以看出定点数和浮点数各自又自己的适用场景,比如定点数的精度不会变化,但是表示的范围很有限,比如我们想表示很大和很小的数都无法做到。为了解决这一问题才引入了浮点数,目前大部分编程语言和 cpu 都是按照 IEEE754 标准来进行浮点数的存储和运算的,在 JavaScript 中的 Number 类型都是标准中的双精度浮点数。

IEEE754 标准

我一直都做前端的工作,对定点数和浮点数的理解也仅限于大学中的计算机组成原理的课程中,虽然这些知识可能在前端工作中用处不大,不过我对计算机的工作原理和设计思路还是很好奇,并且不喜欢云里雾里的感觉,对于有能力搞懂的内容还是尽量搞懂,毕竟这个行业变化很快,这些底层的知识掌握肯定有用处,对于我们的整个知识框架肯定是很有帮助的。

IEEE754 标准是二进制浮点数运算标准,规定了计算机中的二进制浮点数的存储和计算的标准。我们在上面的已经介绍了浮点数的三个组成部分:符号位,指数偏移值和有效数字。浮点数又分为两种,单精度和双精度:
1. 单精度:符号位 1bit,指数偏移量 8bit,有效数字 23bit,共 32bit
2. 双精度:符号位 1bit,指数偏移量 11bit,有效数字 52bit,共 64bit
符号位不用讲了,0 为正,1 为负。我们主要说一说指数偏移值和有效数字的部分。

有效数字

有效数字和我们的科学计数法基本相同,小数点右边只保留一位,也就是 1 ≤ 有效数字 < 2。因为这一位都是 1,所以并不保存,相当于一个默认位。比如保存1.01的时候,只保存 01,等到读取的时候,再把第一位的 1 加上去。这样做的目的,是节省 1 位有效数字。以 32 位浮点数为例,留给有效数字只有 23 位,将第一位的 1 舍去以后,等于可以保存 24 位有效数字。

指数偏移值

指数偏移值理解起来很简单,就比如我们十进制的科学计数法 1.2 * 10 ^ 21,这个 21 就是指数偏移值,但是我们在 wikipedia 对标准中的指数偏移值的介绍中可以发现,这个指数偏移值并不是我们前面介绍的 补码 方式存储的,而是用了一种所谓的移码加偏移量的形式。

Wikipedia移码 是这么介绍的:在计算机科学中,移码(英语:Offset binary )是一种将全 0 码映射为最小负值、全 1 码映射为最大正值的编码方案。理解起来并不困难,但是我们可能很难理解为什么要这么做。我们想想我们对科学计数法的计算过程:

  1. 比较两个数的指数大小
  2. 把两个数化成相同指数
  3. 对有效数字进行计算
  4. 将结果格式化为科学计数法的正常格式

计算机对浮点数的计算其实也遵循这三个步骤:求阶差、对阶,尾数相加,结果规格化。也就是说我们首先要比较指数偏移量的大小。我们以单精度浮点数为例,我们的指数偏移值一共是 8bit,如果按照我们之前的补码方式存储,那么就是用 1000000001111111 来表示 -128127。但是我们可以发现,补码在整数的计算中虽然很方便不用考虑符号位,但是在大小比较的时候却并不方便。因为我们首先要比较符号位,然后在比较后面的值,这对我们来说可能很直观,可是在计算机的电路设计上会不方便,这就是使用移码的原因。

上面我们已经介绍了移码的概念,我们在补码那篇文章中也说了,同余运算我们只要数字成环即可,原点终点可以自定,所以我们可以给定任意偏移量生成一个新的环。移码其实没有标准,但是一般是 $2 ^ {n -1}$,在这里就是 $2 ^ 7 $ 128,于是我们从 1000000001111111 来表示 -128127 变成了 0000000011111111 来表示 -128127,更具体的说是 0000000001111111 表示 -128-11000000011111111 表示 0127,符合上面我们对移码的定义。此时我们所有的数按照从小到大排列,此时要比较大小就非常简单了。我们计算真实指数也很容易,将指数减去偏移量即可。

上面我们给出的偏移值是 $2 ^ {n -1}$ ,但实际 IEEE754 标准中给出的偏移值是 $2 ^ {n -1} -1$ ,在单精度浮点数中就是 127,并且给出了几种特殊情况的定义:

  1. 如果指数是0并且尾数的小数部分是 0,这个数 ±0(和符号位相关)
  2. 如果指数 = $2 ^e -1$并且尾数的小数部分是 0,这个数是 ±∞(同样和符号位相关)
  3. 如果指数 = $2 ^e -1$并且尾数的小数部分非 0,这个数表示为不是一个数( NaN )。

根据这几条规则,当偏移量为 127 的时候,1111111111111110 表示 -128127,由于全为 0或全为 1 都是特殊值,所以指数取值范围从 0000000111111110 表示 -126127。除上面的特殊规则意外事件还有一种情况:指数全为 0,并且有效数字不为 0,此时舍弃有效数字小数点右边的 1 (就是没有保存的默认 1 ),而指数则为 $1 – (2 ^e – 1)$,即 1 - 127 或者 1 - 1023,这主要是用来表示非常接近 0 的小数,这类数称为非规约数,而其他指数在 0 到 $2 ^e -1$ (开区间)中的数字则为规约数。

关于为什么用 127 作为偏移量而不是 128,我并没有找到特别合理的解释,如果你知道原因欢迎评论。

浮点数表示范围

以单精度浮点数为例:

类别 正负号 实际指数 有偏移指数 指数域 尾数域
0 -127 0 0000 0000|000 0000 0000 0000 0000 0000 0.0
负零 1 -127 0 0000 0000 000 0000 0000 0000 0000 0000 −0.0
1 0 0 127 0111 1111 000 0000 0000 0000 0000 0000 1.0
-1 1 0 127 0111 1111 000 0000 0000 0000 0000 0000 −1.0
最小的非规约数 * -126 0 0000 0000 000 0000 0000 0000 0000 0001 $\pm2^{23}\times2^{-126}=\pm2^{-149}\approx\pm1.4\times10^{-45}$
中间大小的非规约数 * -126 0 0000 0000 100 0000 0000 0000 0000 0000 $\pm2^{-1}\times2^{-126}=\pm2^{-127}\approx\pm5.88\times10^{-39}$
最大的非规约数 * -126 0 0000 0000 111 1111 1111 1111 1111 1111 $\pm(1-2^{-23})\times2^{-126}\approx\pm1.18\times10^{-38}$
最小的规约数 * -126 1 0000 0001 000 0000 0000 0000 0000 0000 $\pm2^{-126}\approx\pm1.18\times10^{-38}$
最大的规约数 * 127 254 1111 1110 111 1111 1111 1111 1111 1111 $\pm(2-2^{-23})\times2^{127}\approx\pm3.4\times10^{38}$
正无穷 0 128 255 1111 1111 000 0000 0000 0000 0000 0000 $+\infty$
负无穷 1 128 255 1111 1111 000 0000 0000 0000 0000 0000 $-\infty$
NaN * 128 255 1111 1111 non zero NaN

* 符号位可以为 01

对于 JavaScript 中使用的浮点数一共可以表示 $2^{64}-2^{53}+3$ 共 18437736874454810627 个值,至于计算很简单,64 为一共可以表示 $2^{64}$ 个数,但是特殊情况 3 中的指数为 $2 ^e -1$并且尾数的小数部分非 0排除掉了 $2 ^ {53}$ 个值(除指数位外还有 53 位),但是 23 两种情况中又有三种 正无穷负无穷NaN 三个数,所以得出上面的结论。

精度

面试题中经常出现 0.1 + 0.2 为什么不等于 0.3,这就和浮点数计算的精度有关系了。

我们在使用十进制的时候也是又误差的,比如 1/3,或者 $\sqrt2$的时候我们是无法用有限的小数精确的表示这个值的,只能根据我们的使用场景来确定我们需要的精度。使用二进制也一样,二进制中的 0.1 表示十进制的 0.50.01 表示十进制的 0.25,显然又很多小数我们无法表示,比如 0.10.9 这九个小数(小数点后只有一位的小数),只有 0.5 在二进制中可以精确表示,其他都是有误差的。

IEEE二进制浮点数算术标准中定义了以下几种舍入规则:

  • 朝0方向舍入: 即截尾,直接将需要精确的位数以后的数位舍去。
  • 舍入到最接近: 即四舍五入,结果可能会变大或变小。
  • -∞方向舍入: 总是向数轴的左方向舍入。
  • +∞方向舍入: 总是向数轴的右方向舍入。

所以当我们计算无法精确计算的值的时候产生误差是不可避免的,可能有些同学会有疑问,为什么 0.3 能正确表示,而 0.1 + 0.2 却表示有问题呢?这是因为误差会在计算中被扩大,计算次数越多,误差越大。举个十进制的例子:1.6 + 2.8 = 4.4,但是如果我们的计算机精度只能到整数,那么我们的就算就变为了 2 + 3 = 5 ,虽然这个例子很简单,但其实计算机中的计算虽然字长长很多,但也是一样的道理。

关于误 Wikipedia 上还给出了几个具体的例子,比如 1990225 日,海湾战争期间,在沙特阿拉伯宰赫兰的爱国者导弹防御系统因浮点数舍入错误而失效,该系统的计算机精度仅有 24 位,存在 0.0001% 的计时误差,所以有效时间阙值是 20 个小时。当系统运行 100 个小时以后,已经积累了 0.3422 秒的误差。这个错误导致导弹系统不断地自我循环,而不能正确地瞄准目标。结果未能拦截一枚伊拉克飞毛腿导弹,飞毛腿导弹在军营中爆炸,造成 28 名美国陆军死亡。

小数有误差,整数同样会产生误差。我们上面的表格给出了单精度最大的规约数大概是 $\pm(2-2^{-23})\times2^{127}\approx\pm3.4\times10^{38}$,但其实当我们超出了有效数字的表示范围以后就一样会产生误差,单精度的有效数字位数是 24,双精度的有效数字位数是 53(都要加上默认的 1),所以单精度的最大安全整数是 $2^{24}-1$,而双精度的最大安全整数是 $2^{24}-1$,所谓的最大安全整数是指能够连续表示的整数,在有效数字范围内的整数都是能连续表示的,而超出有效数字范围则会产生误差。

当我们要对浮点数进行比较的时候,比较安全的方式是用 Number.EPSILONNumber.EPSILON 表示 1Number 可表示的大于 1 的最小的浮点数之间的差值,对于双精度浮点数,这个值为 $2^{-52}$,接近于 2.2204460492503130808472633361816E-16,利用这个属性比较浮点数可以得出正确的结果。

console.log( Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON);

总结

这篇文章主要是讲了浮点数的标准和误差产生的原因,自己的总结可能会有一些错误,望指正。

参考文章

  1. 浮点数指数为什么使用移码
  2. 浮点数的二进制表示
  3. Wikipedia-IEEE754
  4. 谁偷了你的精度

Clloz

人生をやり直す

1 个评论

李不显 · 2019年8月24日 - 下午8:52

深入浅出,非常受益。期待更多作品。

发表评论

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

我不是机器人*

 出现新的回复时用邮件提醒我。

EA PLAYER &

历史记录 [ 注意:部分数据仅限于当前浏览器 ]清空

      00:00/00:00