面试官:为什么 0.1+0.2 ≠ 0.3 ?

大家好,我是团队的 Fine 老师。

0.1+0.2不等于0.3是一个很经典的前端面试题了,但大部分人可能只知道这是 JS 中的精度引发的问题,但为什么会存在精度丢失呢?

换个角度,在实际工作中,数字和金额对于业务来说往往很敏感的,如果不提前对浮点数计算做处理,那很可能会引出意想不到的Bug。

今天希望能把这个知识点搞透,文章有点很长,建议大家耐心阅读。

前言

看这篇文章之前你需要了解的知识 :
1. 进制之间的转换
2. 科学计数法中的E
3. 浮点型的存储机制(单精度浮点数,双精度浮点数)

最通俗易懂的解释

比如一个数 1÷3=0.33333333...... 大家都知道3会一直无限循环,数学可以表示,但是计算机要存储,方便下次取出来再使用,但0.333333...... 这个数 无限循环,你让计算机怎么存储?计算机再大的内存它也存不下,对吧! 所以不能存储一个相对于数学来说的值,只能存储一个近似值,所以当计算机存储后再取出来用时就会出现精度问题。

一 、你需要知道的进制知识

世界上有1 0种人:一种是懂得二进制的,另一种是不懂二进制的

二进制

  • 基数为2
  • 有2个数字,即0和1
  • 满2进1

八进制

  • 基数为8
  • 由8个数字组成,分别是0、1、2、3、4、5、6、7
  • 满8进1

十进制

  • 我们日常生活中所用的都是十进制,也就是满10进1

十六进制

  • 基数为16。
  • 由16个数字符号组成,分别是0、1、2、3、4、5、6、7、8、9、A、B、C、D、E、F
  • 满16进1

在古代中国当时使用的重量单位就是十六进制,16两为1斤,就有了所谓的“半斤八两”

二、科学计数法中的E

E是指数的意思,E代表的英文是exponent,E表示10的多少次方的意思

比如7.823E5 = 782300这里E5表示10的5次方,再比如54.3E-2 = 0.543这里E-2表示10的-2次方

三、浮点型的存储机制(单精度浮点数,双精度浮点数)

浮点型数据类型主要有:单精度float、双精度double

单精度浮点数(float)

单精度浮点数在内存中占4个字节、有效数字8位、表示范围:-3.40E+38 ~ +3.40E+38

双精度浮点数(double)

双精度浮点数在内存中占8个字节、有效数字16位、表示范围:-1.79E+308 ~ +1.79E+308

浮点型常量 数有两种表示形式:

1. 十进制数形式:由数字和小数点组成,且必须有小数点,如0.123,123.0
2. 科学计数法形式:如:123e3123E3,其中eE之前必须有数字,且e或E后面的指数必须为整数(当然也包括负整数)

浮点型简单来说就是表示带有小数的数据,而恰恰小数点可以在相应的二进制的不同位置浮动,可能是这样就被定义成浮点型了

但 JavaScript 存储小数和其它语言都不同,JavaScript 中所有数字包括整数和小数都只有一种类型,即 Number类型。它的实现遵循 IEEE 754 标准,大家需要记住以下一点:

javascript以64位双精度浮点数存储所有Number类型值 即计算机最多存储64位二进制数。

这样的存储结构优点是可以归一化处理整数和小数,节省存储空间

到这里还是不明白双精度浮点数啥意思是吧?没关系,接着看。

对于一个整数,我们可以很方便的在十进制与二进制中转换,但是对于一个浮点数来说不是这么容易——因为小数点的存在

对于浮点数来说,小数点的位置不是固定的(小数点后面的数的个数不定),所以如何存储小数点是一个挑战。后来人们想出用科学计数法通常如这般:1.01∗241.01*2^4 来表示浮点数,这样的好处是:小数点的位置固定下来了

举例:27.0表示成二进制为:11011.0 用科学计数法表示为

1.10110∗241.10110*2^4

那么如何存储 1.10110∗241.10110*2^4 这个数呢?

对于double型数据(双精度浮点数),其长度是8个字节(大小),

右边52位用来表示小数点后面的数字

中间11位表示e(exponent)小数点移动的位数

左边一位用来表示正负 如图所示:

图解:

  • 1位用来表示符号位
  • 11位用来表示指数
  • 52位表示小数部分

符号位:1表示正数,0表示负数

比如:-0.896是负数,符号位为0;0.123是正数,符号位为

指数位:因为e可以为正,可以为负数。比如 1.10110∗241.10110*2^4 这个e为正数,如果是0.101那么用指数表示就是 1.01∗2−11.01*2^-1 ,那么e为-1。同时要求先把e+指数偏移量,得到的结果再化成二进制,就是我们的指数位

小数部分(也称为阶数):二进制下转换为科学记数法后小数点后面的数字。如 1.1011^4 的小数位为1011,它的总位数应为52位, 位数不够就用0补齐 因此也可以这样理解小数点的偏移量最大为52位,取点符号位和指数 那么这个52位就表示最大整数位为52位 即JS 中能精准表示的最大整数是 Math.pow(2, 53) 十进制即 9007199254740992

指数偏移量:指数偏移量公式:

X=2k−1X=2^{k-1}

k为指数位个数(如上图所示双精度浮点数的指数位为11,套用公式即 X=211−1X=2^{11-1} =1023)
所以双精度浮点数的指数偏移量都为1023

计算机存储二进制即为:符号位+指数位+小数部分 (阶数)

举例:

27.5 转换为二进制11011.1

11011.1转换为科学记数法 1.10111∗241.10111*2^4

符号位为1(正数)

指数位为4+指数偏移量1023 即1027 因为它是十进制的需要转换为二进制即 10000000011

小数部分为10111,补够52位即:1011 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

指数偏移量为 211−12^{11-1} =1023

所以27.5存储为计算机的二进制标准形式为

符号位+指数位+小数部分 (阶数)

0+10000000011+011 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

即:

0100 0000 0011 1011 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

正好64位

注意区分:十进制转换为_二进制数只保留52位有效数字(科学记数法),而_计算机存储双精度浮点数为64位

那小数点前面的整数位不用存储吗?

不用,因为转化为二进制之后首位数都是 1 ,计算机会自动处理。

大家再来看下 js 中常见的一些精度丢失的现象:

// 加法 =====================
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 +0.2===0.30000000000000004 ?

我们来推导一下0.1

0.1>>>二进制>>>科学记数法>>实际存储时的形式[64位] 符号位+(指数位+指数偏移量)+小数部分) 即:

0.1>>>0.0001100110011001100110011001100110011001100110011001101>>> 1.100110011001100110011001100110011001100110011001101 * 2^(-4)>>>

0011111110111001100110011001100110011001100110011001100110011010

同理,0.2

0.2>>>0.001100110011001100110011001100110011001100110011001101>>>1.100110011001100110011001100110011001100110011001101 * 2^(-3)>>>0011111111001001100110011001100110011001100110011001100110011010

可以看出来在转换为二进制时

0.1 >>> 0.0001 1001 1001 1001...(1001无限循环)

0.2 >>> 0.0011 0011 0011 0011...(0011无限循环)

就像一些无理数不能有限表示,如 圆周率 3.1415926...,1.3333... 等,在转换为二进制的科学记数法的形式时只保留64位有效的数字,此时只能模仿十进制进行四舍五入了,但是二进制只有 0 和 1 两个,于是变为 0 舍 1 入。在这一步出现了错误,那么一步错步步错,那么在计算机存储小数时也就理所应当的出现了误差。这即是计算机中部分浮点数运算时出现误差,这就是丢失精度的根本原因

将0.1和0.2的二进制形式按实际展开,末尾补零相加,结果如下

0.00011001100110011001100110011001100110011001100110011010

+0.00110011001100110011001100110011001100110011001100110100

=0.01001100110011001100110011001100110011001100110011001110

0.1+0.2 >> 0.0100 1100 1100 1100...(1100无限循环)

则0.1 + 0.2的结果的二进制数科学记数法表示为为1.001100110011001100110011001100110011001100110011010 * 2^(-2), 省略尾数最后的0,即 1.00110011001100110011001100110011001100110011001101 * 2^(-2), 因此(0.1+0.2)实际存储时的形式是 0011111111010011001100110011001100110011001100110011001100110100
因计算机存储位数的限制而截断的二进制数字,再转换为十进制,就成了0.30000000000000004

推导完成

总结:计算机存储双精度浮点数需要先把十进制数转换为二进制的科学记数法的形式,然后计算机以自己的规则{符号位+(指数位+指数偏移量的二进制)+小数部分}存储二进制的科学记数法,因为存储时有位数限制(64位),并且某些十进制的浮点数在转换为二进制数时会出现无限循环,会造成二进制的舍入操作(0舍1入),当再转换为十进制时就造成了计算误差。

四、精度丢失的场景分析

为什么 x=0.1 能得到 0.1?

存储二进制时小数点的偏移量最大为52位,最多可表示的十进制为9007199254740992,对应科学计数尾数是 9.007199254740992,这也是 JS 最多能表示的精度。它的长度是 16,所以可以使用 toPrecision(16) 来做精度运算,js自动做了这一部分处理,超过的精度会自动做凑整处理。于是就有:

0.10000000000000000555.toPrecision(16//0.1000000000000000 去掉末尾的零后正好为0.1

但你看到的 `0.1` 实际上并不是 `0.1`。不信你可用更高的精度试试:

0.1.toPrecision(21)=0.100000000000000005551

大数危机

9999999999999999 == 10000000000000001===true ?

16位和17位数竟然相等

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

大数转换为二进制

9007199254740992 >> 10000000000000...000 精度未丢失
9007199254740992 + 1 >> 10000000000000...001 精度丢失
9007199254740992 + 2 >> 10000000000000...010 精度未丢失

以上,可以知道看似有穷的数字, 在计算机的二进制表示里却是无穷的,由于存储位数限制因此存在“舍去”,精度丢失就发生了,此时两个大数在计算机中的二进制正数好相等

因此 9999999999999999 == 10000000000000001===true

使用parseInt()时也会有这种问题 parseInt()把字符串转换为整数后存储机制和浮点数是一样的,当大于 9007199254740992 的可能会丢失精度

在淘宝早期的订单系统中把订单号当作数字处理,后来随意订单号暴增,已经超过了9007199254740992,最终的解法是把订单号改成字符串处理。要想解决大数的问题你可以引用第三方库 bignumber.js,原理是把所有数字当作字符串,重新实现了计算逻辑,缺点是性能比原生的差很多。所以原生支持大数就很有必要了,现在 TC39 已经有一个 Stage 3 的提案 proposal bigint,大数问题有问彻底解决

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

//firefox/chrome中toFixed 兼容性问题
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 错误

可以看到,小数点位数为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.3333355))

修复方式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.3333325))

ES6在Number对象上新增了一个极小的常量——Number.EPSILON

Number.EPSILON
// 2.220446049250313e-16
Number.EPSILON.toFixed(20)
// "0.00000000000000022204"

引入一个这么小的量,目的在于为浮点数计算设置一个误差范围,如果误差能够小于Number.EPSILON,我们就可以认为结果是可靠的。

误差检查函数(出自《ES6标准入门》-阮一峰)

function withinErrorMargin (left, right{
    return Math.abs(left - right) < Number.EPSILON
}
withinErrorMargin(0.1+0.20.3)

数据展示类

当你拿到 1.4000000000000001 这样的数据要展示时,建议使用 toPrecision 凑整并 parseFloat 转成数字后再显示,如下:

parseFloat(1.4000000000000001.toPrecision(12)) === 1.4  // true

封装成方法就是:

function strip(num, precision = 12{
  return +parseFloat(num.toPrecision(precision));
}

为什么选择 12 做为默认精度?

这是一个经验的选择,一般选12就能解决掉大部分0001和0009问题,而且大部分情况下也够用了,如果你需要更精确可以调高。

即对于运算类操作,如 +-*/,就不能使用 toPrecision 了。正确的做法是把小数转成整数后再运算。【先扩大再缩小】

回到浮点数的计算问题!

我们可以把需要计算的数字升级(乘以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("."),记录一下两个值小数点后面的位数的长度,做比较,取最大值(即为扩大多少倍数),计算完成之后再缩小回来。

先举一个简单的例子

// 加法运算
function add(num1, num2{
  const num1Digits = (num1.toString().split('.')[1] || '').length
  const num2Digits = (num2.toString().split('.')[1] || '').length
  const baseNum = Math.pow(10Math.max(num1Digits, num2Digits))
  return (num1 * baseNum + num2 * baseNum) / baseNum
}

以上方法能适用于大部分场景。遇到科学计数法如 2.3e+1时还需要特别处理一下。

因为当数字个数超过21位时数字会强制转为科学计数法形式显示。

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

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

/**
* 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 = { times1num0 }
                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.510)
                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.163445563.153))

如果觉得此函数调用麻烦,也可以在Number.prototype上添加对应的运算方法。这里就不做展示了。

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

比如 math.js 可以从cdnjs 下载或者链接:

[https://cdnjs.cloudflare.com/ajax/libs/mathjs/3.16.0/math.min.js]

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

[https://github.com/dt-fe/number-precision]

原文地址:https://zhuanlan.zhihu.com/p/100353781

原文作者: 逆流成河

本文来自知乎分享

最后


顺便也给我们的辅导服务打个广告: