最近做了一个报表导出任务,导出的部分数据由前端进行运算。我按需求写出计算公式,并成功导出。
然而,看导出结果,本来至多只有2位小数的数据,出现了多个小数位。
一查,发现我遇上了经典的浮点数运算精度丢失问题。
浮点数运算后精度丢失
我在项目中,遭遇的情况如下:
ini
9900.88-7000 = 2900.879999999999
然后上网一查,才发现,不仅仅减法有,加减乘除运算都存在此问题。以下是网友整理的:
ini
// 加法 =====================
0.1 + 0.2 = 0.30000000000000004
0.7 + 0.1 = 0.7999999999999999
0.2 + 0.4 = 0.6000000000000001
// 减法 =====================
1.5 - 1.2 = 0.30000000000000004
0.3 - 0.2 = 0.09999999999999998
// 乘法 =====================
19.9 * 100 = 1989.9999999999998
0.8 * 3 = 2.4000000000000004
35.41 * 100 = 3540.9999999999995
// 除法 =====================
0.3 / 0.1 = 2.9999999999999996
0.69 / 10 = 0.06899999999999999
为什么会存在浮点数在运算时会丢失精度?
因为计算机底层只有0 和 1, 所以所有的运算最后实际上都是二进制运算。十进制整数利用辗转相除的方法可以准确地转换为二进制数,但浮点数的小数部分却无法用二进制很精准的转换出来,而以近似值来进行运算,所以就存在精度的问题。
至于浮点数怎么转换为二进制数,这里就不赘述了,感兴趣的可以去搜索研究一下。
精度丢失问题解决
知道了浮点数运算存在精度丢失问题,那怎么解决呢?
核心思想:首先将浮点数放大成整数进行运算,再将运算结果缩小为浮点数。
第一步:判断num是否为整数
javascript
function isInteger(num){
return Math.floor(num) === num
}
第二步:如果num不是整数,而是浮点数,则将其放大成整数
ini
/**
* @param floatNum {number} 小数
* @return {object} {times:100, num: 314}
*/
function toInteger(floatNum){
let ret = {times: 1, num: 0}
const strfi = String(floatNum)
const dotPos = strfi.indexOf('.')
const len = strfi.substr(dotPos+1).length
const times = Math.pow(10, len)
const intNum = Number(floatNum.toString().replace('.',''))
ret.times = times
ret.num = intNum
return ret
}
考虑到浮点数相乘,也会出现精度丢失问题,故此处采取去除小数点的方式。
第三步:进行加减乘除运算,确保不丢失精度
javascript
/**
* @param a {number} 运算数1
* @param b {number} 运算数2
* @param op {string} 运算类型,有加减乘除(add/subtract/multiply/divide)
*
*/
function getOperatenNum(a, b, op){
const o1 = isInteger(a)? {times: 1, num: a} : toInteger(a)
const o2 = isInteger(b)? {times: 1, num: b} : toInteger(b)
const n1 = o1.num
const n2 = o2.num
const t1 = o1.times
const t2 = o2.times
const max = t1 > t2 ? t1 : t2
let 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
break
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
break
case 'multiply':
result = (n1 * n2) / (t1 * t2)
return result
break
case 'divide':
result = (n1 / n2) * (t2 / t1)
return result
break
}
}
浮点数运算的正确结果出来了,那么按指定位数进行四舍五入该如何处理呢?
本来想着直接用js提供的toFixed方法就行,没想到这个方法也有坑。
toFixed四舍五入后与预期不符
我们先来看一组浮点数toFixed后的结果。
scss
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 错误
可见,toFixed的四舍五入规则与数学中的规则不同,它使用的其实是银行家舍入规则:所谓银行家舍入法,其实质是一种四舍六入五取偶法。
简单来说就是:四舍六入五考虑,五后非零就进一,五后为零看奇偶,五前为偶应舍去,五前为奇要进一。
显然这种规则不符合我们平常在数据中处理的方式。那如何修正这个问题呢?
符合预期的四舍五入
方法一:Number.EPSILON
为了修正这个问题,我们可以为数值加上最小偏差 Number.EPSILON
。
ES6 在 Number 对象上新增了一个极小的常量:Number.EPSILON,可以利用这个常量,使计算结果更正确。
javascript
function toFixed(num) {
const s = (num + Number.EPSILON).toFixed()
return s;
}
方法二:Math.round()
Math.round(num)是将num取其最接近的整数。
javascript
小数点后第一位<5
正数:Math.round(11.46) = 11
负数:Math.round(-11.46) = -11
小数点后第一位>5
正数:Math.round(11.68) = 12
负数:Math.round(-11.68) = -12
小数点后第一位=5
正数:Math.round(11.5)=12
负数:Math.round(-11.5)=-11
可见,Math.round取舍的方法使用的是四舍五入中的方法,符合数学中取舍的规则。即小数点后第一位大于五全部加,等于五正数加,小于五全不加。
但这种方法的缺点是,只能是1位小数位进行四舍五入,那我们要是有2位、3位小数要进行四舍五入呢?
我们可以做如下变通:
javascript
Math.round(11.687*100)/100
Math.round(11.3458*1000)/1000
也就是说,保留几位小数,就乘以几个零。
javascript
/**
* 四舍五入
* @param num [待处理数字]
* @param decimal [需要保留的小数位]
* @return 最终需要的数字
*/
function toFixed(num, decimal) {
const strfi = String(num)
const dotPos = strfi.indexOf('.')
const len = strfi.substr(dotPos+1).length
if(len <= decimal) {
return num
}
// num的小数位超过保留位数,则进行四色五入
const o = toInteger(num)
const t = o.times/10
const n = o.num/10
const f = Math.round(n) / t
return s
}