前言
作为一个高级前端程序员,数据精度问题一定不能出错。那么我们对于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位 | 1 | 8 | 23 |
64位 | 1 | 11 | 52 |
我们来个例子🌰,把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 <= 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 != 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
代表了取整的方式。
参考资料: