javascript的精度问题,包含原因和解决方案 |
您所在的位置:网站首页 › js计算金额 › javascript的精度问题,包含原因和解决方案 |
前言 作为一个高级前端程序员,数据精度问题一定不能出错。那么我们对于js精度问题,应该了解一些什么呢? js浮点数运算为什么出现精度失真? 解决的方法有哪些? 想要解决问题,我们先来弄清楚问题产生的根本原因 原因知道原因必须了解,js浮点数运算标准和规则。javascript的浮点数运算就是采用了IEEE 754的标准。 IEEE 754IEEE二进制浮点数算术标准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位182364位11152我们来个例子🌰,把10进制数转化位IEEE754 的32位浮点数表示。 例如: 178.125 第一步:将整数部分和小数部分转化为二进制 整数部分用除2取余的方法,求得:10110010 小数部分用乘2取整的方法,求得:001 合起来为: 10110010.001第二步:转化为二进制浮点数。 即把小数点移动到只有一位整数:1.0110010001 * 2^111。左移了7位二进制为111。 第三步:确定符号位,阶码,尾数 数符:由于浮点数是正数,故为0.(负数为1) 阶码 : 阶码的计算公式:阶数 + 偏移量, 阶码是需要作移码运算,在转换出来的二进制数里,阶数是111(十进制为7),对于单精度的浮点数,偏移值为01111111(127)[偏移量的计算是:2^(e-1)-1, e为阶码的位数,即为8,因此偏移值是127],即:111+01111111 = 10000110 尾数:小数点后面的数,即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 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 != 0 if (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代表了取整的方式。 参考资料: 浮点数的二进制表示(IEEE 754标准) IEEE 754 - 维基百科,自由的百科全书 JS中浮点数精度问题 - 掘金 segmentfault.com/a/119000001… |
今日新闻 |
推荐新闻 |
CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3 |