js 精度丢失问题

764 阅读6分钟

最通俗易懂的解释:
有无限的有理数例如0.33333...在数学里可以表示出来, 在计算机里也可以表示,但是即使计算机内存再大也存储不下,所以像这样的无限的有理数只能存储一个近似值(javascript以64位双精度浮点数存储所有Number类型值 最多存储64位二进制数),导致很容易丢失精度

Demo

image.png

image.png

image.png

image.png

推导过程请参考# js精度丢失问题-看这篇文章就够了(通俗易懂)
总结:计算机存储双精度浮点数需要先把十进制数转换为二进制的科学记数法的形式,然后计算机以自己的规则{符号位+(指数位+指数偏移量的二进制)+小数部分}存储二进制的科学记数法,因为存储时有位数限制(64位),并且某些十进制的浮点数在转换为二进制数时会出现无限循环,会造成二进制的舍入操作(0舍1入),当再转换为十进制时就造成了计算误差。

精度丢失场所分析

存储二进制时小数点的偏移量最大为52位, 最多可表示的十进制为9007199254740992, 它的长度为16位,可以用toPrecision(16)来做精度运算,js自动做了一部分处理, 超过了的精度会自动做凑整处理
例如

image.png

大数危机

image.png

大整数的精度丢失和浮点数本质上是一样的,前面已经说了,存储二进制时小数点的偏移量最大为52位,计算机存储为二进制,而能存储二进制的为62位, 超出就会四舍五入,因此js中能够精准表示的最大整数Math.pow(2,53),十进制即9007199254740992, 大于9007199254740992 的可能会丢失精度

tofixed()对于小数最后一位为5时进位不正确的问题

image.png

可以看到,小数点位数为2,5时四舍五入是正确的,其它是错误。Firefox 和 Chrome的实现没有问题,根本原因还是计算机里浮点数精度丢失的问题

如:1.005.toFixed(2) 返回的是 1.00 而不是 1.01;

原因: 1.005 实际对应的数字是 1.00499999999999989,在四舍五入时全部被舍去

1.005.toPrecision(21) //1.00499999999999989342

修复方式1

/*
 * 修复firefox/chrome中toFixed兼容性问题
 * firefox/chrome中,对于小数最后一位为5时进位不正确,
 * 修复方式即判断最后一位为5的,改成6,再调用toFixed
   number {原始数字}
   precision {位数}
 */
function toFixed(number, precision) {
    var str = number + ''
    var len = str.length
    var last = str.substring(len - 1, len)
    if (last == '5') {
        last = '6'
        str = str.substring(0, len - 1) + last
        return (str - 0).toFixed(precision)
    } else {
        return number.toFixed(precision)
    }
}
console.log(toFixed(1.333335, 5))

修复方式2

//先扩大再缩小法 
function toFixed(num, s) {
    var times = Math.pow(10, s)
    // 0.5 为了舍入
    var des = num * times + 0.5
    // 去除小数
    des = parseInt(des, 10) / times
    return des + ''
}
console.log(toFixed(1.333332, 5))

浮点数的计算问题!

我们可以把需要计算的数字升级(乘以10的n次幂)成计算机能够精确识别的整数,等计算完成后再进行降级(除以10的n次幂),这是大部分语言处理精度问题常用方法 例如

0.1 + 0.2 == 0.3 //false
(0.1*10 + 0.2*10)/10 == 0.3 //true
(0.1*100 + 0.2*100)/100 == 0.3 //true

35.41 * 100 == 3540.9999999999995 // true
// 即使扩大再缩小 还是会有丢失精度的问题
(35.41*100*100)/100==3541 //false  
// 解决方法 如果是乘法的话可以用Math.round()解决【可能不具有普适性】
 Math.round(35.41 * 100)===3541 //true

结论:不能单纯的用扩大缩小法来解决丢失精度的问题 。我们可以将浮点数toString后indexOf("."),记录一下两个值小数点后面的位数的长度,做比较,取最大值(即为扩大多少倍数),计算完成之后再缩小回来

上面例子只适用于加减法运算。遇到乘除时就会有问题。乘除是有另一种转换规则,不过本质上还是扩大缩小法,只不过逻辑变了。

以下是处理加减乘除的完整的代码如下:

/**
* floatObj 包含加减乘除四个方法,能确保浮点数运算不丢失精度
*
* 精度丢失问题(或称舍入误差,其根本原因是二进制和实现位数限制有些数无法有限表示
* 以下是十进制小数对应的二进制表示
*      0.1 >> 0.0001 1001 1001 1001…(1001无限循环)
*      0.2 >> 0.0011 0011 0011 0011…(0011无限循环)
* 计算机里每种数据类型的存储是一个有限宽度,比如 JavaScript
  使用 64 位存储数字类型,因此超出的会舍去。舍去的部分就是精度丢失的部分。
*
* ** method **
*  add / subtract / multiply /divide
*
* ** explame **
*  0.1 + 0.2 == 0.30000000000000004 (多了 0.00000000000004)
*  0.2 + 0.4 == 0.6000000000000001  (多了 0.0000000000001)
*  19.9 * 100 == 1989.9999999999998 (少了 0.0000000000002)
*
* floatObj.add(0.1, 0.2) === 0.3
* floatObj.multiply(19.9, 100) === 1990
*
*/
        var floatObj = function () {

            /*
             * 判断obj是否为一个整数 整数取整后还是等于自己。利用这个特性来判断是否是整数
             */
            function isInteger(obj) {
                // 或者使用 Number.isInteger()
                return Math.floor(obj) === obj
            }
            /*
             * 将一个浮点数转成整数,返回整数和倍数。如 3.14 >> 314,倍数是 100
             * @param floatNum {number} 小数
             * @return {object}
             *   {times:100, num: 314}
             */
            function toInteger(floatNum) {
                // 初始化数字与精度 times精度倍数  num转化后的整数
                var ret = { times: 1, num: 0 }
                var isNegative = floatNum < 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)  // 精度倍数
                /* 为什么加0.5?
                    前面讲过乘法也会出现精度问题
                    假设传入0.16344556此时倍数为100000000
                    Math.abs(0.16344556) * 100000000=0.16344556*10000000=1634455.5999999999 
                    少了0.0000000001
                    加上0.5 0.16344556*10000000+0.5=1634456.0999999999 parseInt之后乘法的精度问题得以矫正
                */
                var intNum = parseInt(Math.abs(floatNum) * times + 0.5, 10)
                debugger
                ret.times = times
                if (isNegative) {
                    intNum = -intNum
                }
                ret.num = intNum
                return ret
            }

            /*
             * 核心方法,实现加减乘除运算,确保不丢失精度
             * 思路:把小数放大为整数(乘),进行算术运算,再缩小为小数(除)
             * @param a {number} 运算数1
             * @param b {number} 运算数2
             */
            function operation(a, b, op) {
                var o1 = toInteger(a)
                var o2 = toInteger(b)
                var n1 = o1.num  // 3.25+3.153
                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':
                        // 325*3153/(100*1000) 扩大100倍 ==>缩小100倍
                        result = (n1 * n2) / (t1 * t2)
                        return result
                    case 'divide':
                        // (325/3153)*(1000/100)  缩小100倍 ==>扩大100倍
                        result = (n1 / n2) * (t2 / t1)
                        return result
                }
            }

            // 加减乘除的四个接口
            function add(a, b) {
                return operation(a, b, 'add')
            }
            function subtract(a, b) {
                return operation(a, b, 'subtract')
            }
            function multiply(a, b) {
                return operation(a, b, 'multiply')
            }
            function divide(a, b) {
                return operation(a, b, 'divide')
            }
            return {
                add: add,
                subtract: subtract,
                multiply: multiply,
                divide: divide
            }
        }();
        console.log(floatObj.add(0.16344556, 3.153))

当然你也可以用成熟的库来解决此问题

比如 math.js

第一种: cdnjs 下载或者链接:

cdnjs.cloudflare.com/ajax/libs/m…

第二种:

安装

npm install mathjs

请注意,在 TypeScript 项目中使用 mathjs 时,您还必须安装类型定义文件:npm install @types/mathjs.

import * as math from 'mathjs';
export default {
    // 加
	add(num1,num2){
		return math.add(math.bignumber(num1),math.bignumber(num2));
	},
	// 乘
	multiply(num1,num2){
		return math.multiply(math.bignumber(num1),math.bignumber(num2));
	},
	// 减
	subtract(num1,num2){
		return math.subtract(math.bignumber(num1),math.bignumber(num2));
	},
	// 除
	divide(num1,num2){
		return math.divide(math.bignumber(num1),math.bignumber(num2));
	}
}

number-precision也能完美支持浮点数的加减乘除、四舍五入等运算。非常小只有1K,远小于绝大多数同类库(如Math.js、BigDecimal.js)

github.com/dt-fe/numbe…

以上内容转自js精度丢失问题-看这篇文章就够了(通俗易懂)