图片来源 bz.zzzmh.cn/index
前言
我们知道在 JavaScript
中 0.1 + 0.2 !== 0.3
,究其原因,是因为浮点数精度
问题导致。以前自己对这个问题也只是一知半解,最近想对这问题有个全方位的认识,所以想写下这篇文章记录一下。
ECMAScript 的数字类型
ECMAScript
中的 Number
类型使用 双精度 IEEE 754 64位浮点 标准来表示整数
和浮点数值
。所谓 IEEE754 标准,全称 IEEE二进制浮点数算术标准(ANSI/IEEE Std 754-1985)
,又称 IEC 60559:1989,微处理器系统的二进制浮点数算术
。
IEEE 754 规定了四种表示浮点数值的方式:单精确度(32位)
、双精确度(64位)
、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。
浮点数转二进制
例如 0.75 用二进制
表示:
0.75 = a * 2^-1 + b * 2^-2 + c * 2^-3 + d * 2^-4 + ...
因为是二进制,所以这里的系数 a、b、c、d
的值不是 0
就是 1
。
两边同时乘以 2 :
1 + 0.5 = a * 2^0 + b * 2^-1 + c * 2^-2 + d * 2^-3... (所以 a = 1)
剩下的:
0.5 = b * 2^-1 + c * 2^-2 + d * 2^-3...
再同时乘以 2:
1 + 0 = b * 2^0 + c * 2^-2 + d * 2^-3... (所以 b = 1)
所以 0.75 用二进制表示就是 0.ab,也就是 0.11。
0.1 用二进制表示:
0.1 = a * 2^-1 + b * 2^-2 + c * 2^-3 + d * 2^-4 + e * 2^-5 ...
0 + 0.2 = a * 2^0 + b * 2^-1 + c * 2^-2 + ... (a = 0)(取出整数部分0)
0 + 0.4 = b * 2^0 + c * 2^-1 + d * 2^-2 + ... (b = 0)(取出整数部分0)
0 + 0.8 = c * 2^0 + d * 2^-1 + e * 2^-2 + ... (c = 0)(取出整数部分0)
0 + 0.6 = d * 2^0 + e * 2^-1 + f * 2^-2 + ... (d = 1)(取出整数部分1)
0 + 0.2 = e * 2^0 + f * 2^-1 + g * 2^-2 + ... (e = 1)(取出整数部分1)
0 + 0.4 = f * 2^0 + g * 2^-1 + h * 2^-2 + ... (f = 0)(取出整数部分0)
0 + 0.8 = g * 2^0 + h * 2^-1 + i * 2^-2 + ... (g = 0)(取出整数部分0)
0 + 0.6 = h * 2^0 + i * 2^-1 + j * 2^-2 + ... (h = 1)(取出整数部分1)
0 + 0.2 = i * 2^0 + j * 2^-1 + k * 2^-2 + ... (i = 1)(取出整数部分1)
....
可以看出 0.1 用二进制表示就是 0.00011001100110011……((0011)循环
)
IEEE754 浮点数存储
我们以 64位双精度浮点数
为例:
如上图所示,这 64 个二进制位的内存编号从高到低 (从63到0), 共包含如下几个部分:
sign: 符号位, 即图中蓝色的方块;
biased exponent: 偏移后的指数位, 即图中绿色的方块;
fraction: 尾数位, 即图中红色的方块;
符号位: sign
符号位: 占据最高位(第63位)这一位, 用于表示这个浮点数是正数还是负数, 为0表示正数, 为1表示负数。
对于十进制数20.5, 存储在内存中时, 符号位应为0, 因为这是个正数。
偏移后的指数位: biased exponent
指数位占据第 52 位到第 62 位这 11位, 也就是上图的绿色部分,用于表示以 2 位底的指数。
尾数位:fraction
尾数位占据剩余的51
位到 0
位这 52 位, 用于存储尾数。
以 0.1 的二进制 0.00011001100110011…… 这个数来说,用二进制可以表示为:
1.1001100110011…… * 2^-4
其中 sign
就是 0,exponent
就是 2^-4,fraction
就是 1.1001100110011…,
有时候尾数会不够填满尾数位(即图中的红色格子),尾数不够52位
此时, 需要在 低位补零。
浮点数 (Value) 通用表达式
V = (-1)^S * (1 + Fraction) * 2^E;
(-1)^S
表示符号位,当 S = 0,V 为正数;当 S = 1,V 为负数。
(1 + Fraction)
,所有的浮点数都可以表示为 1.xxxx * 2^xxx 的形式,前面的一定是 1.xxx,所以没必要存储这个 1 了,像 0.1 这样直接存 1 后面的 1001100110011…… 好了,可以省略前面的 1 和小数点
,等读取的时候再把第一位1加上去。所以52位有效数字实际可以存储53位。
2^E
,例如 1020.75,对应二进制数就是 1111111100.11,对应二进制科学计数法就是 1 * 1.11111110011 * 2^9,E 的值就是 9,而如果是 0.1 ,对应二进制是 1 * 1.1001100110011…… * 2^-4, E 的值就是 -4,也就是说,E 既可能是负数,又可能是正数,那我们该怎么储存这个 E 呢 ?
在 64 位浮点数中,指数位有 11 位,它能表示的数字是从 0 到 2 的 11 次方,也就是 2048
。
但是指数有正有负,所以我们需要把 2048 这个数字从中间劈开,一半表示正数
,一半表示负数
,所以就是 -1024 到 +1023
(中间还有个0
),只能表示 -1024 到 1023 这 2048 个数字。
那么怎么记录负数呢?一种作法是把高位置 1
,这样我们只要看到高位是 1 的就知道是负数了,所谓高位置 1 就是说把 0 到 2047 这么多个数字劈成两半,从 0 到 1023 表示正数,从 1024 到 2047 表示负数。但是这种作法会带来一个问题:当你比较两个数的时候,比如 1300 和 30,谁更大?机器会觉得 1300 更大,但实际上 1300 是个负数,它应该比 30 小才对。所以为了解决这个麻烦,人们发明了另外一种方法:把所有数字都给它加上 1024 ,这样-1024 加上 1024 就变成了0,而 1023 加上 1024 变成了 2047,这样的话,再比较大小,就不存在负数比正数大的情况了。
我们要得到原来的数字你只要再把指数减去 1024 就得到了原来的数字。比如说你读到 0,那么减去1024,就得到了负指数-1024,读到 2047,减去1024,就得到了 1023 。
那为什么 双精度 64 位指数偏移是 1023
,不是 1024 ?因为人们为了特殊用处,不允许使用 0 和 2048 这两个数字表示指数,少了 2 个数字,所以采用 1023 了。
所以呢,真到实际存储的时候,我们并不会直接存储 E,而是会存储 E + bias
,当用 11 位的时候,这个 bias
就是 1023。如果要存储一个浮点数,我们存 S 和 Fraction 和 E + bias 这三个值就好了
。
比如 0.1 ,对应二进制是 1 * 1.1001100110011…… * 2^-4, Sign 是 0,E + bias 是 -4 + 1023 = 1019,1019 用二进制表示是 01111111011,Fraction 是 1001100110011……
对应 64 位的完整表示就是:
0 01111111011 1001100110011001100110011001100110011001100110011010
同理, 0.2 表示的完整表示是:
0 01111111100 1001100110011001100110011001100110011001100110011010
IEEE 754 计算网站 IEEE 754 Calculator
所以当 0.1 存下来的时候,就已经发生了精度丢失,当我们用浮点数进行运算的时候,使用的其实是精度丢失后的数。
为了加深理解, 我们再反向推导 0.2 一遍: 符号位是 0: 所以这是个正数;尾数是: 1001100110011001100110011001100110011001100110011010,去掉后面的补零, 再加上隐藏的整数部分1. 得到完整的尾数(含隐藏的整数部分)为: 1.100110011001100110011001100110011001100110011001101,偏移后的指数位为: 01111111100, 转换为十进制为 1020 , 减去偏移量1023 , 得到真正的指数是 -3。所以, 最后得到的浮点数 = 尾数(含隐藏的整数部分) * 以2为底的指数次幂=二进制的: 1.100110011001100110011001100110011001100110011001101 * 2^-3== 把小数点向右移动4位=二进制 0.001100110011001100110011001100110011001100110011001101=十进制 0.2。(进制转换网站)
浮点数的运算
关于浮点数的运算,一般由以下五个步骤完成:对阶、尾数运算、规格化、舍入处理、溢出判断
。我们来简单看一下 0.1 和 0.2 的计算。
首先是对阶,所谓对阶,就是把阶码调整为相同,比如 0.1 是 1.1001100110011…… * 2^-4
,阶码是 -4,而 0.2 就是 1.10011001100110...* 2^-3
,阶码是 -3,两个阶码不同,所以先调整为相同的阶码再进行计算,调整原则是小阶对大阶,也就是 0.1 的 -4 调整为 -3,对应变成 0.11001100110011…… * 2^-3
接下来是尾数计算:
0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
————————————————————————————————————————————————————————
10.0110011001100110011001100110011001100110011001100111
我们得到结果为 10.0110011001100110011001100110011001100110011001100111 * 2^-3
。将这个结果处理一下,即结果规格化,变成 1.0011001100110011001100110011001100110011001100110011(1) * 2^-2
,括号里的 1 意思是说计算后这个 1 超出了范围,所以要被舍弃了。再然后是舍入,四舍五入对应到二进制中,就是 0 舍 1 入,因为我们要把括号里的 1 丢了,所以这里会进一,结果变成
1.0011001100110011001100110011001100110011001100110100 * 2^-2
。
所以最终的结果存成 64 位就是:
0 01111111101 0011001100110011001100110011001100110011001100110100
将它转换为10进制数就得到 0.30000000000000004440892098500626
,因为两次存储时的精度丢失加上一次运算时的精度丢失,最终导致了 0.1 + 0.2 !== 0.3。
toPrecision 方法
toPrecision()
方法以指定的精度返回该数值对象的字符串表示。
// 以指定的精度返回该数值对象的字符串表示
(0.1 + 0.2).toPrecision(21)
=> "0.300000000000000044409"
(0.3).toPrecision(21)
=> "0.299999999999999988898"
(0.1).toPrecision(16)
=> '0.1000000000000000'
(0.1).toPrecision(21)
=> '0.100000000000000005551'
MAX_SAFE_INTEGER
Number.MAX_SAFE_INTEGER
常量表示在 JavaScript 中最大的安全整数(maxinum safe integer)(2^ 53 - 1
)。
Number.MAX_SAFE_INTEGER
=> 9007199254740991
最大的安全整数为什么是 2^ 53 - 1
? “安全”意思是说能够 one-by-one
表示的整数,也就是说在(-2^53, 2^53)
范围内,双精度数表示和整数是一对一的,反过来说,在这个范围以内,所有的整数都有唯一的浮点数表示,这叫做安全整数。最大的安全整数必然是 Fraction
全部是 1 的浮点数,Fraction 全为1,即为 1.11…1(小数部分共有52位1),该浮点数换算为二进制后记为111…1(共53位)。该二进制数换算为十进制数字即为2^52+2^51+…+2^1+2^0 一个等比数列求和的计算~结果为2^ 53 - 1。
最大安全数的二进制 const MAX_SAFE_NUM_BINARY = (2^53-1).toString(2);
=> 11111111111111111111111111111111111111111111111111111
// 科学计数法表示
const numE = 1.1111111111111111111111111111111111111111111111111111e52
// 省去整数位,刚好是52位1
=> "1111111111111111111111111111111111111111111111111111"
根据IEEE754标准,双精度浮点数中,尾数最多只有 52 位,当 52 位都为 1 时已经是能表示的最大数值。
(Math.pow(2,53)-1).toString(2)
=> '11111111111111111111111111111111111111111111111111111'
Math.pow(2,53).toString(2)
=> '100000000000000000000000000000000000000000000000000000'
我们试着给2^53 - 1 加 1:
(2^53-1) + 1
52 位 1 + 51 位0 和一位 1
=> "1111111111111111111111111111111111111111111111111111" + "0000000000000000000000000000000000000000000000000001" // 注意结果有53位,显然是要进位的
=> "10000000000000000000000000000000000000000000000000000" // 把原有的整数加上进位会得到这个结果
=> "10.0000000000000000000000000000000000000000000000000000e52" // 整数保留一位后得到新的科学计数法结果,注意尾数有53位0,显然不符合规范
=> "1.00000000000000000000000000000000000000000000000000000e53" // 由于尾数最多只能用52位表示,我们需要舍去最后一个0 // 结果是:整数位一位1,52位尾数均为0,指数是53
=> "1.0000000000000000000000000000000000000000000000000000e53" // 虽然2^53次方仍然可以正确标识出来,但是从这里开始已经出现了精度丢失
Number.EPSILON
Number.EPSILON
属性表示 1 与Number可表示的大于 1 的最小的浮点数之间的差值。
x = 0.2;
y = 0.3;
z = 0.1;
equal = (Math.abs(x - y + z) < Number.EPSILON);
引入一个这么小的量,目的在于为浮点数计算设置一个误差范围,如果误差能够小于Number.EPSILON,我们就可以认为结果是可靠的。
为什么 x=0.1 能得到 0.1?
存储二进制时小数点的偏移量最大为 52 位,最多可表示的十进制为 9007199254740992,对应科学计数尾数是 9.007199254740992,这也是 JS 最多能表示的精度。它的长度是 16
,所以可以使用 toPrecision(16) 来做精度运算,js自动做了这一部分处理,超过的精度会自动做凑整处理。于是就有
0.10000000000000000555.toPrecision(16) //0.1000000000000000 去掉末尾的零后正好为0.1
但你看到的 0.1
实际上并不是 0.1
。
0.1.toPrecision(21)=0.100000000000000005551
大数相加
9999999999999999 == 9999999999999999 +1 ===true ?,16位和17位数竟然相等,大整数的精度丢失和浮点数本质上是一样的。要想解决大数的问题你可以引用第三方库 bignumber.js,原理是把所有数字当作字符串,重新实现了计算逻辑,缺点是性能比原生的差很多。
var addStrings = function(num1, num2) {
let i = num1.length - 1, j = num2.length - 1, add = 0;
const ans = [];
while (i >= 0 || j >= 0 || add != 0) {
const x = i >= 0 ? num1.charAt(i) - '0' : 0;
const y = j >= 0 ? num2.charAt(j) - '0' : 0;
const result = x + y + add;
ans.push(result % 10);
add = Math.floor(result / 10);
i -= 1;
j -= 1;
}
return ans.reverse().join('');
};
这里 num1.charAt(i) - '0'
是转为数字类型。两个数字字符运算,两个字符会提升为对应 ASCII
码值,而 ASCII 码里面 '0' 对应为 48,'2'对应 50,所以 '2' - '0' = 50 - 48 = 2, '0' - '0' = 48 - 48 = 0,这样的话,字符数字
转为对应的数字类型的最快方法就是减去对应的字符'0'。