极乐科技
首发于极乐科技

JS与数

本篇将讨论JS在数字类型方面的一些表现和特性

1. 0.1 + 0.2不等于0.3

这是一个老生常淡的问题,甚至有一个网站叫:0.30000000000000004.com,这个也就是0.1 + 0.2的值,如下图所示:

在控制台运行得到的结果带了个尾巴,但0.1是0.1,0.2是0.2,0.3结果也是0.3,为什么0.1 + 0.2就不等于0.3了呢?

而且我们发现0.01 + 0.09 + 0.2这个结果又等于0.3了:

原因很简单,因为0.1存储的值比实际值大了一点,0.2也是大了一点(差值比0.1大一倍),两个相加就大很多了,多出来的就是那个尾巴。为什么0.1不能够被准确存储呢?因为计算机都是二进制的,在十进制能表示的数不一定能被二进制精确表示,就好像在十进制里面无法准确表示1/3一样,而在三进制里面0.1便表示1/3了。在二进制里面能够被精确表示都必须得是二的倍数的组合,如二进制的0.1表示十进制的0.5,0.11便表示0.75( = 0.5 + 0.25),0.111表示0.875( = 0.5 + 0.25 + 0.125),假设现在要存储0.625那么能够被精确表示为二进制的0.101,如果要表示0.626呢?那么应该是通过后面的小数位相加拼凑,让其尽可能逼近0.626. 这个时候就不是精确表示了,这个事情就是编译器的工作。

十进制的0.1和0.2究竟被存储成什么呢?我们可以写一段C代码,然后使用gcc编译器生成汇编代码便可知道存储的值了,如下代码所示:

// add.c
int main() {
  double a = 0.1;
  double b = 0.2;
  double c = 0.1 + 0.2;
  return 0; 
}
// 运行
gcc -S add.c -o add.s

打开add.s这个文件,如下图所示:


.quad指令表示这是一个64位的数字,这里是双精度浮点数存储的二进制的值的十进制数值,也就是说用这个数字转成的二进制就是内存里面符合IEEE 754浮点数的存储:

我们再反过来看一下这两个数的精确值是多少,如下图计算所示(根据浮点数尾数、阶码的计算规则):

或者使用JS和toFixed也能看到实际的值:

可以看到,0.1的存储实际上是大了0.55e-17,而0.2大了1.11e-17,两个相加由于二进制相加时阶码要对齐(0.1进阶末位是1,进了一位,详细过程推导可见:为什么0.1 + 0.2不等于0.3?),导致精度舍入,所以最后结果便为了4e-17。

这个时候你可能会说为什么0.2打印出来不是0.20000000000000001呢?1e-17和4e-17差别就那么大吗?从V8源码(fatt_dtoa.cc)可以看到浮点数转成字符串的时候对尾数的处理有一个非常复杂的过程,基本上要符合尽可能地短以及找到一个接近它的数:

// The digits in the buffer are the shortest representation possible: no
// 0.09999999999999999 instead of 0.1. The shorter representation will even be
// chosen even if the longer one would be closer to v.

具体过程不去深入研究,但可以肯定的是4e-17已经超过了它能把尾数省掉的容忍度,因此转成字符串的时候就带了个尾巴。

同时这也解释了为什么(2.55).toFixed(1)的值是2.5而不是2.6,因为2.55存储的值比实际的值小,在四舍五入的时候是严格按照满就进位的规则,所以结果就(2.54999999).toFixed(1)的结果一样了,具体可见《为什么(2.55).toFixed(1)等于2.5?

2. 小数和整数之间的自动切换

JS相对于其它编程语言来说有一个比较方便的特性,就是Number类型即能表示整数也能表示小数,使用的时候不区分到底是整型还是浮点型,例如0.9 + 0.1两个浮点型相加就会变成一个整型的值1,如果是整型的话在转成字符串的时候末尾就不会给加上无用的0。所以数在JS里面究竟是怎么表示的呢?从V8源码可以看到,所有的数都是用64位二进制表示的,也就是说浮点数都是双精度的,浮合IEEE 754的规定,和其它语言一致。而整数也是用浮点数的结构表示的,例如如果要表示整数2,那么尾数为0,阶码值为1,即1.0 * 2 ^ 1 = 2,如果要表示整数3,那么尾数应当为0.1,阶码也为1,即1.1 * 2 ^ 1 = 0b11 = 3(注意浮点数的尾数按规定都是默认加1)。通过控制阶码(乘方或者说移位),就能把尾数部分变成整数。

也就是说JS是利用尾数部分来表示整数的,尾数总共有52位,加上默认的1,总共有53位,所以JS最大能表示精确的整数便为2 ^ 53 - 1即:

这个数约等于9e16,这也就是为什么说双精度浮点数最多的精确位只有15、16位,上面0.1 + 0.2打印的结果带了4e-17的尾巴,并不能说JS出错了,而是第17位是不精确的,不能够依赖第17位的结果。那既然不能保证准确性,为啥JS要把它打印出来呢?实际上这个取决于使用的人怎么用了,如第一点所说,转字符串的时候JS只是负责尽可能地准确。


同时这也是JS的数的一个缺点,最大整数只有16位,而不是64位整型能表示最大的19位,而后端数据库的id通常是64位整型有可能会超过9e16,这个时候我们通常会让后端把id转成字符串再传给我们,否则在JSON.parse的时候通常转成的整数值就不对了。带来方便的同时也牺牲了一点功能性。

这个时候你可能会有这个问题,为什么52位尾数会凭空多了一个1,变成了53位,明明只有52位的空间,却能够表示53位的值,似乎不符合xxx守恒定律。因为会默认加1,最高位加多了一位,多了这一位的代价是没法表示0了,因为52位尾数表示的最小值是1,所以文档规定64位全为0的时候便表示0,如果没有这个规定的话,全为0的数应该是最小的那个小数(阶码全为0便表示最小的阶码-1023)。所以是牺牲掉了最小的小数换取了0的表示,这个小数作用微乎其微,但是却让精确值多了一位而且是最高的一位,相当于精确的范围直接乘以2了。

另外一个延伸的问题为什么Number.EPSILON会是2.2e-16次方:

如果两个浮点数的差值小于这个数,那么便认为这两个数是相等的,如Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON,这个数便是精确值的1 unit,即尾数最后一位为1(前面都为0)的那个数0.0000...0001,即1 * 2 ^ -52:

3. JS的数是Object吗?

例如var a = 1,那么a是一个基础变量,还是一个对象Object?我们看书的经常会有这么一种说法叫boxing和unboxing,装盒和拆盒,当调用a的方法如toString时,就会进行装盒把a变成一个对象,而如果只是进行加减运算的时候就是一个基础变量。

在V8里面可以看到,V8有一个Object和JSObject的类,Object是V8里面所有类的根类,而JSObject则表示JS里面的Object,上面的a变量是V8里的Object对象,它有一个指针指向它在堆上的内容。V8把对象分为两类,一种是栈上的小整数,其它剩下都是堆上的变量(都是需要new或者malloc内存的)。因为小整数(Small Integer)在编程里面是最常用的数,如0,1,2,3,4,5...,所以这种就不要用new了,直接把指针的值变成小整数的值,也就是说这个指针并不是指向堆上的一个地址,而就是一个整数的值。这样可以节省空间,并且运算也会比较快。

所以a一直都是Number的对象(确实是Object),指针是一个Number类型,但是指针的值不是指向堆上的一个地址,而是实际存储的整数值。

除了小整数外,大一点的整数是按照上面第2点所说的双精度存储的(数据是在堆上的),以及小数和其它所有的对象都是堆上的对象。你可能会问,如果之前是一个小数后来赋值成了一个整数,这个时候怎么办?V8会进行存储结构的转换。这个时候你可能会说这样会降低效率?确实,但这是一个平衡考虑的策略。

编辑于 2019-11-17

文章被以下专栏收录