浮点数运算精度丢失、toFixed四舍五入与预期不符问题一次性解决!

最近做了一个报表导出任务,导出的部分数据由前端进行运算。我按需求写出计算公式,并成功导出。

然而,看导出结果,本来至多只有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
}
相关推荐
也无晴也无风雨28 分钟前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang1 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、4 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤5 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui