javascript的精度问题,包含原因和解决方案-面试总结

17,216 阅读9分钟

前言

    作为一个高级前端程序员,数据精度问题一定不能出错。那么我们对于js精度问题,应该了解一些什么呢?

  •  js浮点数运算为什么出现精度失真?

  • 解决的方法有哪些? 

想要解决问题,我们先来弄清楚问题产生的根本原因

原因

    知道原因必须了解,js浮点数运算标准和规则。javascript的浮点数运算就是采用了IEEE 754的标准。

IEEE 754

    IEEE二进制浮点数算术标准IEEE 754是20世纪80年代以来最广泛使用的浮点数运算标准。

     IEEE 754规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。其中javascript采用的是 双精度(64位)浮点运算规则

    

IEEE754存储和运算规则是怎么样的呢?

一个浮点数在计算机中表示为:

Value = sign x exponent x function

也就是浮点数的实际值等于符号位(sign)乘以指数偏移值(exponent)再乘以分数值。

  • sign:最高有效位被指定为正负的符号位, 0表示正数,1表示负数

  • exponent:表示指数偏移值,等于指数值加上某个固定的值。固定值为:2^e - 1,其中e为存储指数的长度,比如32位的是8,64位的为11

  • fraction: 为尾数,可以理解为小数点部分。超出的部分自动进一舍零。

S为符号位,Exp为指数字,Fraction为有效数字。指数部分即使用所谓的偏正值形式表示,偏正值为实际的指数大小与一个固定值(64位的情况是1023)的和。采用这种方式表示的目的是简化比较。因为,指数的值可能为正也可能为负,如果采用补码表示的话,全体符号位S和Exp自身的符号位将导致不能简单的进行大小比较。正因为如此,指数部分通常采用一个无符号的正数值存储。双精度的指数部分是−1022~+1023加上1023,指数值的大小从1~2046(0(2进位全为0)和2047(2进位全为1)是特殊值)。浮点小数计算时,指数值减去偏正值将是实际的指数大小。

数符阶码尾数
32位1823
64位11152

我们来个例子🌰,把10进制数转化位IEEE754 的32位浮点数表示。

例如: 178.125

第一步:将整数部分和小数部分转化为二进制

  1. 整数部分用除2取余的方法,求得:10110010
  2. 小数部分用乘2取整的方法,求得:001
  3. 合起来为: 10110010.001

第二步:转化为二进制浮点数。

    即把小数点移动到只有一位整数:1.0110010001 * 2^111。左移了7位二进制为111。

第三步:确定符号位,阶码,尾数

  1.    数符:由于浮点数是正数,故为0.(负数为1)

  2. 阶码 : 阶码的计算公式:阶数 + 偏移量, 阶码是需要作移码运算,在转换出来的二进制数里,阶数是111(十进制为7),对于单精度的浮点数,偏移值为01111111(127)[偏移量的计算是:2^(e-1)-1, e为阶码的位数,即为8,因此偏移值是127],即:111+01111111 = 10000110

  3. 尾数:小数点后面的数,即0110010001 

    

可能有个疑问:小数点前面的1去哪里了?由于尾数部分是规格化表示的,最高位总是“1”,所以这是直接隐藏掉,同时也节省了1个位出来存储小数,提高精度。

浮点数运算

所以0.1 + 0.2 为什么出现精度失真呢?

首先,十进制的0.1和0.2会被转换成二进制的,但是由于浮点数用二进制表示时是无穷的:

0.1 -> 0.0001 1001 1001 1001...(1100循环)
0.2 -> 0.0011 0011 0011 0011...(0011循环)

EEE 754 标准的 64 位双精度浮点数的小数部分最多支持53位二进制位,所以两者相加之后得到二进制为:

0.0100110011001100110011001100110011001100110011001100 

因浮点数小数位的限制而截断的二进制数字,再转换为十进制,就成了0.30000000000000004。所以在进行算术计算时会产生误差。

解决方案

老生常谈的解决方案有如下几种:

使用toFixed

    最简单的方法就是使用toFixed来处理小数:

    (0.1 + 0.2).toFixed(1) = '0.3'

这种虽然简便,但是存在一些结果不精准的问题。

1.35.toFixed(1) // 1.4 正确
1.335.toFixed(2) // 1.33  错误
1.3335.toFixed(3) // 1.333 错误
1.33335.toFixed(4) // 1.3334 正确
1.333335.toFixed(5)  // 1.33333 错误
1.3333335.toFixed(6) // 1.333333 错误
化整数运算

该方法的主要思路就是把,小数转化为整数再进行计算。

/**
 * 小数点后面保留第 n 位
 *
 * @param x 做近似处理的数
 * @param n 小数点后第 n 位
 * @returns 近似处理后的数 
 */
function roundFractional(x, n) {
  return Math.round(x * Math.pow(10, n)) / Math.pow(10, n);
}

思路:n则是要保留的小数,先扩大10^n,用Math.round把计算结果向上取证处理,然后除以10^n。

结果不仅没有变大,而且确保是整数兜底。如果想向下取证则使用Math.floor函数即可。

转字符串

大部分第三方库就是基于该方法进行封装,并且支持大数的处理。推荐使用。

/*** method **
 *  add / subtract / multiply /divide
 * floatObj.add(0.1, 0.2) >> 0.3
 * floatObj.multiply(19.9, 100) >> 1990
 *
 */
var floatObj = function() {

    /*
     * 判断obj是否为一个整数
     */
    function isInteger(obj) {
        return Math.floor(obj) === obj
    }

    /*
     * 将一个浮点数转成整数,返回整数和倍数。如 3.14 >> 314,倍数是 100
     * @param floatNum {number} 小数
     * @return {object}
     *   {times:100, num: 314}
     */
    function toInteger(floatNum) {
        var ret = {times: 1, num: 0}
        if (isInteger(floatNum)) {
            ret.num = floatNum
            return ret
        }
        var strfi  = floatNum + ''
        var dotPos = strfi.indexOf('.')
        var len    = strfi.substr(dotPos+1).length
        var times  = Math.pow(10, len)
        var intNum = Number(floatNum.toString().replace('.',''))
        ret.times  = times
        ret.num    = intNum
        return ret
    }

    /*
     * 核心方法,实现加减乘除运算,确保不丢失精度
     * 思路:把小数放大为整数(乘),进行算术运算,再缩小为小数(除)
     *
     * @param a {number} 运算数1
     * @param b {number} 运算数2
     * @param digits {number} 精度,保留的小数点数,比如 2, 即保留为两位小数
     * @param op {string} 运算类型,有加减乘除(add/subtract/multiply/divide)
     *
     */
    function operation(a, b, digits, op) {
        var o1 = toInteger(a)
        var o2 = toInteger(b)
        var n1 = o1.num
        var n2 = o2.num
        var t1 = o1.times
        var t2 = o2.times
        var max = t1 > t2 ? t1 : t2
        var result = null
        switch (op) {
            case 'add':
                if (t1 === t2) { // 两个小数位数相同
                    result = n1 + n2
                } else if (t1 > t2) { // o1 小数位 大于 o2
                    result = n1 + n2 * (t1 / t2)
                } else { // o1 小数位 小于 o2
                    result = n1 * (t2 / t1) + n2
                }
                return result / max
            case 'subtract':
                if (t1 === t2) {
                    result = n1 - n2
                } else if (t1 > t2) {
                    result = n1 - n2 * (t1 / t2)
                } else {
                    result = n1 * (t2 / t1) - n2
                }
                return result / max
            case 'multiply':
                result = (n1 * n2) / (t1 * t2)
                return result
            case 'divide':
                result = (n1 / n2) * (t2 / t1)
                return result
        }
    }

    // 加减乘除的四个接口
    function add(a, b, digits) {
        return operation(a, b, digits, 'add')
    }
    function subtract(a, b, digits) {
        return operation(a, b, digits, 'subtract')
    }
    function multiply(a, b, digits) {
        return operation(a, b, digits, 'multiply')
    }
    function divide(a, b, digits) {
        return operation(a, b, digits, 'divide')
    }

    // exports
    return {
        add: add,
        subtract: subtract,
        multiply: multiply,
        divide: divide
    }
}();
第三方库(bignumber.js)源码浅析
x = new Big(123.4567)
 y = Big('123456.7e-3')                 // 'new' is optional
 z = new Big(x)
 x.eq(y) && x.eq(z) && y.eq(z)
 ​
 ​
 0.3 - 0.1                              // 0.19999999999999998
 x = new Big(0.3)
 x.minus(0.1)                           // "0.2"
 x                                      // "0.3"

 function Big(n) {
  var x = this;
 ​
  // 支持函数调用方式进行初始化,可以不使用new操作符
  if (!(x instanceof Big)) return n === UNDEFINED ? _Big_() : new Big(n);
 ​
  // 原型链判断,确认传入值是否已经为Big类的实例
  if (n instanceof Big) {
  x.s = n.s;
  x.e = n.e;
  x.c = n.c.slice();
  } else {
  if (typeof n !== 'string') {
  if (Big.strict === true) {
  throw TypeError(INVALID + 'number');
  }
 ​
  // 确定是否为-0,如果不是,转化为字符串.
  n = n === 0 && 1 / n < 0 ? '-0' : String(n);
  }
 ​
  // parse函数只接受字符串参数
  parse(x, n);
  }
 ​
  x.constructor = Big;
 }

 function parse(x, n) {
  var e, i, nl;
 ​
  if (!NUMERIC.test(n)) {
  throw Error(INVALID + 'number');
  }
 ​
  // 判断符号,是正数还是负数
  x.s = n.charAt(0) == '-' ? (n = n.slice(1), -1) : 1;
 ​
  // 判断是否有小数点
  if ((e = n.indexOf('.')) > -1) n = n.replace('.', '');
 ​
  // 判断是否为科学计数法
  if ((i = n.search(/e/i)) > 0) {
 ​
  // 确定指数值
  if (e < 0) e = i;
  e += +n.slice(i + 1);
  n = n.substring(0, i);
  } else if (e < 0) {
 ​
  // 是一个正整数
  e = n.length;
  }
 ​
  nl = n.length;
 ​
  // 确定数字前面有没有0,例如0123这种0
  for (i = 0; i < nl && n.charAt(i) == '0';) ++i;
 ​
  if (i == nl) {
 ​
  // Zero.
  x.c = [x.e = 0];
  } else {
 ​
  // 确定数字后面的0,例如1.230这种0
  for (; nl > 0 && n.charAt(--nl) == '0';);
  x.e = e - i - 1;
  x.c = [];
 ​
  // 把字符串转换成数组进行存储,这个时候已经去掉了前面的0和后面的0
  for (e = 0; i <= nl;) x.c[e++] = +n.charAt(i++);
  }
 ​
  return x;
 }

 P.plus = P.add = function (y) {
  var t,
  x = this,
  Big = x.constructor,
  a = x.s,
  // 所有操作均转化为两个Big类的实例进行运算,方便处理
  b = (y = new Big(y)).s;
 ​
  // 判断符号是不是不相等,即一个为正,一个为负
  if (a != b) {
  y.s = -b;
  return x.minus(y);
  }
 ​
  var xe = x.e,
  xc = x.c,
  ye = y.e,
  yc = y.c;
 ​
  // 判断是否某个值是0
  if (!xc[0] || !yc[0]) return yc[0] ? y : new Big(xc[0] ? x : a * 0);
 ​
  // 拷贝一份数组,避免影响原实例
  xc = xc.slice();
 ​
  // 填0来保证运算时的位数相等
  // 注意,reverse函数比unshift函数快
  if (a = xe - ye) {
  if (a > 0) {
  ye = xe;
  t = yc;
  } else {
  a = -a;
  t = xc;
  }
 ​
  t.reverse();
  for (; a--;) t.push(0);
  t.reverse();
  }
 ​
  // 把xc放到一个更长的数组中,方便后续循环加法操作
  if (xc.length - yc.length < 0) {
  t = yc;
  yc = xc;
  xc = t;
  }
 ​
  a = yc.length;
 ​
  // 执行加法操作,将数值保存到xc中
  for (b = 0; a; xc[a] %= 10) b = (xc[--a] = xc[a] + yc[a] + b) / 10 | 0;
 ​
  // 不需要检查0,因为 +x + +y != 0 ,同时 -x + -y != 0if (b) {
  xc.unshift(b);
  ++ye;
  }
 ​
  // 删除结尾的0
  for (a = xc.length; xc[--a] === 0;) xc.pop();
 ​
  y.c = xc;
  y.e = ye;
 ​
  return y;
 };

 P.times = P.mul = function (y) {
  var c,
  x = this,
  Big = x.constructor,
  xc = x.c,
  yc = (y = new Big(y)).c,
  a = xc.length,
  b = yc.length,
  i = x.e,
  j = y.e;
 ​
  // 符号比较确定最终的符号是为正还是为负
  y.s = x.s == y.s ? 1 : -1;
 ​
  // 如果有一个值是0,那么返回0即可
  if (!xc[0] || !yc[0]) return new Big(y.s * 0);
 ​
  // 小数点初始化为x.e+y.e,这是我们在两个小数相乘的时候,小数点的计算规则
  y.e = i + j;
 ​
  // 这一步也是保证xc的长度永远不小于yc的长度,因为要遍历xc来进行运算
  if (a < b) {
  c = xc;
  xc = yc;
  yc = c;
  j = a;
  a = b;
  b = j;
  }
 ​
  // 用0来初始化结果数组
  for (c = new Array(j = a + b); j--;) c[j] = 0;
 ​
  // i初始化为xc的长度
  for (i = b; i--;) {
  b = 0;
 ​
  // a是yc的长度
  for (j = a + i; j > i;) {
 ​
  // xc的一位乘以yc的一位,得到最终的结果值,保存下来
  b = c[j] + yc[i] * xc[j - i - 1] + b;
  c[j--] = b % 10;
 ​
  b = b / 10 | 0;
  }
 ​
  c[j] = b;
  }
 ​
  // 如果有进位,那么就调整小数点的位数(增加y.e),否则就删除最前面的0
  if (b) ++y.e;
  else c.shift();
 ​
  // 删除后面的0
  for (i = c.length; !c[--i];) c.pop();
  y.c = c;
 ​
  return y;
 };

 P.round = function (dp, rm) {
  if (dp === UNDEFINED) dp = 0;
  else if (dp !== ~~dp || dp < -MAX_DP || dp > MAX_DP) {
  throw Error(INVALID_DP);
  }
  return round(new this.constructor(this), dp + this.e + 1, rm);
 };
 ​
 function round(x, sd, rm, more) {
  var xc = x.c;
 ​
  if (rm === UNDEFINED) rm = Big.RM;
  if (rm !== 0 && rm !== 1 && rm !== 2 && rm !== 3) {
  throw Error(INVALID_RM);
  }
 ​
  if (sd < 1) {
  // 兜底情况,精度小于1,默认有效值为1
  more =
  rm === 3 && (more || !!xc[0]) || sd === 0 && (
  rm === 1 && xc[0] >= 5 ||
  rm === 2 && (xc[0] > 5 || xc[0] === 5 && (more || xc[1] !== UNDEFINED))
  );
 ​
  xc.length = 1;
 ​
  if (more) {
 ​
  // 1, 0.1, 0.01, 0.001, 0.0001 等等
  x.e = x.e - sd + 1;
  xc[0] = 1;
  } else {
  // 定义为0
  xc[0] = x.e = 0;
  }
  } else if (sd < xc.length) {
 ​
  // xc数组中,在精度之后的纸会被舍弃取整
  more =
  rm === 1 && xc[sd] >= 5 ||
  rm === 2 && (xc[sd] > 5 || xc[sd] === 5 &&
  (more || xc[sd + 1] !== UNDEFINED || xc[sd - 1] & 1)) ||
  rm === 3 && (more || !!xc[0]);
 ​
  // 删除所需精度后的数组值
  xc.length = sd--;
 ​
  // 取整方式判断
  if (more) {
 ​
  // 四舍五入可能意味着前一个数字必须四舍五入,所以这个时候需要填0
  for (; ++xc[sd] > 9;) {
  xc[sd] = 0;
  if (!sd--) {
  ++x.e;
  xc.unshift(1);
  }
  }
  }
 ​
  // 删除小数点后面的0
  for (sd = xc.length; !xc[--sd];) xc.pop();
  }
 ​
  return x;
 }

在正常的逻辑中,我们根据精度舍弃了精度后的值,统一填充0进行表示。

通过内部的round函数的实现可以看到,在最开始我们进行了异常的兜底检测,排除了两种异常的情况。一种是参数错误,直接抛出异常;另一种是精度小于1的情况,在这个时候,定义了兜底的值1。

在big.js中,所有的取整运算都调用了内部的一个round函数。那么,接下来,我们就以API中的round方法为例。这个方法有两个参数,第一个值dp代表着小数后有效值的位数,第二个rm代表了取整的方式。

参考资料: