与js Number类型相关的问题不知道大家有没遇到过,以下是我遇到过的 3 个典型的问题
- 大整数运算错误
- toFixed() 函数四舍五入错误
- 小数运算错误
以上三个问题产生的原因是一样的,都是由于 js 存储数据的精度有限导致的,下面来分析一下 js 数据储存的原理。
计算机数学基础
为了更好的理解后面的计算原理,我们先来复习一些数学知识:
- 在数学里,小数是可以无限位的,但计算机存储介质有限,不可能全部存下,因此在计算机领域的很多小数都只是个近似值。
- 科学计数法是一种计数方式,把一个数表示成 a 与 10 的 n 次幂相乘
(1≤ |a| < 10)
,缩写:aEn = a * 10^n
。 - 用科学计数法可以免去浪费很多空间和时间。
- 一个数的负 n 次幂等于这个数的 n 次幂的倒数,
10^-2 = 1 / (10^2) = 1/100
。 - 十进制的近似值:四舍五入,二进制的近似值:零舍一入。
二进制转换
正整数的转换方法:除二取余,然后倒序排列,高位补零。 例如 65 的转换
65 转二进制为 1000001,高位 0 后为 01000001
负整数的转换方法:将对应的正整数转换成二进制后,对二进制取反,然后对结果再加一,也就是补码,计算机中数据都是以补码的形式储存的,只不过正数的补码是它本身。
例如-65
先把65转换成二进制 01000001
逐位取反:10111110
再加1:10111111(补码)
小数的转换方法:对小数点以后的数乘以 2,取整数部分,再取小数部分乘 2,以此类推…… 直到小数部分为 0 或位数足够。取整部分按先后顺序排列即可。
例如123.4:
`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.4 = 0.01100110……(0110循环)
整数部分123的二进制是 1111011
则123.4的二进制表示为:1111011.011001100110……
发现了什么?十进制小数转二进制后大概率出现无限位数!但计算机存储是有限的啊,怎么办呢?来,我们接着看。
js Number存储机制
IEEE 754 与 ECMAScript
所谓 IEEE754 标准,全称 IEEE 二进制浮点数算术标准,这个标准定义了表示浮点数的格式等内容,类似这样:
value = sign x exponent x franction
也就是浮点数的实际值,等于符号位(sign bit)乘以指数偏移值 (exponent bias) 再乘以分数值 (fraction)。所以 js 数值存储都是以二进制科学记数法来存储。
在 IEEE754 中,规定了四种表示浮点数值的方式:单精确度(32 位)、双精确度(64 位)、延伸单精确度、延伸双精确度。
ECMAScript 中的 Number 类型使用 IEEE 754 标准来表示整数和浮点数值,采用的就是双精确度,也就是说,会用 64 位来储存一个整数和浮点数。
在这个标准下,我们会用 1 位存储 S(sign)
,0 表示正数,1 表示负数。用 11 位存储 E(指数值) + bias(偏移)
,对于 11 位来说,bias 的值是 2^(11-1) - 1
,也就是 1023。用 52 位存储 Fraction(也称为尾数或小数)。
指数偏移量 bias 的来源
如果你知道为什么32位浮点数的指数偏移量是127,你就能知道为什么64位浮点数的指数偏移量是1023。
在32位浮点数中,指数位有8位,它能表示的数字是从0到2的8次方-1,也就是255,总共256个数。但是指数有正有负,所以我们需要把这256个数字从中间劈开,一半表示正数,一半表示负数,所以就是-128到+127这256个数字。那么怎么记录负数呢?一种作法是把高位置1,这样我们只要看到高位是1的就知道是负数了,所谓高位置1就是说把0到255这么多个数字劈成两半,从0到127表示正数,从128到255表示负数。但是这种做法会带来一个问题:当你比较两个数的时候,比如130和30,谁更大呢?机器会觉得130更大,但实际上130是个负数,它应该比30小才对啊。所以为了解决这个麻烦,人们发明了另外一种方法:干脆把所有数字都给它加上128得了,这样-128加上128就变成了0,而127加上128变成了255,这样的话,再比较大小,就不存在负数比正数大的情况了。
但是我要得到原来的数字怎么办呢?这好办,你只要再把指数减去128就得到了原来的数字,不是吗?比如说你读到0,那么减去128,就得到了负指数-128,读到255,减去128,就得到了127。
那为什么指数偏移是127,不是128呢?因为人们为了特殊用处(至于什么特殊用处,下面会说到),不允许使用0和255这两个数字表示指数,少了2个数字,自然就只好采用127了。
同理,64位浮点数,指数位有11位之多,2的11次方是2048,劈一半作偏移,可不就是1024吗?同理,去掉0和2048这两个数字,所以就用1023作偏移了。
所以对于指数值有如下公式:
11位编码值 = E(指数值) + 1023
三个特殊值
- 如果指数编码值是0并且尾数部分全是0,则这个数是±0
0 00000000000 0000000000000000000000000000000000000000000000000000 表示0
1 00000000000 0000000000000000000000000000000000000000000000000000 表示-0
- 如果指数部分11全为1,并且尾数部分全是0,这个数是±∞
0 11111111111 0000000000000000000000000000000000000000000000000000 = ∞
1 11111111111 0000000000000000000000000000000000000000000000000000 = -∞
- 如果指数部分11全为1,并且尾数部分不全为0,这个数是NAN
0 11111111111 0000000000000000000000000000000000000000000000000001 = NaN
0 11111111111 0000000000000000000000000000000000000000000000000010 = NaN
0 11111111111 0000000000000000000000000000000000000000000000000011 = NaN
……(为NaN的数共有2^52 - 1个)
规约形式的浮点数
关于规约浮点数与非规约浮点数可以参考:浮点数深入分析
如果浮点数中指数部分非全0且非全1,在科学表示法的表示方式下这个数就是规约形式的浮点数
,那么它的尾数部分最高有效位(即整数字)是1,也就是说尾数部分的值为“1.尾数”。例子如下:
求 0 00000000001 0000000000000000000000000000000000000000000000000001 所表示的十进制数
解答:由于指数部分非全0且非全1,是规约数。
符号位为 0, 是正数
指数值为 1 - 1023 = -1022,
尾数值为 1.0000000000000000000000000000000000000000000000000001 = 1 + 2^-52
十进制数为:2^-1022 * (1 + 2^-52)
非规约形式的浮点数
如果浮点数的指数部分的编码值是0,分数部分非零,那么这个浮点数将被称为非规约形式的浮点数,一般是某个数字相当接近零时才会使用非规约型式来表示。
IEEE754标准规定:非规约形式的浮点数的指数偏移值比规约形式的浮点数的指数偏移值小1,且尾数部分最高有效位(即整数字)是0。
所以尾数部分的值为“0.尾数”,对于64位双精度浮点数来说,指数偏移值为 1023 - 1 = 1022。
求 0 00000000000 0000000000000000000000000000000000000000000000000001 所表示的十进制数
解答:由于指数部分全0且尾数部分非全0,是非规约数。
符号位为 0, 是正数
指数值为 0 - 1022 = -1022,
尾数值为 0.0000000000000000000000000000000000000000000000000001 = 2^-52
十进制数为:2^-1022 * 2^-52 = 2^-1074
小数运算错误解析
通过对 js Number存储机制的分析,我们对js Number类型的存储原理有了一个理解,那么 0.1 + 0.2 为什么不等于 0.3 呢?
就拿 0.1 来看,对应二进制是 1 * 1.1001100110011……(1001循环) * 2^-4
,由于是正数,Sign 是 0,指数值 E 为 -4,那么编码值为 -4 + 1023 = 1019,用二进制表示是 1111111011,Fraction 是 1001100110011……(1001循环)
对应 64 位的完整表示就是:
0 01111111011 1001100110011001100110011001100110011001100110011010
同理,0.2 表示的完整表示是:
0 01111111100 1001100110011001100110011001100110011001100110011010
那么 0.1 + 0.2 的结果对应的 64 位如何表示呢?
0.1 和 0.2 对应的二进制表示如下:
0.1 >>> 0.0001 1001 1001 1001...(1001无限循环)
0.2 >>> 0.0011 0011 0011 0011...(0011无限循环)
将 0.1
和 0.2
的二进制形式按实际展开,末尾补零相加,结果如下:
0.00011001100110011001100110011001100110011001100110011010
+0.00110011001100110011001100110011001100110011001100110100
=0.01001100110011001100110011001100110011001100110011001110
用科学计数法(保留52位小数)表示为:
1.0011001100110011001100110011001100110011001100110100 * 2^(-2)
因此 0.1 + 0.2 实际存储时的形式是:
0 01111111101 0011001100110011001100110011001100110011001100110100
再转十进制为:0.30000000000000004
toFixed() 四舍五入错误解析
2.55.toFixed(1) 结果应该为 2.6 为什么是 2.5 呢,原因就是前面提到的,小数部分转为二进制时往往为无限循环小数,由于精度的原因,js 里存储的值并非是 2.55,而是有点小误差,可以用 toPrecision
多保留点精度看下:
如何解决呢?
- 可以给小数加个很小的值来“纠正”误差,例如 1e-14,大部分场景下的精度基本都够用
- 自己封装一个函数
function fixtoFixed(target, _n) {
var n = _n || 0;
return Number(Math.round(target + 'e' + n) + 'e-' + n).toFixed(n);
}
大整数运算错误解析
比较大的整数运算错误也同样是因为 js 存储值的精度问题,因为尾数部分就 52 位,超过了 52 位超过的部分会被丢弃,那么 js 所能表示的最大精确的整数为:
MAX_SAFE_INTEGER = 1.11...11(小数点后连续52个1) * 2^52 = 2^53 - 1
那么再看文章开头的问题
比较一下目标值与 2^53 - 1
由于目标值大于 2^53 - 1 ,目标值本身就不是一个精确值了,那么经过运算后的值自然也会有误差。
几个重要的常量
js 里有几个重要的常量,例如 Number.MAX_SAFE_INTEGER、Number.MAX_VALUE、Number.MIN_VALUE、Number.EPSILON、Infinity
,看看他们是如何算出来的
Number.MAX_SAFE_INTEGER
这个值是 js 所能表示的最大安全整数,超过了这个值的整数是不准确的,计算过程在上面一节已给出,其值为:2^53 - 1,同样也有一个 Number.MIN_SAFE_INTEGER,它等于 -Number.MAX_SAFE_INTEGER
Number.MAX_VALUE
Number.MAX_VALUE 表示 js 能表示的最大值,它的计算过程为:由于指数最大的编码值为 11111111110 即 2^11 -2 = 2046, 偏移值为 1023,所以真正的指数值为 2046 - 1023 = 1023,所以能表示的最大数为 1.11...11(小数点后连续52个1) * 2^1023 = (2^53 - 1) * 2^971,可以验证下:
Number.MIN_VALUE
表示最接近 0 的正数,具体计算过程为:指数部分为 0,尾数最后一位为 1 其余部分为 0,由于它是一个非规约数,所以偏移值为 1022,真正指数值为 0 - 1022 = -1022,所以最终值为 2^-1022 * 0.000...001(小数点后连续 51 个 0) = 2^-1022 * 2^-52 = 2^-1074,可以验证下:
Number.EPSILON
它表示 1 与大于 1 的最小浮点数之间的差,也就是 1.00...001(小数点后连续51个0) - 1 = 0.00...001(小数点后连续51个0) = Math.pow(2, -52),可以验证下:
Infinity
如果指数部分11全为1,并且尾数部分全是0,这个数是±∞,指数部分11全为1时编码值为 2047,偏移值为 1023,指数真正值为 2047 - 1023 = 1024,Infinity = Math.pow(2, 1024), 可以验证下: