0.1+0.2!=0.3 的原因你还记得吗?

2,236 阅读8分钟

前言

随着网络的快速发展,线上消费已经成为主流,与大众的生活融为一体。作为一个程序猿,在保证消费者隐私安全的情况下,金额的准确计算成了非常重要的一环。对于我们来说,数字的加减乘除的规则肯定手到擒来,但是计算机的加减乘除呢,你了解它的计算规则吗?

在 JavaScript 语言中,0.1 + 0.2 不等于 0.3,我想不光是前端程序猿知道,甚至后端程序猿也知道,但这些不等式,你了解吗👇

12 * 0.6 != 7  (7.199999999999999)

0.3 + 0.6 != 0.9  (0.8999999999999999)

0.4 * 0.7 != 0.28  (0.27999999999999997)

0.9 * 0.2 != 0.18 (0.18000000000000002)

... ...

这样的例子其实有很多,在这里小编就不一一列举了。作为一个优秀的开发人员,怎能只知其然,而不知其所以然,今天就跟着小编一起刨根问底!!!

知识储备

为了更方便大家了解之后的文章内容,咱们先来‘唤醒’一些数学知识!

  • 科学技术法
  • 十进制与二进制之间的转换
  • 二进制与幂运算

十进制与二进制之间的转换

在这里简单复习一下,解封大家沉睡多年的记忆。

十进制转换成二进制

以十进制 10.3 为例,由于整数部分与小数部分转换成二进制的方式不同,咱们拆解着说,先说整数部分 10。整数部分采用"除 2 取余,逆序排列"法。具体做法是:用 2 整除十进制整数,可以得到一个商和余数;再用 2 去除商,又会得到一个商和余数,如此进行,直到商为小于 1 时为止,然后把先得到的余数作为二进制数的低位有效位,后得到的余数作为二进制数的高位有效位,依次排列起来。

10 / 2 = 5...0  =====  取余是 0
 5 / 2 = 2...1  =====  取余是 1
 2 / 2 = 1...0  =====  取余是 0
 1 / 2 = 0...1  =====  取余是 1

采用的是逆序排列,所以 10 转化成二进制是 1010。接着来转化 0.3。小数部分采用"乘 2 取整,顺序排列"法。具体做法是:用2 乘十进制小数,可以得到积,将积的整数部分取出,再用2乘余下的小数部分,又得到一个积,再将积的整数部分取出,如此进行,直到积中的小数部分为 0,此时 0 或 1 为二进制的最后一位。

0.3 * 2 = 0.6  =====  取整是 0
0.6 * 2 = 1.2  =====  取整是 1
0.2 * 2 = 0.4  =====  取整是 0
0.4 * 2 = 0.8  =====  取整是 0
0.8 * 2 = 1.6  =====  取整是 1

0.6 * 2 = 1.2  =====  取整是 1
0.2 * 2 = 0.4  =====  取整是 0
0.4 * 2 = 0.8  =====  取整是 0
0.8 * 2 = 1.6  =====  取整是 1
……

小伙伴是不是已经发现了,十进制 0.3 转化成二进制就是无限循环的 0.0100110011001...,最后将转化后的整数部分合并,即得到 1010.0100110011001......。

当然按照计算机的存储规则,是无法存储无限循环小数的,那该如何处理呢?下面会做解释哟!!!

二进制转换成十进制

二进制转换成十进制同样需要区分为整数部分与小数部分。整数部分要从右到左用二进制的每个数去乘以 2 的相应次方并递增,小数部分则是从左往右乘以2的相应负次方并递减,然后将两部分相加。以二进制 1011.01 为例:

// 整数部分 
1011 ====  1 * 2^0 + 1 * 2^1 + 0 * 2^2 * 1 * 2^3 = 11

// 小数部分
0.01 ====  0 * 2^-1 + 1 * 2^-2 = 0 + 0.25 = 0.25

最后将两部分相加,二进制 1011.01 转换成十进制为 11 + 0.25 = 11.25

科学计数法

把一个数表示成 a 与 10 的 n 次幂相乘的形式(1≤|a|<10,n 为整数)的计数法。举个例子🌰:

十进制

782300 = 7.823 * 10^5

0.00012 = 1.2 * 10^−4

10000 = 1 * 10^4

二进制的转换也是同样的逻辑

111001 = 1.11001 * 2^5

0.001101 = 1.101 * 2^-3

10000 = 1 * 2^4

也就是说:十进制 5.0,转换成二进制为 101.0 ,使用科学计数法就是 1.01 * 2^2。

二进制运算与幂运算

要搞清楚 0.1 + 0.2 != 0.3 的原因,会涉及到很多关于二进制运算,下面这些计算你还记得吗?

  • 将二进制 1111111111 转换成十进制

    如果按照上面提到的二进制转换成十进制的方式会是这样的:2^0 + 2^1 + 2^2 + …… + 2^9,这简直是灾难。那咱们换个思路,1111111111 = 10000000000 - 1 ,在二进制下,这个等式是成立的,将二进制 10000000000 转换十进制就相对容易多了,也就是:

    1111111111 = 10000000000 - 1 = 2^10 - 1 = 1024 - 1 = 1023

  • 二进制 2^-2 转换成小数

    2^-2 = 1/2^2 = 1/100 = 0.01

  • 特殊运算

    2^0 = 1

JavaScript 中数字的表示

通过上面的描述,我想小伙伴们大脑深处的记忆已经被唤醒了,理解下面的内容应该会很顺利。好了,咱们步入正题!

在 JavaScript 中通过 Number 类型表示十进制数字,在计算机中的存储则是将十进制转换成二进制,还记得上一节中的二进制转换吗,十进制 0.3 转换成二进制是一个无限小数,计算机的内存在大也不可能存储一个无限小数,那在计算机中如何存储二进制数字呢?计算机遵循 IEEE 754 标准,通过 64 位的二进制数来存储一个数字。

IEEE-754 二进制浮点数算术标准是 20 世纪 80 年代以来最广泛使用的浮点数运算标准,其规定了四种表示浮点数值的方式:单精确度(32 位)、双精确度(64 位)、延伸单精确度(43 比特以上,很少使用)与延伸双精确度(79 比特以上,通常以 80 位实现)。Javascript 编程语言中则使用的是双精确度(double)表示。

根据 IEEE-754 标准,任意一个二进制浮点数 V 可以表示成下面的形式:

V = (-1)^S * M * 2^E
  • (-1)^S 表示符号位,当 S=0,V 为正数;当 S=1,V 为负数。
  • M 表示有效数字,大于等于 1,小于 2。
  • 2^E 表示指数位。

这个表示形式说白了,就是将一个浮点数转化成科学计数法(第二小节中有解释)的形式。举个例子来说,十进制 5.0,转化成二进制位为 101.0,科学计数法表示为 1.01 * 2^2,对照上面 V 的表示形式,S=0,M=1.01,E=2。如果是 -5.0,得出的结果则是 S=1,M=1.01,E=2。

JavaScript 编程语言中,遵循 V 的表示形式,将双精确度 64 位分为 3 部分(1 + 11 + 52)来分别表示 S、E、M。

  • 62 位:符号位,0 表示正数,1 表示负数(s)
  • 52~62 位:储存指数部分(e)
  • 0~51 位:储存小数部分(f)

需要特别注意一点,在计算机存储中,52 位的尾数 f 与 IEEE-754 V 的表示形式 M 有区别:尾数 f 只存储小数位。这是因为在二进制中,只有 1 和 0 ,M 的表示形式为 1.xxxx,小数点前第一位默认为 1,因此可以省略,节省 1 位有效数字。在计算机存储中,只保留小数部分 xxxxx。比如保存 1.01 的时候,只保存 01,等到读取的时候,再把第一位的 1 加上去。

既然都说到这儿,就在补充一个知识点:JavaScript 的最大安全数为

Number.MAX_SAFE_INTEGER == Math.pow(2,53) - 1  // true

最大安全数也就是当尾数 f 全为 1 ,符号位为 0,那指数位是多少。指数位是 11 位,最大值为 2^11 - 1 = 2047,但最大安全数指数位的值并不是 2047。

大家可以考虑一下,尾数 f 最大为 52 位,对于二进制无限循环小数,不能确定 52 位之后数字是 0 还是 1,只有 52 位是确定的。按照幂运算的法则,如果指数为大于 52,则需要用 0 补齐,这个不准确的。所以最大安全数的指数位 e=52。

Max =(-1)^0 * 1.1111111111111111111111111111111111111111111111111111 * 2^52
    =(10.000……000 - 0.000……001)* 2^52 
    = 2^53 - 1

理论上是没问题的,那咱们实际验证一下:

有没有小伙伴会有疑惑,为啥 Math.pow(2,53) 不是最大安全值?注意,是最大安全值,不是最大值,Math.pow(2,53) == Math.pow(2,53) + 1,所以 Math.pow(2,53) 与 Math.pow(2,53) + 1 转换成二进制,在计算机中存储的都是 9007199254740992,在进行计算时并不能确定准确值。

指数位

刚刚提到了指数位的最大值,那指数位的最大值真的是这个值吗?答案是否定的。

指数位 e 共 11 位,取值范围 [0000000000,11111111111],即 [0,2^11-1],转化成十进制为 [0,2047]。但这种存储方式存在问题,当存储的十进制数小于 0 时,指数应该为负数。

按照 IEEEE-754 规范,取中间值进行偏移,来表示负指数,[0,2047] 的中间值 bias = 1023,也就是说指数的范围是 [-1023,1024],偏移指数 E = e - bias。

现在我们已经知道了指数与尾数的范围,按照 Javascript 存储方式,能够表达的数的范围是 [2^-1023,2^1024],超出这个范围 Javascript 将返回一个不正确的值(Infinity)。

2^-1023 用十进制的科学计数法表示为:5 × 10^-324

2^1024 用十进制的科学计数法表示为:1.7976931348623157 × 10^308

这两个边界值可以分别通过访问 Number 对象的 MAX_VALUE 属性和 MIN_VALUE 属性来获取:

Number.MAX_VALUE; // 1.7976931348623157e+308

Number.MIN_VALUE; // 5e-324

从上面的图了解到,偏移指数 E 有 2 个极限情况:

  • E 全为 0
    指数 e 为 1-1023 = -1022,转换为十进制数为 0.00……XXX 的小数,很接近于 0,表示为 0。
  • E 全为 1
    如果尾数 f 全为 0 ,表示无穷大。如果尾数 f 不全为 0 ,表示 NaN。

说了这么多,咱们来举个例子,十进制 5.0 在计算机中该怎么表示,加深小伙伴的理解!

十进制 5.0 === 二进制 101.0 === 科学计数法 1.01 * 2^2

1.01 * 2^2 对应到计算机的存储结构中,即:

  • 符号位 s = 0
  • 尾数为 f = 01
  • 指数位 e = 2 + 1023 = 1025 (十进制) ,即 100 0000 0001

这个例子中,需要偏移的指数为 2,而偏移指数 E = e - bias,即 E = 2,bias = 1024

精度丢失

好了,回到最初的问题 0.1+0.2 != 0.3。计算机会先把 0.1 和 0.2 分别转化成二进制,然后相加,最后再把相加得到的结果转为十进制。恰巧 0.1、0.2 转换成二进制后全是无限循环。

0.1 => 0.0001 1001 1001 1001…(无限循环)

0.2 => 0.0011 0011 0011 0011…(无限循环)

而存储结构中的尾数部分最多只能表示 52 位。为了能表示超过的数字,只能模仿十进制进行四舍五入了,但二进制只有 0 和 1 ,于是变为 0 舍 1 入。所以 0.1、0.2 在计算机中的存储形式为:

0.1 => 0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 101

0.2 => 0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 001

用科学计数法表示:

0.1 => (−1)^0 * (1.1001100110011001100110011001100110011001100110011010) * 2^-4

0.2 => (−1)^0 * (1.1001100110011001100110011001100110011001100110011010) * 2^-3

在阶数不等时,需要先进行 “对阶”,将较小的指数化为较大的指数,并将小数部分相应右移:


0.1 => (−1)^0 * (0.1100110011001100110011001100110011001100110011001101) * 2^-3

0.2 => (−1)^0 * (1.1001100110011001100110011001100110011001100110011010) * 2^-3

最终相加结果:

       1 * ( 0.1100110011001100110011001100110011001100110011001101) * 2^-3  
    +  1 * ( 1.1001100110011001100110011001100110011001100110011010) * 2^-3
    =  1 * (10.0111001100110011001100110011001100110010011001100111) * 2^-3

 标准化 1 * ( 1.0011001100110011001100110011001100110011001100110100) * 2^-2

将二进制转换成十进制:

// 二进制
( 1.0011001100110011001100110011001100110011001100110100 ) * 2^-2

// 十进制
  (1 + 2^-3 + 2^-4 + 2^-7 + 2^-8 + 2^-11 + 2^-12 + 2^-15 + …… + 2^-50) * 2^-2
= 0.3000000000000000444089209850062616169452667236328125

因为精度的问题,最终结果:

0.30000000000000004

0.1 + 0.2 算是一个典型的精度丢失的案例,从上面的计算过程了解到,0.1 和 0.2 在转成二进制的过程中就发了一次精度丢失,而相加后发生了第二次精度丢失,因此得到的结果是不准确的。也就印证了:

0.1 + 0.2 === 0.3  // false

解决

既然知道了问题产生的原因,就得想办法去解决它!

1、将小数转成整数

function add(num1, num2) {
    // 转换成字符串,得到小数部分的长度
    const Decimal1 = (num1.toString().split('.')[1] || '').length;
    const Decimal2 = (num2.toString().split('.')[1] || '').length;
    // 取两个数字的对大值
    const baseNum = Math.pow(10, Math.max(Decimal1, Decimal2));
    return (num1 * baseNum + num2 * baseNum) / baseNum;
}
// 计算0.1+0.2

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

这种方式是最容易想到,也是容易实现,在工作中采用的也是最多的。但是这种方式对于大数就不太友好,依然会存在精度丢失的问题。

2、利用第三方库

精度丢失问题,不仅存在与 JavaScript 中,Java 中也存在,所以出现了很多优秀的 Math 库,来解决精度丢失问题。下面这个是我经常使用的库。

Math.js

Math.js is an extensive math library for JavaScript and Node.js. It features a flexible expression parser with support for symbolic computation, comes with a large set of built-in functions and constants, and offers an integrated solution to work with different data types like numbers, big numbers, complex numbers, fractions, units, and matrices. Powerful and easy to use.

有道翻译的意思大致是这样:

math.js 是一个针对 JavaScript 和 Node.js 的广泛数学库。它提供了一个灵活的表达式解析器,支持符号计算,提供了大量内置函数和常量,并提供了一个集成的解决方案来处理不同的数据类型,如数字、大数、复数、分数、单位和矩阵。功能强大,易于使用。

官网:mathjs.org/

不过有的时候,一个函数就能解决的问题,大家也不愿意去引入一个库。那不妨看一下这个库的源码,让自己的函数式更加健壮,不出 bug。其实,这样的库很多,大家可以留言告诉我呦!

PS:前端技术日新月异,但是万变不离其宗,掌握了最底层的知识,才会不惧怕更新!