浮点数精度之谜

1 年前

话要从业务代码里的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. (-1)^s 表示符号位,当s=0,V为整数;s=1,V为负数;
  2. M 表示有效数字,1≤M<2;
  3. 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又规定了,浮点数分单精度双精度之分:

  • 32位的单精度浮点数,最高1位是符号位s,接着的8位是指数E,剩下的23位是有效数字M
  • 64位的双精度浮点数,最高1位是符号位s,接着的11位是指数E,剩下的52位为有效数字M

对于有效数字M和指数E,这个IEEE 754还规定了:

  1. 有效数字M (1)1≤M<2,也即M可以写成1.xxxxx的形式,其中xxxxx表小数部分 (2)计算机内部保存M时,默认这个数第一位总是1,所以舍去。只保存后面的xxxxx部分,节省一位有效数字
  2. 指数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:

  1. 8位有符号的原码表示范围是多少? A:111111111 ~ 01111111 => -127 ~ +127
  2. 阶码运算为啥要有中间数? A:指数可以为正数,也可以为负数。为了计算机处理数据的方便,就是希望在加法运算中将减法运算一并处理了,所以处理了负指数的情况,加上中间值来简化CPU中运算器的设计
0
推荐阅读