JavaScript中的Number

Clloz · · 5,453次浏览 ·

前言

由于 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。但是我们可以发现,补码在整数的计算中虽然很方便不用考虑符号位,但是在大小比较的时候却并不方便。因为我们首先要比较符号位,然后在比较后面的值,这对我们来说可能很直观,可是在计算机的电路设计上会不方便,这就是使用移码的原因。

上面我们已经介绍了移码的概念,我们在补码那篇文章中也说了,同余运算我们只要数字成环即可,原点终点可以自定,所以我们可以给定任意偏移量生成一个新的环。移码的偏移量其实没有标准,IEEE 754标准规定该固定值为$2 ^ {n -1} – 1$,在这里就是 $2 ^ 7 – 1$ 127,于是我们从 1000000001111111 来表示 -128127 变成了 1111111111111110 来表示 -128127(其中 1111111100000000 用来表示特殊值,具体说明在下一小节),更具体的说是 0000000101111111 表示 -12601000000011111110 表示 1127,符合上面我们对移码的定义。此时我们所有的数按照从小到大排列,此时要比较大小就非常简单了。我们计算真实指数也很容易,将指数减去偏移量即可。

上面我们给出的偏移值是 $2 ^ {n -1}$ ,但实际 IEEE754 标准中给出的偏移值是 $2 ^ {n -1} -1$ ,在单精度浮点数中就是 127(这样刚好将最小负数 -128 也就是 10000000,移动到 11111111,这样刚好用 1111111100000000 做特殊情况使用),并且给出了几种 特殊情况 的定义($e$表示指数部分的位数即指数偏移量,64 位双精度就是 1132 位单精度就是 8):

  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 ),而指数则为 $0 – (2 ^e – 2)$,即 0 - 126 或者 0 - 1022IEEE 754标准规定:非规约形式的浮点数的指数偏移值比规约形式的浮点数的指数偏移值小1),这主要是用来表示非常接近 0 的小数,这类数称为非规约数,而其他指数在 0 到 $2 ^e -1$ (开区间)中的数字则为规约数。$2 ^e -1$即全为 1 的情况。

将上面的规则总结为下面的表格:

形式 指数 小数部分
0 0
非规约形式 0 大于 0 小于 1
规约形式 1 到 $2 ^e -2$ 大于等于 1 小于 2
无穷 $2 ^e -1$ 0
NaN $2 ^e -1$ 非0

浮点数表示范围

以单精度浮点数为例:

类别 正负号 实际指数 有偏移指数 指数域 尾数域
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 两种情况,这$2 ^ {53}$个数中又有 正无穷负无穷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 ,虽然这个例子很简单,但其实计算机中的计算虽然字长长很多,但也是一样的道理。

0.1 + 0.2 != 0.3 的推导

这是一个非常常用的题目考察我们对浮点数的理解,大部分人都知道在 IEEE 754 标准下的双精度浮点数的计算结果位 0.30000000000000004,至于为什么下面给出具体的计算过程。

这个题目使用的非常多,甚至有人专门搞了一个https://0.30000000000000004.com/的网站。

JS中能表示的最大精确整数是多少呢?这个问题其实就是问 IEEE 754 标准下的双精度浮点数能表示的最大精确整数,结果很简单,64bit 中,有 52 位用来表示有效数字,加上默认的 1 ,一共是 53 位,所以结果就是 $2 ^ {53} -1$ 9007199254740991。注意这里说的是最大精确整数,不是最大的数,要求精确就是从 09007199254740991 中的每一个整数都能准确表示,超过这个范围则会损失精度。64 位双精度浮点数能表示的最大规约数位 $(2 – 2 ^ {-52}) \times 2 ^ {1023}$,结果约为 1.7976931348623157e+308,具体的计算方法参考上面的 32 位单精度浮点数的计算方法,这两个值可以通过 Number.MAX_SAFE_INTEGERNumber.MAX_VALUE 来表示。

要知道 0.1 + 0.2 的计算结果,我们先要知道计算机是如何保存 0.10.2 这两个数的。

可以到这个Double (IEEE754 Double precision 64-bit) Converter进行转换,结果如下

  • 0.1
    • sign 0
    • exponent 01111111011
    • mantissa 1001100110011001100110011001100110011001100110011010
  • 0.2
    • sign 0
    • exponent 01111111100
    • mantissa 1001100110011001100110011001100110011001100110011010

我们可以看出两个数字的有效数字部分是完全相同的,唯一不同的就是阶码(指数偏移量),0.2 的阶码比 0.11,我们把这两个数进行十进制的转化可以得出结果(64 位双精度浮点数的指数偏移量为 $2 ^ {11 – 1} -1 = 1023$),由于计算精度要求较高,可以到这个网站进行求值Big online calculator

  • 0.1:$(2702159776422298 \times 2 ^ {-52} + 1) \times 2 ^ {-4} = 0.1000000000000000055511151231257827021181583404541015625$

  • 0.2:$(2702159776422298 \times 2 ^ {-52} + 1) \times 2 ^ {-3} = 0.200000000000000011102230246251565404236316680908203125$

你可以是试试在 javascript 中输出 0.1 === 0.1000000000000000055511151231257827021181583404541015625 是返回 true 的,而且不管你删除多少位(保留 0.1)都是 true

这两个数相加,注意不能将上面的结果直接相加,计算机中的相加先对阶(低阶的向高阶对齐,减少精度损失),然后相加,在这里我们要先将 0.1 的阶也变为$2 ^ {-3}$,

  • 0.1
    • sign 0
    • exponent 01111111100
    • mantissa 1100110011001100110011001100110011001100110011001101 0

相当于把 0.1 的小数点向左移一位,注意的是最左边还有一个隐藏的 1,最后一个 0 就被舍弃了,需要注意的是被截断的最后一位如果是 1 则需要在新数的末位再加一。

对阶完成后就是有效数字部分相加,需要注意的是 0.2 的左侧有一个隐藏的 1:

00.1100110011001100110011001100110011001100110011001101 + 01.1001100110011001100110011001100110011001100110011010
10.0110011001100110011001100110011001100110011001100111

可以看出结果溢出了,这时候我们需要截断最后一位,阶数加一,由于截断的是 1, 在新的尾数末位要加一,结果就是:

  • sign 0
  • exponent 01111111101 十进制1021
  • mantissa 0011001100110011001100110011001100110011001100110100 十进制900719925474100

最后再将结果转化为十进制 $(900719925474100 \times 2 ^ {-52} + 1) \times 2 ^ {-2} = 0.3000000000000000444089209850062616169452667236328125$

这里就得到了我们在计算 0.1 + 0.2 时得到的 0.30000000000000004。误差的主要来源就是 0.10.2 在计算机中的保存就是存在误差的,之后在二者相加计算的时候又有舍入的误差。其实不仅仅是 0.1 + 0.2 有误差,包括我们连续将 0.1 相加,你会发现加 8 次会得到 0.7999999999999999,所以我们在进行对精度有要求的运算的时候一定要注意。

关于最后的浮点数计算这一部分,一定要记住把隐藏的 1 拿出来参与尾数的计算,因为这个隐藏的 1 也是尾数的一部分。

这里再插一点,上面提到在双精度浮点数中最大精确整数是9007199254740991,就是 9e16 这个数量级,而且 JS是不区分整形和浮点型,整数也是用浮点数的形式表示的,所以在 JS 中,超出 15 位的数就不一定是绝对精确的了。这会导致一个问题,就是 64 位整形能表示的最大数是 19 位,后端数据库的 id 通常是 64 位整型其大小有可能会超过 9e16,所以比较安全的方法是在后端转成字符串再传给前端。所以 JS 灵活的类型转换,带来的缺点就是整数的精确表达范围小了很多。

关于误差 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);

Number.EPSILONES6 添加的新属性,如果考虑兼容性,可以使用如下 polyfill

if (Number.EPSILON === undefined) {
    Number.EPSILON = Math.pow(2, -52);
}

其他还有 Number.prototype.toprecision()Number.prototype.toFixed() 方法,他们根据我们给出的精度返回一个字符串,我们可以再用 parseFloat 获得我们想要的数字。ECMA-262 只需要最多 21 位显示数字。

Number.prototype.toprecision()Number.prototype.toFixed() 的区别是:

  • toPrecision 是处理精度,精度是从左至右第一个不为 0 的数开始数起。
  • toFixed 是小数点后指定位数取整,从小数点开始数起。

他们都能够对精度进行处理,进行四舍五入或添 0,不过需要注意的是,我们的有效数字部分一共只有 53 位,所以真正能精确表示的也就 9007199254740991 个数,16 位,超过这个范围,都是只能取近似了,小数也是一样。但是他们也会有 bug,比如 1.005.toFixed(2) 返回的结果是 1.00 而不是 1.01,因为 1.005 的实际值是 1.00499999999999989

还有一个比较完美的解法就是第三方库 number-precision

JS 中的数字进制

JS 中我们也可以用不同的进制来表示数字字面量,0b0B 前缀表示二进制,如果 0b 后面的数字不是 0 或者 1 会提示愈发爱错误 Missing binary digits after 0b0o 前缀表示八进制,0 开头也可以表示八进制,假如 0 后面的数字不在 07 的范围内,该数字将会被转换成十进制数字。ECMAScript 5 严格模式下禁止使用八进制语法。八进制语法并不是 ECMAScript 5 规范的一部分, ECMAScript 6 中使用八进制数字是需要给一个数字添加前缀 0o0x 或者 0X 前缀表示十六进制,假如 0x 后面的数字超出规定范围(0123456789ABCDEF),那么就会提示这样的语法错误(SyntaxError):Identifier starts immediately after numeric literal

还有一点需要注意的是,JS 中的小数点只要一边有数字即为合法的字面量,也就是说 0..0 都是合法的字面量。当我们调用数字的 toString 方法的时候,如果表达式为 0.toString() 是会报错的,合法的写法是 0 .toString(),在 0. 之间留一个空格。这其实是一个 bug,不过目前我们只能这么使用。其他进制转十进制用 parseInt,注意 parseInt 的第一个参数需要是 String,如果传入的不是一个字符串,会调用 toString 方法转换为字符(这里的机制还是比较复杂的,参考 MDN),第二个数字表示字符串的进制。十进制转其他进制使用 Number.prototype.toString()toString 接受一个参数即要转换的进制。parseIntNumber.prototype.toString 的进制数都在 2-36 范围内,超出这个范围将会报错。

//JS中的进制转换
parseInt(num,8);   //八进制转十进制
parseInt(num,16);   //十六进制转十进制
parseInt(num).toString(8)  //十进制转八进制
parseInt(num).toString(16)   //十进制转十六进制
parseInt(num,2).toString(8)   //二进制转八进制
parseInt(num,2).toString(16)  //二进制转十六进制
parseInt(num,8).toString(2)   //八进制转二进制
parseInt(num,8).toString(16)  //八进制转十六进制
parseInt(num,16).toString(2)  //十六进制转二进制
parseInt(num,16).toString(8)  //十六进制转八进制

总结

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

参考文章

  1. 浮点数指数为什么使用移码
  2. 浮点数的二进制表示
  3. Wikipedia-IEEE754
  4. 谁偷了你的精度
  5. 为什么0.1 + 0.2不等于0.3?
  6. JS与数 – 李银城的文章 – 知乎
  7. JavaScript 浮点数陷阱及解法

Clloz

人生をやり直す

1 个评论

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

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

李不显进行回复 取消回复

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

我不是机器人*

 

00:00/00:00