背景
在浏览器控制台窗口输入以下两行代码,结果出乎意料
0.1 + 0.2 > 0.3 // true
0.1 + 0.2 = 0.30000000000000004
0.1 * 0.1 = 0.010000000000000002
复制代码
前置知识
- 在计算机的世界里,应该是只有二进制数据的,不是 0 就是 1,那么为了表达生活中最为常见的十进制数据,就会有个转换过程
十进制转为二进制
- 十进制转换为二进制这个过程整体总结就是:
整数采用
整数除 2 取余,直到商为 0 时为止,将余数逆序排列
,小数采用小数部分乘以 2,取整,直到得到小数部分 0 或者达到所要求的精度为止,将整数顺序排列
二进制转为十进制
// 以二进制 10101101.1101 为例
// 针对整数部分 10101101 计算逻辑如下
// ← 从右往左
1 * 2^0 + 0 * 2^1 + 1 * 2^2 + 1 * 2^3 + 0 * 2^4 + 1 * 2^5 + 0 * 2^6 + 1 * 2^7
= 1 + 0 + 4 + 8 + 0 + 32 + 0 + 128
= 173
// 小数部分 1101 计算逻辑如下
// 从左往右 →
1 * 2^-1 + 1 * 2^-2 + 0 * 2^-3 + 1 * 2^-4
= 1/2 + 1/4 + 0 + 1/16
= 13/16
= 0.8125
复制代码
科学计数法
- 十进制 173.8125 的科学计数法为 1.738125 * 10^2
- 十进制 173.8125 对应的二进制 10101101.1101,进一步可以使用二进制的科学计数法来表示,对应的二进制科学计数法为 1.01011011101 * 2^7,跟十进制类似,将底数 10 换为了 2,7 则代表小数点往右多少位
重点:1.01011011101 * 2^7 为二进制,将其转换为 10 进制的过程为,先将 1.01011011101 做为 2 进制转换为 10 进制,得到 1.35791015625,然后将其乘以 2^7 (也就是 1.35791015625 * 128),最后得到的十进制为 173.8125
二进制的边界问题
n 位二进制可表示的最大数值范围,是 n 位的每一位都为 1,对应十进制为 2^n – 1
// 8 位二进制 11111111 可表示的最大值,有两种计算方法
// 1、累加
1*2^0 + 1*2^1 + 1*2^2 + 1*2^3 + 1*2^4 + 1*2^5 + 1*2^6 + 1*2^7 = 127
// 2、2^n - 1
2^8 - 1 = 127
复制代码
IEEE 754 规范-双精度(64位)
-
众所周知 JS 仅有 Number 这个数值类型,而其严格遵循 ECMAScript 规范定义的 IEEE754 64 位双精度浮点数规则。而浮点数表示方式具有以下特点:
- 浮点数可表示的值范围比同等位数的整数表示方式的值范围要大得多
- 浮点数无法精确表示其值范围内的所有数值,而有符号和无符号整数则是精确表示其值范围内的每个数值
- 浮点数只能精确表示 m*2e 的数值
- 当 biased-exponent 为 2e-1-1 时,浮点数能精确表示该范围内的各整数值
- 当 biased-exponent 不为 2e-1-1 时,浮点数不能精确表示该范围内的各整数值
由于部分数值无法精确表示(存储),于是在运算统计后偏差会愈见明显
-
IEEE 754 浮点数由三个部分组成,分别是 sign bit (符号位)、exponent bias (指数偏移值) 和 fraction (分数值),因此一个 JavaScript 的 number 表示二进制应该是如下格式:
- sign bit:1bit,0 表示正数,1 表示负数
- exponent bias:11bit,表示次方数,在(二进制的)科学计数法中定义 2 的多少次幂,需要移动的位数;由于会存在正负数,所以这里用了一个偏移的方式处理,即真正的指数 +1023,这样的话就表示了【-1023 ~ 1024】;而 -1023 也就是全 0,1024 就是全 1
- fraction:表示精确度(小数部分,规范中会省略个位数上的 1 ),52bit,这里需要注意的是由于小数点前面 1 位必须为 1(隐藏位),所以实际上是 52+1=53 位
-
一个浮点数 (Value) 的表示其实可以这样表示:
value = sign * exponent * fraction
-
十进制转换为 IEEE 754 标准主要转换过程主要经历 3 个过程
- 转换为二进制表示
- 将转换后的二进制通过科学计数法表示
- 将通过科学计数法表示的二进制转换为 IEEE 754 标准表示
-
十进制 0.1 转换结果如下
(1)将 0.1 转换为二进制 0.00011001100110011001100110011001100110011001100110011001(1001 无限循环)
(2)将上述二进制转换为科学计数法得到:2^-4 * 1.1001100110011001100110011001100110011001100110011001(1001无限循环)
(3)从科学计数法中可以得到 sign 值为 0、exponent 值为 -4,-4 + 1023(固偏移定值,64 位的情况下为 1023)= 1019 再转换为 11 位二进制为 01111111011
(4)fraction 为科学计数法小数点后面的值 1001100110011001100110011001100110011001100110011010(fraction 最长为 52 位,若小数点后的长度不够 52 位用 0 补齐,但这里超过了 52 位所以这里产生了截断,截断时由于第 53 位为 1 所以会产生进位,小数点左边的 1 计算机不会自动存储,在再次换算为 10 进制时会自动加上小数点左边的 1)
(5)最终得到 0.1 存储在计算机中的二进制为:0 01111111011 1001100110011001100110011001100110011001100110011010在第(3)步中可以看到 0.1 在存储的过程中被截断了,因为计算机最多只能存 52 位,所以这里就产生了精度偏差,这样当再次将二进制换算为十进制时就不是原来的值
能被转化为有限二进制小数的十进制小数的最后一位必然以 5 结尾(因为只有 0.5 * 2 才能变为整数,即二进制能精确地表示位数有限且分母是 2 的倍数的小数),所以十进制中一位小数 0.1 ~ 0.9 当中除了 0.5 外的值在转化成二进制的过程中都丢失了精度
-
将上图中的二进制再次转换为十进制的的步骤:
- sign 为 0 代表正数 +
- 指数偏移值(exponent)为 01111111011 转换为十进制为 1019,这时还要减去之前加上的固定偏移值 1023 得到最终指数的值为 -4
- 小数点右侧的二进制(significand)为1001100110011001100110011001100110011001100110011010,由于存储时省略了小数点左边的 1 所以需要加上,得到1.1001100110011001100110011001100110011001100110011010
- 最终用上面的公式带入得到 value = +2^-4 * 1.1001100110011001100110011001100110011001100110011010 最后将这串二进制转换为 10 进制为 0.100000000000000005551115123126 即可
-
所以前面的公式具体一点可以写成下面这样:其中的 e 为指数偏移值
-
前面计算指数偏移值时,会加上一个固定偏移值 1023,该值的计算公式如下:
其中的 e 为存储指数的比特的长度,在 64 位双精度二进制中存储指数的比特长度 e 为 11,从而可以计算得到该固定偏移值为 1023 -
采用指数的实际值(例如前面的 -4)加上固定偏移值的办法表示浮点数的指数,好处是可以用长度为 e 个比特的无符号整数来表示所有的指数取值,这使得两个浮点数的指数大小的比较更为容易,实际上可以按照字典次序比较两个浮点表示的大小
指数的实际值可能为正可能为负,所以 11 比特中需要有一位用来表示符号位,如果采用补码表示的话,全体符号位 sign 和指数自身的符号位将导致不能简单的进行大小比较。所以就采用了这种加上固定偏移值的方式,中文称作阶码
-
所以 64 位双精度二进制中指数部分本来能表示的范围为 -1023 ~ +1023(2^10 – 1,第11位为符号位),在加上一个固定偏移值 1023 后,指数偏移值范围就为 0 ~ 2046,这样指数偏移值都为正数,在存储时就不需要关心符号的问题了,我们就可以直接用 11 位来存储指数偏移值,最终存储时指数偏移值的范围就为 0 ~ 2^11 – 1(2047)
-
另外在重新转换为 10 进制时,为了还原指数实际的值,指数偏移值需要减去固定偏移值 1023,最终指数在未加上固定偏移值前的的实际值的范围为 -1023 ~ 1024,其中 -1023 用来表示 0,1024 用来表示无穷,除去这两个值指数的范围为 -1022 ~ 1023
-
前面的公式还需要在细分一下,一共有两个公式:
-
当指数偏移值不为 0 时,使用下面的公式
-
当指数偏移值为 0 时,使用下面的公式
sign e 指数偏移值 = 实际值+固定偏移1023 fraction 计算过程 JS 中的值 0 11111111110(1023+1023) 1111111111111111111111111111111111111111111111111111 (-1)^0 x 2^1024=1.7976931348623157e+308 Number.MAX_VALUE 0 00000000000(-1023+1023) 0000000000000000000000000000000000000000000000000001 (-1)^0 x 2^-52 x 2^-1022=5e-324 Number.MIN_VALUE 最小的正数 0 10000110011(52+1023) 1111111111111111111111111111111111111111111111111111 (-1)^0 x 2^53=9007199254740991 Number.MAX_SAFE_INTEGER 1 10000110011(52+1023) 1111111111111111111111111111111111111111111111111111 (-1)^1 x 2^53=-9007199254740991 Number.MIN_SAFE_INTEGER 0 11111111111(1024+1023) 0000000000000000000000000000000000000000000000000000 (-1)^0 x 1 x 2^1024 Infinity 正无穷 1 11111111111(1024+1023) 0000000000000000000000000000000000000000000000000000 (-1)^1 x 1 x 2^1024 -Infinity 负无穷 0 00000000000 0000000000000000000000000000000000000000000000000000 (-1)^0 x 0 x 2^-1022 0 1 00000000000 0000000000000000000000000000000000000000000000000000 (-1)^1 x 0 x 2^-1022 -0
在计算过程中,有一步没有写出来,以第一行为例,看看 2^1024 是怎么来的:
1.(52个1) × 2^(2046 – 1023) = 1.(52个1) × 2^1023 = (53 个 1) × 2^(1023-52) = 53 位二进制表示的十进制为 (2^53 – 1) × 2^971
还有一种方法就是直接将 1.fraction 转换为 10 进制再 ✖️ 指数部分,如上面也可以写作 1.(52个1) × 2^(2046 – 1023) = 1.9999999999999998 * 2^1023 -
-
大数危机,为什么 2^53-1 是最大安全整数呢?比它大会怎样?
-
以 2^53 来说明一下为什么 2^53-1 是最大安全整数,安全在哪里
2^53 转二进制 => 100000000000000000000000000000000000000000000000000000(53个0)
转为科学计数法 => 1.00000000000000000000000000000000000000000000000000000(53个0)×2^53
存入计算机 => 尾数位只有52位所以截掉末尾的0只能存52个02^53+1 转二进制 => 100000000000000000000000000000000000000000000000000001(52个0)
转为科学计数法 => 1.00000000000000000000000000000000000000000000000000001(52个0)×2^53
存入计算机 => 尾数位只有52位所以截掉末尾的1只能存52个0 -
可以看出来,2^53 和 2^53+1 在计算机中的存储的分数部分、指数部分都相同,所以两个不同的数在计算机中的存储是一样的,当大于这安全值时就可能会出现精度丢失,这样就非常的不安全了。所以 2^53-1 是 JavaScript 里面的最大安全整数
-
-
既然小数在计算机中会有精度丢失,那为什么 num = 0.1 能得到 0.1 呢?
- Number.toPrecision() 跟 toFixed 类似,表示要保留几位有效数字。前面我们知道 0.1 在存储的过程中其实是丢失了精度的,因为它在转换为 2 进制时为无限循环,之所以我们写 0.1 能得到 0.1 是因为 js 帮做了处理。
-
const num = 0.1 为什么能得到 0.1 呢?
Number.toPrecision() 跟 toFixed 类似,表示要保留几位有效数字。前面我们知道 0.1 在存储的过程中其实是丢失了精度的,因为它在转换为 2 进制时为无限循环,之所以我们写 0.1 能得到 0.1 是因为 js 帮我们做了处理
从图中看到我们让 0.1 保留 25 位有效数字,得出来的结果并不是 0.1,所以 js 默认帮我们做了截断。那么这个问题就可以转化为:双精度浮点数是按什么规则来截断的呢? 在双精度浮点数的英文wiki中可以找到中可以知道- 如果十进制有效数字未超过 15 位,那么存储和读取的时候十进制都一样,js 不会截断
- 如果十进制有效数字至少有 17 位,由于分数位(presicion)最多只能存储 53 位,最终以该 53 位为准,后面的就会被截断,截断后重新计算出来的就是一个截断后的数字,例如 0.1 跟 0.10000000000000001(17)其实是相等的,因为后者在存储时转换为双精度浮点数的形式跟前者的双精度浮点数的形式是一样的,所以后者在存储时存储的就是 0.1
-
为什么 例如1.335.toFixed(2) 得到的是 1.33?
-
1.335 在我们存储为数字时其实存储的双精度浮点数就为 1.335,如下图中的双精度浮点数的表示,虽然在存储时会把 53 位及后面的截断,但当把下面的双精度浮点数换算为 10 进制,其实得到的就是 1.335
-
为什么我们调用 toPrecision 还能得到 1.335 被截断前的值呢?首先我们存储的 1.335 是 Number 格式,Number 格式受最大存储位数的限制,所以 1.335 会被截断,但是我们在调用 toPrecision() 时可以看到其实是以字符串的形式表示出来的,字符串不管再长都能表示出来,所以我们可以得到被截断前的值。当你再将截断前的值转换为 Number 时,由于受双精度浮点数存储位数的限制,存储时就又会得到截断后的值
-
0.1 + 0.2 !== 0.3 -> true 分析
有了以上铺垫没,既然 0.1 + 0.2 !== 0.3,因此不仅是 JavaScript 会产生这种问题,只要是采用 IEEE 754 双精度浮点数编码方式来表示浮点数的都会产生这类问题。分析过程
// 0.1
e = -4;
m = 1.1001100110011001100110011001100110011001100110011010 (52位)
// 0.2
e = -3;
m = 1.1001100110011001100110011001100110011001100110011010 (52位)
复制代码
这里的 m 指的是小数点后的 52 位,而小数点前的整数部分 1 就是前面说过的隐藏位
然后把它们相加,这里有一个问题就是指数不一致时应该怎么处理,一般是往右移,因为即使右边溢出了,损失的精度远远小于左移时的溢出
e = -4;
m = 1.1001100110011001100110011001100110011001100110011010 (52位)
+
e = -3;
m = 1.1001100110011001100110011001100110011001100110011010 (52位)
复制代码
转化
e = -3;
m = 0.1100110011001100110011001100110011001100110011001101 (52位)
+
e = -3;
m = 1.1001100110011001100110011001100110011001100110011010 (52位)
复制代码
得到
e = -3;
m = 10.0110011001100110011001100110011001100110011001100111 (52位)
复制代码
保留一位整数
e = -2;
m = 1.00110011001100110011001100110011001100110011001100111 (53位)
复制代码
此时已经溢出来了(超过了 52 位),那么这时就要做四舍五入了,那怎么舍入才能与原来的数最接近呢?比如 1.101 要保留 2 位小数则结果有可能是 1.10 和 1.11,这时两个都是一样近,取哪一个呢?规则是保留偶数的那一个,在这里就是保留 1.10
回到上面之前,结果就是
m = 1.0011001100110011001100110011001100110011001100110100 (52位)
复制代码
然后得到最终的二进制数
2 ^ -2 * 1.0011001100110011001100110011001100110011001100110100
= 0.010011001100110011001100110011001100110011001100110100
复制代码
最终转化为十进制就是:0.30000000000000004
解决方案
特殊常数 Number.EPSILON
根据规格,Number.EPSILON 表示 1 与大于 1 的最小浮点数之间的差,Number.EPSILON 实际上是 JavaScript 能够表示的最小精度,误差如果小于这个值就可以认为已经没有意义了,即不存在误差
比较两个数字与 Number.EPSILON 之间的绝对差值
function numbersEqual(num1, num2) {
return Math.abs(num1 - num2) < Number.EPSILON
}
const a = 0.1+0.2, b=0.3;
console.log(numbersEqual(a, b)); // true
复制代码
考虑兼容性问题,在 chrome 中支持这个属性,但 IE 并不支持(IE10 不兼容),所以还要解决 IE 的不兼容问题
Number.EPSILON=(function(){ //解决兼容性问题
return Number.EPSILON?Number.EPSILON:Math.pow(2,-52);
})();
//上面是一个自调用函数,当 JS 文件刚加载到内存中就会去判断并返回一个结果,相比
//if(!Number.EPSILON){
// Number.EPSILON=Math.pow(2,-52);
//}
// 这种代码更节约性能也更美观
function numbersequal(a,b){
return Math.abs(a-b) < Number.EPSILON;
}
// 接下来再判断
const a=0.1+0.2, b=0.3;
console.log(numbersequal(a, b)); // true
复制代码
toFixed()
- toFixed(num) 方法可把 Number 四舍五入为指定小数位数的数字
- 参数描述:num 必需,规定小数的位数,是 0 ~ 20 之间包括 0 和 20的值,有些实现可以支持更大的数值范围,若省略了该参数将用 0 代替
- 特别注意:toFixed() 返回一个数值的字符串表现形式,该数值在必要时进行四舍五入,另外在必要时会用 0 来填充小数部分,以便小数部分有指定的位数。若数值大于 1e+21,该方法会简单调用 Number.prototype.toString() 方法并返回一个指数记数法格式的字符串
- 可以这样解决精度问题
parseFloat((数学表达式).toFixed(digits)); // toFixed() 精度参数须在 0 与20 之间
// 运行
parseFloat((0.1 + 0.2).toFixed(10))// 结果为 0.3
parseFloat((0.3 / 0.1).toFixed(10)) // 结果为 3
parseFloat((0.7 * 180).toFixed(10))// 结果为 126
parseFloat((1.0 - 0.9).toFixed(10)) // 结果为 0.1
parseFloat((9.7 * 100).toFixed(10)) // 结果为 970
parseFloat((2.22 + 0.1).toFixed(10)) // 结果为 2.32
复制代码
原生封装
function add(num1, num2) {
const num1Digits = (num1.toString().split('.')[1] || '').length;
const num2Digits = (num2.toString().split('.')[1] || '').length;
const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
return (num1 * baseNum + num2 * baseNum) / baseNum;
}
function roundFractional(x, n) {
return Math.round(x * Math.pow(10, n)) / Math.pow(10, n);
}
复制代码
第三方类库
math.js、D.js、bigNumber.js、decimal.js、big.js 等