感谢美团点评工程师 @Ricas 的原创投稿。
话要从业务代码里的bug说起,大致过程是前端运算 2.07-1 之后结果却是1.0699999999999998,老司机们都知道是浮点数运算的精度丢失导致的,在查看了下具体代码,果然处理不当。因此我深究一番,并诞生了此文。此处重点强调两个认识误区:
浮点数运算精度丢失问题并不是js独有的!
js浮点数的加减乘除运算都可能导致精度丢失问题!
首先不得不说说浮点数的表示方法,任何数在计算机面前都会被处理成二进制,而数字的二进制表示主要有原码、反码、补码。(有点熟悉对不对?哥就是来给你补计算机组成原理的,坏笑~)
原码
原码是计算机中对数字的二进制的定点表示方法,最高位表示符号位,其余位表示数值位。优点显而易见,简单直观;缺点也很明显,不能直接参与运算,可能会报错,如11+(-11) => 10010110 => -22,结果竟然不等于0。(卧槽,瞎搞啊~,以为我没上过学?)所以,原码符号位不能直接参与运算。说到这,给大家个思考题,8位有符号的原码表示范围是多少?自己思考哈~
反码
正数的反码和其原码一样;负数的反码,符号位为1,数值部分按原码取反。例如 [+7]原 = 00000111,[+7]反 = 00000111; [-7]原 = 10000111,[-7]反 = 11111000。
补码
正数的补码和其原码一样;负数的补码为其反码加1。例如 [+7]原 = 00000111,[+7]反 = 00000111,[+7]补 = 00000111; [-7]原 = 10000111,[-7]反 = 11111000,[-7]补 = 11111001。
说到这,你也许会问,哥你这都是讲的整数啊,没说到浮点数啊。别急,弟继续往下看~
浮点数的表示方法
国际标准IEEE 754规定,任意一个二进制浮点数V都可以表示成下列形式:
(-1)^s 表示符号位,当s=0,V为整数;s=1,V为负数;
M 表示有效数字,1≤M<2;
2^E 表示指数位
举个小栗子:
-0.5 => -0.1[二进制]
=> -1.0 * 2^-1
=> (-1)^1 * 1.0 * 2^-1
=> s=1,M=1.0,E=-1
IEEE 754又规定了,浮点数分单精度双精度之分:
对于有效数字M和指数E,这个IEEE 754还规定了:
有效数字M (1)1≤M<2,也即M可以写成1.xxxxx的形式,其中xxxxx表小数部分 (2)计算机内部保存M时,默认这个数第一位总是1,所以舍去。只保存后面的xxxxx部分,节省一位有效数字
指数E(阶码) (1)E为无符号整数。E为8位,范围是0~255;E为11位,范围是0~2047 (2)因为科学计数法中的E是可以出现负数的,所以IEEE 754规定E的真实值必须再减去一个中间数(偏移值),127或1023
有人又要问了,哥,为啥子要有中间数?自己思考哈,弟你自己要学会成长,实在不行你也可以问你谷哥~
Attention! 精华部分来了~
浮点数加法
浮点数的加法运算(不要问哥为啥只讲加法~)分为下面几个步骤:
(1)对阶
顾名思义就是对齐阶码,使两数的小数点位置对齐,小阶向大阶对齐;
(2)尾数求和
对阶完对尾数求和
(3)规格化
尾数必须规格化成1.M的形式
(4)舍入
在规格化时会损失精度,所以用舍入来提高精度,常用的有0舍1入法,置1法
(5)校验判断
最后一步是校验结果是否溢出。若阶码上溢则置为溢出,下溢则置为机器零
实例计算(以单精度为例)
0.2 => 1/8 + 1/16 + 1/128 +... => 1.100110011001100...*2^-3 =>
0(符号位) 01111100 (指数位) (1) 10011001100110011001100(尾数位)
0.4 => 1/4 + 1/8 +1/64 +... => 1.100110011001100...*2^-2 =>
0(符号位) 01111101 (指数位) (1) 10011001100110011001100(尾数位)
这里,细心的同学可能会发现指数位为何是01111100,不是应该是-3,这是因为-3加上了中间值127等于124;所以反算的时候,要用计算值减去中间值得到真正的指数值。
(1)对阶
根据小阶对大阶原则,0.2的阶码向0.4阶码对齐,即0.4的阶码不作调整,0.2的阶码对齐,且尾数做右移处理:
0.2 => 0 01111101 (0)11001100110011001100110
0.4 => 0 01111101 (1)10011001100110011001100
(2)尾数求和
(0)11001100110011001100110 + (1)10011001100110011001100 => (10)01100110011001100110010
(3)尾数规格化
0 01111101 (10)01100110011001100110010 => 0 01111110 (1)00110011001100110011001
⚠️ 最后的0被移出去了,这就是误差产生的根源!
(4)舍入
(5)校验判断
0.2 + 0.4 => 0 01111110 (1)00110011001100110011001 => 1.1999999285/2 => 0.5999999643 (并不等于0.6)
最后发现计算结果果然出现误差,因为在尾数规格化的步骤中可能产生移位误差,看来要想精确运算,不能直接操作浮点数运算啊!最保险的方法是在运算过程中,将浮点数处理成整数进行运算:
/**
* [scaleNum 通过操作其字符串将一个浮点数放大或缩小]
* @param {number} num 要放缩的浮点数
* @param {number} pos 小数点移动位数
* pos大于0为放大,小于0为缩小;不传则默认将其变成整数
* @return {number} 放缩后的数
*/
function scaleNum(num, pos) {
if (num === 0 || pos === 0) {
return num;
}
let parts = num.toString().split('.');
const intLen = parts[0].length;
const decimalLen = parts[1] ? parts[1].length : 0;
// 默认将其变成整数,放大倍数为原来小数位数
if (pos === undefined) {
return parseFloat(parts[0] + parts[1]);
} else if (pos > 0) {
// 放大
let zeros = pos - decimalLen;
while (zeros > 0) {
zeros -= 1;
parts.push(0);
}
} else {
// 缩小
let zeros = Math.abs(pos) - intLen;
while (zeros > 0) {
zeros -= 1;
parts.unshift(0);
}
}
const idx = intLen + pos;
parts = parts.join('').split('');
parts.splice(idx > 0 ? idx : 0, 0, '.');
return parseFloat(parts.join(''));
}
有很多同学将浮点数扩大成整数,直接乘以10^N,其实这也会可能导致误差,例如 0.57*100 => 56.99999999999999;另外除法运算也可能导致误差,5.7/10 => 0.5700000000000001;记住,包含浮点数的加减乘除都可能导致计算误差。
Q&A:
8位有符号的原码表示范围是多少? A:111111111 ~ 01111111 => -127 ~ +127
阶码运算为啥要有中间数? A:指数可以为正数,也可以为负数。为了计算机处理数据的方便,就是希望在加法运算中将减法运算一并处理了,所以处理了负指数的情况,加上中间值来简化CPU中运算器的设计
欢迎关注前端外刊评论,关注前端前沿技术,探寻业界深邃思想。也欢迎给本专栏投稿,原作译作不限,质量上乘就好!