什么?0.1 + 0.2 !== 0.3?原来是因为这个

深入理解浮点数精度丢失:从二进制到IEEE754

大家好,我是若川。我持续组织了近一年的源码共读活动,感兴趣的可以 点此扫码加我微信 lxchuan12 参与。欢迎星标我的公众号~不错过推文~

浮点数精度丢失,一直是前端面试八股文里很常见的一个问题,今天我们就来深入的了解一下问题背后的原理,以及给一些日常处理的小技巧。

     Musa 2023     

    现象:不听话的小数  

01

我们先来看两个现象:

  • 0.1 + 0.2 ≠ 0.3

87fee73960c1b165368661a4bd9425e0.png

  • 2.55.toFixed(1) = 2.5, 而 1.55.toFixed(1) = 1.6

42c4bad9811eb76425fd70b719c73969.png

但凡你稍微有点前端开发经验,第一个现象你就一定见过,而第二个现象却相对少见,不过其实它们底层的原理是相通的,让我们看看这里到底发生了什么。

    背景:数学知识  

02

为了更好的理解后面的计算原理,我们先来复习一些数学知识:

  • 在数学里,小数是可以无限位的,但计算机存储介质有限,不可能全部存下,因此在计算机领域的所有小数都只是个近似值。

  • 科学计数法是一种计数方式,把一个数表示成a与10的n次幂相乘(1≤ |a| < 10),缩写:aEn = a * 10^n。

  • 用科学计数法可以免去浪费很多空间和时间。

  • 一个数的负n次幂等于这个数的n次幂的倒数,10^-2 = 1 / (10^2) = 1/100。

  • 十进制的近似值:四舍五入,二进制的近似值:零舍一入。

    溯源:二进制转换   

03

正整数的转换方法:除二取余,然后倒序排列,高位补零。

例如65的转换

f35cc09cbd23d92cfc65e0192350a879.jpeg

(65转二进制为 1000001,高位0后为01000001)

负整数的转换方法:将对应的正整数转换成二进制后,对二进制取反,然后对结果再加一(这个操作实际上是一个便捷操作,其底层原理涉及到补码知识,感兴趣的可以看看文末的参考资料 )。

例如-65
先把65转换成二进制 01000001
逐位取反:10111110
再加1:10111111(补码)

小数的转换方法:对小数点以后的数乘以2,取整数部分,再取小数部分乘2,以此类推……直到小数部分为0或位数足够。取整部分按先后顺序排列即可。 

例如123.4:
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.4 = 0.01100110……(0110循环)
整数部分123的二进制是 1111011
则123.4的二进制表示为:1111011.011001100110……

发现了什么?十进制小数转二进制后大概率出现无限位数!但计算机存储是有限的啊,怎么办呢?来,我们接着看。

    溯源:浮点型存储机制   

04

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

  • 单精度浮点数(float)

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

  • 双精度浮点数(double)

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

    溯源:IEEE 754与ECMAScript  

05

IEEE 754

所谓 IEEE754 标准,全称 IEEE 二进制浮点数算术标准,这个标准定义了表示浮点数的格式等内容,类似这样:

value = sign x exponent x franction

也就是浮点数的实际值,等于符号位(sign bit)乘以指数偏移值(exponent bias)再乘以分数值(fraction)。

1cb489338029e4972d3e83e17cb9a1cb.png

在 IEEE754 中,规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度、延伸双精确度。

ECMAScript 对于IEEE754的实践

ECMAScript 中的 Number 类型使用 IEEE 754 标准来表示整数和浮点数值,采用的就是双精确度,也就是说,会用 64 位来储存一个浮点数。

d0e77d799af6cea42939d59d2f2ef226.png

在这个标准下,我们会用1位存储 S(sign),0 表示正数,1 表示负数。用11位存储 E(exponent) + bias,对于11位来说,bias 的值是 2^(11-1) - 1,也就是 1023。用 52 位存储 Fraction。

举个例子,就拿 0.1 来看,对应二进制是 1 * 1.1001100110011…… * 2^-4, Sign 是 0,E + bias 是 -4 + 1023 = 1019,1019 用二进制表示是 1111111011,Fraction 是 1001100110011……

对应 64 位的完整表示就是:

0 01111111011 1001100110011001100110011001100110011001100110011010

同理, 0.2 表示的完整表示是:

0 01111111100 1001100110011001100110011001100110011001100110011010

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

0.1 >>> 0.0001 1001 1001 1001...(1001无限循环)
0.2 >>> 0.0011 0011 0011 0011...(0011无限循环)

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

0.00011001100110011001100110011001100110011001100110011010
+0.00110011001100110011001100110011001100110011001100110100
=0.01001100110011001100110011001100110011001100110011001110

用科学计数法表示为:

1.001100110011001100110011001100110011001100110011010 * 2^(-2)

省略尾数最后的0,即:

1.00110011001100110011001100110011001100110011001101 * 2^(-2)

因此 0.1 + 0.2 实际存储时的形式是:

0 01111111101 0011001100110011001100110011001100110011001100110100

再转十进制为:0.30000000000000004

好了,奇怪的东西出现了, 0.1 + 0.2 竟然不等于 0.3!bd27204ceb8e30321ea198e75b7e9a53.png


计算机存储双进度浮点数,需要先把十进制转换为二进制的科学计数法形式,然后计算机以一定的规则(IEEE 754)存储,因为存储时有位数限制(双进度8字节,64位),末位就需要取近似值(0舍1入),再转换为十进制时,就造成了误差。

破案!收工。

    解法   

06

既然知道问题所在了,那么有什么好的解决办法呢?这里给大家提供几种思路。

简单解法

  • 纯展示类

比如你从后端拿到 2.3000000000000001 这种数据要展示时,可以先用 toPrecision 方法保留一定位数的精度,然后再 parseFloat 后显示

parseFloat(2.3000000000000001.toPrecision(12)) === 2.3 // true

网上有人给出了这里的默认精度建议为 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(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 multiplier = 10 ** Math.max(num1Digits, num2Digits)
  return (num1 * multiplier + num2 * multiplier) / multiplier
}

第三方库

在一些对数据精度要求极高的场景,可以直接使用一些现成的库,这些库本身封装了较为复杂的计算方式,相对而言更加精准,比如处理大数的 bignumber.js,处理小数的number-precision 和 decimal.js,都是不错的库。

    类似问题   

07

还记得我们最开始展示了两种现象?上面我们只还原了第一个现象(即 0.1 + 0.2问题),接下来我们简单聊下 Number.toFixed 产生的四舍五入问题。再看下这个现象:

0576820379dba38cd0f854b69ca61297.png

我们用 toPrecision 多保留点精度看下:

23d84f86d895ad5ac1d12a0710d8a263.png

原来如此!toFixed 方法会根据你传入的精度对数字进行四舍五入,而2.55实际上是2.54999……取1位精度的话,由于第二位是4,四舍五入之后就是2.5。而1.55如果取1位精度的话,由于第二位是5,四舍五入后就是1.6。

那此类问题又是怎么解呢?网上给了一种通用的解法,在四舍五入前,给数字加一个极小值,比如 1e-14:

8ceeafa852db75bd45f761534f857be4.png

这样处理后,大部分场景下的精度基本都够用了。

这里我们采用的极小值是10的负14次方(1e-14),有没有一个官方推荐的极小值常量呢?嘿,巧了,还真有!ES6在 Number 对象上新增了一个极小的常量Number.EPSILON:

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

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

// 误差检查函数
function withinErrorMargin (left, right) {
  return Math.abs(left - right) < Number.EPSILON
}


withinErrorMargin(0.1+0.2, 0.3)

5ae1c21ae36a7179219d87d14a194c79.png

看,0.3 - ( 0.1 + 0.2 ) 的误差是1e-17次方,小于 Number.EPSILON,那么我们就认为二者在大部分场景下是等值的。

解法总结

1.  数据展示类,可以直接使用toPrecision(12)凑整,再parseFloat后展示

2.  浮点数计算类,取二者中小数位数最长者(记为N),同时乘以10的N次幂,转换为整数进行计算,再除以N次幂转回小数

3.  需要用toFixed取近似值的地方,可以先加上1e-14或Number.EPSILON,再取。

4.  判定两个数字相等,可以使用Math.abs(left - right) < Number.EPSILON

5.  实在不会,就直接用别人写好的成熟库吧。

参考资料

  • https://betterprogramming.pub/why-is-0-1-0-2-not-equal-to-0-3-in-most-programming-languages-99432310d476

  • https://www.cnblogs.com/zhangycun/p/7880580.html

  • https://zh.wikipedia.org/zh-cn/IEEE_754 

  • https://zhuanlan.zhihu.com/p/150291780

  • https://zhuanlan.zhihu.com/p/99082236

  • https://zhuanlan.zhihu.com/p/100353781

                 END                

<think>嗯,用户问为什么在Python里0.10.2等于0.3会返回False。这个问题应该和浮点数精度有关吧。我记得之前学过计算机中的浮点数是用二进制表示的,有些十进制小数无法精确转换成二进制,导致精度丢失。比如0.1在二进制里是无限循环的,所以存储的时候会被截断,这样计算的时候就会有误差。首先,我需要确认一下用户的问题。他们可能已经观察到0.1 +0.2的结果是0.30000000000000004,而不是精确的0.3,所以用==比较就会返回False。用户可能是在编程时遇到了这个问题,想知道原因,并且希望找到解决办法。接下来,我应该解释浮点数的表示方式,特别是IEEE754标准。双精度浮点数使用64位,其中1位符号,11位指数,52位尾数。由于这种结构,像0.1这样的数无法精确表示,导致舍入误差。当进行多次运算时,这些误差会累积,使得结果与预期不符。然后,需要给出具体的例子。比如在Python中,打印0.1+0.2的结果,会发现它显示为0.30000000000000004,而0.3的实际存储值可能也是近似值,但可能和这个结果不同,所以比较的时候返回False。 用户可能还想知道如何解决这个问题。这时候可以提到几种方法:比如允许一定的误差范围,使用math.isclose函数或者numpy的isclose方法;或者使用decimal模块进行高精度计算,避免二进制浮点数的问题;或者将小数转换为整数进行计算,比如用分而不是元来处理金额,这样可以避免浮点数问题。另外,用户提供的引用中有提到decimal模块的例子,应该参考这个给出具体的代码示例。同时,引用[4]提到整数是完全精度的,所以用分来计算可能是一个解决方案,但也要注意扩展性的问题。需要确保回答结构清晰,分步骤解释原因和解决方案。可能还要提醒用户,浮点数问题不仅在Python中存在,而是所有使用IEEE754标准的编程语言的共同问题,比如JavaScript也有类似的情况。最后,生成相关的问题,比如如何避免浮点数误差,其他语言中的情况,或者高精度计算的替代方案。这样用户如果有进一步的问题,可以继续探索。</think>### 为什么 $0.1 + 0.2 \neq 0.3$ 输出 `False`? #### 1. **浮点数的二进制表示问题** 计算机使用二进制浮点数(遵循 IEEE 754 标准)存储小数。部分十进制小数(如 $0.1$、$0.2$)无法精确表示为二进制分数,导致存储时存在**舍入误差**。例如: - $0.1$ 的二进制表示为无限循环小数:$0.0001100110011\dots_2$ - $0.2$ 的二进制表示为:$0.001100110011\dots_2$ 当它们在双精度浮点数(64 位)中被截断存储时,实际存储值与理论值存在微小偏差[^1][^3]。 #### 2. **运算误差累积** 计算 $0.1 + 0.2$ 时,两个近似值相加会进一步放大误差: $$0.1_{\text{存储值}} + 0.2_{\text{存储值}} = 0.300000000000000044\dots$$ 而 $0.3$ 的存储值为: $$0.3_{\text{存储值}} = 0.299999999999999988\dots$$ 两者在二进制中不相等,因此 `==` 比较返回 `False`[^1][^3]。 #### 3. **验证代码** ```python print(0.1 + 0.2) # 输出 0.30000000000000004 print(0.1 + 0.2 == 0.3) # 输出 False ``` --- ### 解决方案 #### 方法 1:允许误差范围 通过比较两数的差值是否小于一个极小值(如 $10^{-15}$): ```python def is_close(a, b, tol=1e-15): return abs(a - b) < tol print(is_close(0.1 + 0.2, 0.3)) # 输出 True ``` #### 方法 2:使用 `math.isclose`(Python 3.5+) 内置函数 `math.isclose` 可自动处理浮点数误差: ```python import math print(math.isclose(0.1 + 0.2, 0.3)) # 输出 True ``` #### 方法 3:高精度计算(`decimal` 模块) 通过定点数运算避免二进制误差[^2]: ```python from decimal import Decimal a = Decimal(&#39;0.1&#39;) b = Decimal(&#39;0.2&#39;) c = Decimal(&#39;0.3&#39;) print(a + b == c) # 输出 True ``` #### 方法 4:整数运算替代 将小数转换为整数计算(如货币以“分”为单位): ```python cents_1 = 10 # 0.1 元 = 10 分 cents_2 = 20 # 0.2 元 = 20 分 print(cents_1 + cents_2 == 30) # 输出 True ``` --- ### 总结表格 | 方法 | 适用场景 | 精度控制 | 性能 | |---------------|------------------------|----------|-------| | 允许误差范围 | 简单比较 | 手动调整 | 高 | | `math.isclose`| 通用场景 | 自动调整 | 中 | | `decimal`模块 | 财务、高精度计算 | 完全精确 | 低 | | 整数运算 | 离散单位(如货币) | 完全精确 | 高 | ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值