JavaScript的大数问题

2,753 阅读6分钟

众所周知,实数分为两种:有理数和无理数。但是计算机使用有限的位数来存储数字,因此只能表示实数中有限的有理数,对于其他的数字只能是近似等于而已。(下图比较形象的描述了这种现象,来自其他文章的图)。而实数(无限)-> 计算机能表示的实数(有限)这个问题就会产生一系列的衍生问题,大家常常讨论的问题有:

  • 为什么0.1 + 0.2 = 0.30000000000000004?
  • 为什么 x=0.1 能得到 0.1?
  • 为什么1.005.toFixed(2)=1.00而不是1.01?
  • 为什么 parseInt(0.0000008) === 8?
  • 大数相加问题

本文会以是什么->为什么->怎么做这样一个大概的逻辑思路来解答上面一系列的衍生问题。

1. 基本的数学知识 —— 十进制转二进制

温习一下初中还是高中数学知识

11.125转二进制:

IMG_0105.PNG

0.3为转二进制:

IMG_0104.PNG

2. IEEE 754中双精度浮点数(是什么)

根据国际标准IEEE 754,任意一个二进制浮点数V可以表示成下面的形式:

  • (-1)^s表示符号位,当s=0,V为正数;当s=1,V为负数。
  • M表示有效数字,1≤M<2。
  • 2^E表示指数位

上面我们计算得出0.3的二进制是1.0011...×2^-2,那么代入这个公式就是(-1)^0 × 2^1021 × 1.0011... 。(s=0,M为1.0011...,E为1021)。

这里就又产生了一个疑问,为什么E=1021?带着疑问我们进一步认识这条公式。这条公式,在计算机的存储是这样的:

(1)对于32位的浮点数,最高的1位是符号位s,接着的8位是指数E,剩下的23位为有效数字M:

(2)对于64位的浮点数,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M:

JavaScript的Number类型为双精度IEEE 754 64位浮点类型。

关于M(mantissia)

IEEE 754规定,在计算机内部保存M时,默认这个数的第一位总是1,因此可以被舍去,只保存小数点后的xxxxxx部分。 简单点说,就是我们的有效数字M(1≤M<2),因为总是1.xxxx的格式所以在存储的时候,会不存那个1,只存xxxx的部分。比如保存1.0011的时候,只保存0011,等到读取的时候,再把第一位的1加上去。这样做的目的,是节省1位有效数字。从上面的图来看,就是存储的时候,fraction(粉色部分)的52位或者23位只存1.xxxx中的xxxx的部分

综上,在JavaScript中对于一个浮点数,转为二进制在计算机存储时,加上省略的一位,我们一共其实可以拥有53位有效位数字。

关于E(exponent)

E为一个无符号整数(unsigned int)。对于32位的浮点数,E为8位,它的取值范围为0~255(2^8 - 1);对于64位的浮点数,E为11位,它的取值范围为0~2047(2^11 - 1)。但是,科学计数法中的E是可以出现负数的,所以IEEE 754规定,E的真实值必须再减去一个中间数,对于8位的E,这个中间数是127;对于11位的E,这个中间数是1023。 也就是说我们以127或1023作为中间点划分正负数的情况:

  • 指数为负的情况:(0, 1023)或者(0, 127)
  • 指数为正的情况:(1023,2047)或者(127, 255)

所以,综上,c为一个二进制的数表示的真实指数,得出E=c+1023。所以回到刚刚那个问题,0.3的二进制是1.0011...×2^-2,那么代入这个公式就是E=-2+1023=1021

下面以64位(也就是JavaScript对于Number类型的实现)的情况对E的各种情况进行讲解。

(1) 0<E<1023(-1023<c<0),这种情况下E不全为0或不全为1,指数为负(即c小于0)。

还是以0.3为例,可以看到其存储如下图:

(2) E=0(c=-1023),这种情况下E全为0,表示±0和无限接近0的数字0< x <= 5e-324-5e-324= < x < 0,其存储如下图:

Number.MIN_VALUE

Number.MIN_VALUE表示在 JavaScript 中所能表示的最小的正值(5e-324)。

(3) E=1023为正负的中间点,舍去

(4) 1023<E<2047(0<c<1025),这种情况下E不全为0或不全为1,指数为正(即c大于0)。

以3为例,可以看到其存储如下图:

0<c<1025这个区间会出现JS两个特殊的值Number.MAX_SAFE_INTEGERNumber.MAX_VALUE

Number.MAX_SAFE_INTEGER

Number.MAX_SAFE_INTEGER常量表示在 JavaScript 中最大的安全整数(2^53 - 1)。此时M全为1, E=1075,c=52,其存储如下图:

为什么2^53-1是最大的安全整数?

首先引用一下MDN对于这里安全的解释。安全存储的意思是指能够准确区分两个不相同的值。,如Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2将得到 true的结果,而这在数学上是错误的,也就是不安全的。所以当c>=53时,不再是安全存储,所以也不是一个安全的整数。

所以,Javascript能够安全存储 -(2^53 - 1) 到 2^53 - 1 之间的数值(包含边界值)

进一步来看一下c>=53的情况,也就是 1075<E<2047(53<=c<=1023) 的情况,

十进制数区间二进制数区间备注
[2^53 , 2^54)[1.{52个0}0 × 2^53, 1.{52个0}00 × 2^54)省略了1位,在这个区间只能精确表示2的倍数
[2^54 , 2^55)[1.{52个0}00 × 2^54, 1.{52个0}000 × 2^55)省略了2位,在这个区间只能精确表示4的倍数
.........

我们上面提到的一张图能很好的表示他们之间的对应关系,越往两边越稀疏越不精确

Number.MAX_VALUE

Number.MAX_VALUE常量表示在 JavaScript 里所能表示的最大数值(1.798e308)。此时M全为1,E=2046,c=1023,其存储如下图:

(5) E=2047(c=1024),这种情况下E全为1,表示无穷大Infinity或者NaN,其存储如下图:

infinity其存储如下图:

NaN其存储如下图:

下面这张图很好地表示了上面提到的几种情况:

转十进制展示时是按什么规则来截断?

这里先引用IEEE 754 规范中的一段话:

The 53-bit significand precision gives from 15 to 17 significant decimal digits precision (2−53 ≈ 1.11 × 10−16). If a decimal string with at most 15 significant digits is converted to IEEE 754 double-precision representation, and then converted back to a decimal string with the same number of digits, the final result should match the original string. If an IEEE 754 double-precision number is converted to a decimal string with at least 17 significant digits, and then converted back to double-precision representation, the final result must match the original number.

简单翻译下: 对于64位的浮点数(53位有效位数),十进制数字的精度在15-17位。 15位有效数字的十进制字符串 (1) ->IEEE 754中双精度浮点数存储->15位有效数字的十进制字符串 (2),即经过这一次转换后1和2必须还是相同的。IEEE 754中双精度浮点数存储 (3) -> 17位有效数字的十进制字符串 -> IEEE 754中双精度浮点数存储 (4),即经过这一次转换后3和4必须还是相同的。

在大多数系统上现在选择最短的有效位来展示,即如果一个双精度的浮点数转为十进制的数字时,只要它转回来的双精度浮点数不变,精度取最短的那个。还是以0.3为例子,它的不同精度的结果如下:

而精度为1和17的在计算机中的存储是一样的(如下图),所以选择最短的有效位(1位即0.3)来展示。

我们发现,只要在计算机的存储结果(IEEE 754 64位双精度浮点数)是一样的,JavaScript转十进制时取最短的有效位进行展示, 1<=有效位<=17。

*个人觉得,理解这个问题的答案很重要。这和下文的一系列的衍生问题的答案息息相关。但是很多讲IEEE 754 双精度浮点数的文章对于这个问题都没有很明确的解答或者解答得懵懵懂懂,这也是我想写这篇文的一个初衷。*

3. 一系列的衍生问题的答案(为什么)

为什么0.1 + 0.2 = 0.30000000000000004?

经过上文我们知道0.1+0.2在计算机中其实转化为53位有效数字的二进制进行运算,运算结果再转为十进制展示:

0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010 =
0.0100110011001100110011001100110011001100110011001100111

// 转成十进制正好是 0.30000000000000004

那为什么运算结果展示上是0.30000000000000004而不是0.3呢?上面我们提到, 转十进制展示时,在计算机中的存储结果一样的情况下,是取最短的有效位来展示,0.30000000000000004在计算机存储如下图,和我们上面提到的0.3, 在计算机中的存储结果不一样,所以取最长有效位17位来展示,即0.30000000000000004

在查资料的时候发现一个有趣的网站0.30000000000000004.com,有兴趣可以康康。

为什么 x=0.1 能得到 0.1?

0.1.toPrecision(17) // 0.10000000000000001

与上文提到的0.3类似,因为0.10.10000000000000001,在计算机中的存储结果一样,所以取最短的有效位1位来展示,即0.1。(这里不再贴图,有兴趣的,可以去这个网站验证看一下)

为什么1.005.toFixed(2)=1.00而不是1.01?

toFixed的定义:

numObj.toFixed(digits)一个数值的字符串表现形式,不使用指数记数法,而是在小数点后有 digits位数字。该数值在必要时进行四舍五入,另外在必要时会用 0 来填充小数部分,以便小数部分有指定的位数。

先温习一下小学数学四舍五入的概念:如果尾数的最高位数字是4或者比4小,就把尾数去掉。如果尾数的最高位数是5或者比5大,就把尾数舍去并且在它的前一位进"1",这种取近似数的方法叫做四舍五入法。

1.005.toPrecision(17) // 1.0049999999999999

1.005.toFixed(2)即对1.0049999999999999保留小数点后两位进行四舍五入,即1.00

为什么 parseInt(0.0000008) === 8?

这个问题并不是由于 IEEE 754 64位双精度浮点数 产生的误差。首先,还是来看看parseInt这个方法。

parseInt(string, radix) string要被解析的值,如果参数不是一个字符串,则使用ToString将其转换为字符串。radix,表示字符串的基数,从 2 到 36。如果 radix 是 undefined,分为以下两种情况:1)如果输入的 string以 "0x"或 "0x"开头,radix被假定为十六进制;2)如果输入的 string以 "0"或任何其他值开头,radix被假定为十进制。如果 parseInt 遇到的字符不是指定 radix 参数中的数字,它将忽略该字符以及所有后续字符,并返回到该点为止已解析的整数值。

由上我们发现,parseInt不能正确理解符号e,即e后面的数字将被截断。

parseInt(0.0000008) // 实际进行了两步
// 第一步,0.0000008转String
0.0000008.toString() // "8e-7"
// 第二步,8e-7返回十进制整数
parseInt('8e-7') // 8

// 正确的写法
parseInt('0.0000008') // 0

大数相加问题

我们前面提到Number.MAX_SAFE_INTEGER,当两个数据相加时,其中一个或者两个数据都超过了Number.MAX_SAFE_INTEGER,直接相加结果就会不准了。这就是人们常说的大数相加问题。这个问题的答案有很多,这里提供一种新思路,使用ES2020的BigInt,当然这只是一种新思路,还是存在一些问题,例如浏览器兼容性和带小数的运算会被取整。

BigInt(Number.MAX_SAFE_INTEGER) + BigInt(2) // 9007199254740993n
9007199254740993n.toString() // 9007199254740993

4. 在开发中,关于这个问题我们要注意些什么(怎么做)

在ECMAScript中一些可以用到的方法

  • Number.EPSILON表示 1 与Number可表示的大于 1 的最小的浮点数之间的差值,通常称为机器精度,这个值为2^-52

  • Number.isSafeInteger()方法用来判断传入的参数值是否是一个“安全整数”,即在 [-(2^53 - 1), 2^53 - 1]这个区间。

  • toPrecision()方法以指定的精度返回该数值对象的字符串表示。

  • BigInt是一种内置对象,它提供了一种方法来表示大于 2^53 - 1 的整数。BigInt可以表示任意大的整数。可以用在一个整数字面量后面加n的方式定义一个 BigInt ,如:10n或者BigInt(10)

例如,我们上面提到的0.1+0.2的结果0.30000000000000004与正确的运算结果0.3就在误差Number.EPSILON范围内。

0.1+0.2-0.3 // 5.551115123125783e-17
0.1+0.2-0.3 < Number.EPSILON // true

在数据计算时,要注意考虑是否有超过MAX_SAFE_INTEGER的可能。在带小数的数据结果展示上,要注意考虑是否有类似0.1 + 0.2这种情况的出现。在实际开发中,如有需要可以使用mathjsdecimal.js等第三方库。

参考

浮点数的二进制表示

MDN - Number.MAX_SAFE_INTEGER

Number

IEEE754 64位数字存储在线图示

Double-precision floating-point format

MDN - parseInt

JS处理大数相加问题

抓住数据的小尾巴 - JS浮点数陷阱及解法

ECMAScript中的Number Type与 IEEE 754-2008