Python 系列(三十) 为什么 0.1 + 0.2 不等于 0.3?彻底搞懂 Decimal
1 | 作者:李晓辉 |
- Python 系列(一) 从零开始认识 Python,入门其实没那么难
- Python 系列(二) 学 Python 的第一步,把开发环境搭建好
- Python 系列(三) 基础语法避坑指南,缩进、大小写、续行与注释
- Python 系列(四) Python 输入输出详解,print()、input() 与常见避坑指南
- Python 系列(五) 数值类型与运算符详解,整除、浮点精度与进制转换实战
- Python 系列(六) 字符串处理实战,查找、替换、切片与日志分析
- Python 系列(七) 类型转换详解,int()、float()、str() 与金额计算避坑
- Python 系列(八) Python 流程控制入门,掌握 if、elif 和 else
- Python 系列(九) Python 逻辑运算符详解,and、or、not 一篇搞懂
- Python 系列(十) while、for 循环实战,彻底搞懂 range、break 与 continue
- Python 系列(十一) list、tuple、set、dict,到底该怎么选?
- Python 系列(十二) List 列表实战,搞定数据增删改查与批量处理
- Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
- Python 系列(十四) Set 集合实战,搞定数据去重与集合运算
- Python 系列(十五) Dict 字典详解,掌握快速查找与键值操作
- Python 系列(十六) sort() 和 sorted() 详解,掌握数据排序技巧
- Python 系列(十七) 自定义函数入门,让重复代码一次编写多次使用
- Python 系列(十八) 函数参数详解,搞懂位置参数、关键字参数和默认参数
- Python 系列(十九) 从 *args 到 **kwargs,让函数调用更灵活
- Python 系列(二十) 变量作用域是什么?一文搞懂 global 和 nonlocal
- Python 系列(二十一) 搞懂变量、对象和引用,理解函数参数传递机制
- Python 系列(二十二) map() 和 filter() 详解,轻松实现批量数据处理与筛选
- Python 系列(二十三) 嵌套函数与 lambda 表达式详解,搞懂作用域与闭包
- Python 系列(二十四) 搞懂递归函数,从基线条件到实际应用
- Python 系列(二十五) 聊聊模块与 import,让代码告别重复编写
- Python 系列(二十六) Python 模块入门,从 import 到模块复用
- Python 系列(二十七) Python dir() 函数详解,快速查看模块中的函数与属性
- Python 系列(二十八) 不会用 Python 标准库?教你快速查找模块和函数
- Python 系列(二十九) 搞懂 Python sys 模块,让脚本更适合自动化运维
- Python 系列(三十) 为什么 0.1 + 0.2 不等于 0.3?彻底搞懂 Decimal
- Python 系列(三十一) 搞懂 datetime 日期时间,轻松处理时区与时间计算
- Python 系列(三十二) 面向对象编程入门,从零理解类、对象、封装与继承
前面我们学习了 Python 的数据类型、流程控制,以及如何使用标准库中的模块。
在实际编程中,数字几乎无处不在。
比如,检查服务器磁盘使用率时,需要计算百分比;统计业务数据时,可能需要计算平均值;处理金额时,需要避免浮点数带来的精度问题;编写监控脚本时,还可能需要计算平方根、向上取整或向下取整。
这些需求,Python 都提供了相应的解决方案。
本篇我们重点学习两个标准库模块:
math:提供常用的数学函数,例如平方根、向上取整、向下取整等。decimal:提供十进制数的精确计算能力,适合对金额等十进制精度有要求的场景。
先从最熟悉的数学运算开始。
使用 math 模块进行数学运算
在 Python 中,我们可以直接使用 +、-、*、/ 等运算符完成基本的数学计算。
但是,如果需要计算平方根、对数字向上取整,或者进行三角函数运算,仅靠这些运算符就不够了。
这时候,可以使用 Python 标准库中的 math 模块。
使用之前,先导入模块:
1 | import math |
导入后,就可以通过 math.函数名() 的方式调用其中的功能。
例如,计算一个数字的平方根:
1 | import math |
运行结果:
1 | 4.0 |
这里的 math.sqrt() 用于计算平方根。
需要注意,计算结果通常是 float 类型。即使 16 的平方根恰好是整数 4,返回值仍然是 4.0。
这并不影响后续计算,因为整数和浮点数可以参与常见的混合算术运算。
向上取整与向下取整
在处理监控数据、分页数量或资源分配时,经常会遇到一个问题:计算结果不是整数,应该如何处理?
例如,我们需要把 101 个任务分配给每批最多处理 10 个任务的工作进程。
显然,不能只处理 10 批,因为还剩下 1 个任务。
这时候就需要向上取整。
1 | import math |
运行结果:
1 | 11 |
其中:
math.ceil():向上取整,返回大于或等于原数字的最小整数。math.floor():向下取整,返回小于或等于原数字的最大整数。
需要特别注意,向上取整不等于直接删除小数部分,向下取整也不完全等于截断小数部分。
例如:
1 | import math |
运行结果:
1 | -2 |
为什么会出现这样的结果?
因为 math.floor(-2.3) 是向负无穷方向取整,所以结果是 -3;而 int(-2.3) 是直接截掉小数部分,结果是 -2。
对于正数而言,这几个操作有时看起来很相似;但处理负数时,它们的区别就非常明显了。
在 Linux 运维中计算磁盘使用率
了解了基本的数学函数,我们再来看一个更贴近实际工作的例子。
假设我们希望通过 Python 检查 Linux 服务器的磁盘使用率。当使用率达到 80% 时,输出一条警告信息。
前面我们已经接触过 shutil.disk_usage(),它可以返回文件系统的总容量、已使用空间和剩余空间。
现在,我们将它与数学运算结合起来:
1 | import shutil |
这段代码涉及几个值得理解的知识点。
首先:
1 | percent = usage.used / usage.total * 100 |
这里通过除法计算已使用空间占总容量的比例,再乘以 100,转换为百分比。
其次:
1 | usage.total / 1024**3 |
这里的 ** 是 Python 的乘方运算符,1024**3 表示 1024 的三次方。
在 Linux 运维中,我们经常使用 GiB 表示以 (1024^3) 字节为单位的容量,因此这里将字节数转换为 GiB。
最后:
1 | f"{percent:.2f}%" |
这里使用了 f-string 格式化字符串,.2f 表示显示两位小数。
例如,计算结果为 83.4567 时,输出会显示为 83.46%。
需要注意,这个示例计算的是 shutil.disk_usage("/") 返回的文件系统使用率,并不等同于 df -h 所有场景下的显示结果,也不代表整个服务器所有磁盘的使用率。实际监控时,应根据业务需要检查指定挂载点,并考虑告警阈值和异常处理。
为什么 0.1 + 0.2 不等于 0.3?
接下来,我们讨论一个 Python 初学者经常遇到的问题。
运行下面的代码:
1 | print(0.1 + 0.2) |
输出结果是:
1 | 0.30000000000000004 |
咦?明明是 0.1 + 0.2,为什么结果不是 0.3?
这并不是 Python 的计算错误,而是因为 Python 中的 float 通常使用二进制浮点数表示数字。
有些十进制小数无法用有限长度的二进制小数精确表示,就像十进制中 (1/3) 无法用有限位小数精确表示一样。
因此,0.1 和 0.2 转换为二进制浮点数后,存储的值只是非常接近原始十进制数的近似值。进行计算时,这种微小的表示误差就可能显现出来。
对于一般的科学计算、性能数据统计等场景,浮点数通常已经足够使用。
但是,如果涉及金额计算、财务数据或其他要求十进制结果精确的场景,就需要更加谨慎。
这时候,可以考虑使用 decimal 模块。
使用 decimal 进行精确的十进制计算
decimal 是 Python 标准库中的模块,提供十进制数的算术运算能力。
先看一个例子:
1 | from decimal import Decimal |
运行结果:
1 | 0.3 |
这次结果符合我们的预期。
这里有一个非常重要的细节:
1 | Decimal("0.1") |
我们传入的是字符串 "0.1",而不是浮点数 0.1。
为什么推荐这样写?
因为字符串 "0.1" 可以准确地表达十进制数 0.1。Decimal 会根据这个十进制表示创建相应的数值。
如果先将 0.1 转换为二进制浮点数,再把它传给 Decimal,原有的浮点数表示误差可能也会被保留下来。
例如:
1 | from decimal import Decimal |
第二行的输出会显示一个较长的十进制数值,反映出浮点数 0.1 的实际近似表示。
所以,如果业务要求十进制精度,通常应该从字符串创建 Decimal,避免先经过 float。
模拟一个简单的金额计算
假设我们需要计算两项费用的总金额。
1 | from decimal import Decimal |
运行结果:
1 | 服务器费用:199.90 |
这里使用 Decimal,可以避免普通二进制浮点数表示带来的典型精度问题。
不过,需要明确一点:使用 Decimal 并不意味着所有计算都自动符合财务规则。对于金额计算,还应根据业务要求确定小数位数、舍入方式以及中间计算的精度。
例如,需要将金额保留两位小数时,可以使用 quantize():
1 | from decimal import Decimal, ROUND_HALF_UP |
运行结果:
1 | 20.00 |
这里的 Decimal("0.01") 表示保留到小数点后两位,ROUND_HALF_UP 指定了相应的舍入方式。
在金额计算中,舍入规则可能会影响最终结果,因此应该根据实际业务要求选择,而不是简单地认为所有场景都应该使用同一种规则。
Decimal 综合实战:编写云服务器费用结算脚本
前面我们已经了解了 Decimal 的基本用法。
但是,只会计算 0.1 + 0.2 还不够。实际工作中,我们更关心的是:当一笔费用涉及多个资源、折扣和金额舍入时,如何保证计算过程清晰、结果符合业务规则?
假设你正在维护一个云平台,需要统计某台服务器一个月的资源费用。
本次模拟结算包含以下项目:
| 资源项目 | 计费方式 | 单价 |
|---|---|---|
| 云服务器 | 按小时计费 | 0.85 元/小时 |
| 云硬盘 | 按 GB·月计费 | 0.30 元/GB·月 |
| 公网带宽 | 按 GB 流量计费 | 0.80 元/GB |
假设本月云服务器运行了 720 小时,使用了 100 GB 云硬盘,产生了 125.5 GB 公网流量,并且符合 9 折优惠条件。
我们希望程序能够完成以下任务:
- 计算每项资源的费用。
- 汇总资源费用。
- 计算折扣金额和折后金额。
- 将最终金额保留两位小数。
- 输出清晰的费用清单。
这里使用的是演示价格和计费规则,并不代表任何云厂商的真实报价。
编写完整的费用结算脚本
创建 cloud_cost.py 文件:
1 | from decimal import Decimal, ROUND_HALF_UP |
运行结果
1 | ==================================== |
这样,一份简单的云资源费用结算单就完成了。
接下来,我们逐步拆解其中的关键知识点。
为什么金额必须使用字符串创建 Decimal?
先看这几行代码:
1 | server_price = Decimal("0.85") |
这里没有直接写成:
1 | server_price = Decimal(0.85) |
原因在于,0.85 作为 Python 的 float,通常无法用二进制精确表示。如果先创建浮点数,再传给 Decimal,浮点数中已有的表示误差就可能被带入计算。
而 Decimal("0.85") 直接根据十进制字符串创建数值,避免了这条转换路径。
因此,在金额计算中,建议遵循一个简单的习惯:
从输入、计算到结果,尽量让金额数据始终使用 Decimal,不要中途转换成 float。
如果金额来自 JSON、数据库或接口,也要注意数据的实际类型。对于以十进制字符串传递的金额,可以直接使用 Decimal 解析;如果上游已经把金额转换成浮点数,就不能假设精度仍然完整。
Decimal 不只是加减法,也可以进行乘法和折扣计算
在这个案例中,我们使用了:
1 | server_cost = server_price * server_hours |
以及:
1 | discounted_total = subtotal * discount_rate |
Decimal 支持常见的算术运算,包括加法、减法、乘法和除法。
因此,它不仅适用于两项金额相加,也适合处理计费单价、资源使用量和折扣比例等计算。
这里需要区分两个概念:
- 计算精度:使用
Decimal避免典型的二进制浮点表示误差。 - 业务舍入规则:决定什么时候保留几位小数,以及采用什么舍入方式。
即使使用 Decimal,也仍然需要明确业务舍入规则。
为什么使用 quantize() 保留两位小数?
在代码中,我们定义了:
1 | cent = Decimal("0.01") |
它表示金额保留到小数点后两位。
然后使用:
1 | discounted_total.quantize( |
这里的 quantize() 会按照指定的舍入规则,将结果量化到相应的小数位数。
ROUND_HALF_UP 表示遇到正好位于中间的舍入情况时,按远离零的方向舍入。例如:
1 | from decimal import Decimal, ROUND_HALF_UP |
输出结果:
1 | 12.34 |
需要注意,ROUND_HALF_UP 并不是所有财务系统都必须采用的规则。不同业务可能使用不同的舍入方式,应该根据合同、计费规范或财务要求进行选择。
费用应该在什么时候舍入?
这是金额计算中很容易被忽略的问题。
假设三项资源费用分别计算得到:
1 | from decimal import Decimal |
如果每项费用先独立舍入到两位小数,再进行汇总:
1 | from decimal import Decimal, ROUND_HALF_UP |
结果为:
1 | 30.03 |
如果先汇总,再对总金额舍入:
1 | total_then_round = ( |
结果为:
1 | 30.02 |
看到了吗?两种方式相差了 0.01 元。
这不是 Decimal 算错了,而是因为两种计算方式采用了不同的舍入时机。
在实际计费系统中,需要根据业务规则确定是逐项舍入,还是先汇总再舍入。不能为了让结果看起来整齐,就随意修改计算顺序。
进阶练习:增加费用输入和异常处理
现在,我们可以进一步把固定的资源使用量改成用户输入,让脚本更接近真实的运维工具。
例如,要求用户输入云服务器运行小时数:
1 | from decimal import Decimal, InvalidOperation |
运行时输入:
1 | 请输入服务器运行小时数:720 |
如果输入 abc、负数或者 NaN,程序会给出错误提示,而不是继续进行不合理的费用计算。
这里使用 Decimal.is_finite() 检查输入是否为有限数值,避免 NaN 和无穷大等特殊值参与业务计算。
注意,这只是一个入门练习。生产环境中还需要根据计费规则校验输入范围、单位、精度和资源计费周期,并明确舍入规则及异常处理策略。
本节小结
通过这个实战,我们把 Decimal 应用到了更完整的业务场景中:
| 知识点 | 实际用途 |
|---|---|
Decimal("0.85") | 创建十进制单价 |
Decimal 加减乘除 | 计算资源费用、汇总账单和折扣 |
quantize() | 将金额舍入到指定小数位 |
ROUND_HALF_UP | 明确指定一种舍入规则 |
is_finite() | 检查输入是否为有限数值 |
| 异常处理 | 处理不合法的输入 |
记住:Decimal 能帮助我们控制十进制计算精度,但不能代替业务规则设计。金额系统真正需要保证的,是数值表示、计算顺序、舍入时机和异常处理的一致性。
math 和 decimal 应该怎么选?
学习了这两个模块后,我们可以根据实际需求选择工具。
| 使用场景 | 推荐方式 | 原因 |
|---|---|---|
| 计算平方根 | math.sqrt() | 提供常见数学函数 |
| 向上取整 | math.ceil() | 返回向上取整结果 |
| 向下取整 | math.floor() | 返回向下取整结果 |
| 计算磁盘使用率 | 普通算术运算 | 百分比计算通常使用浮点数即可 |
| 科学计算、性能数据分析 | float 或 math | 适合常见近似数值计算 |
| 金额加减与十进制计算 | Decimal | 避免典型的二进制浮点表示误差 |
| 金额保留指定小数位 | Decimal.quantize() | 可以明确指定舍入规则 |
需要记住的是,math 和 decimal 解决的问题并不完全相同。
math 主要提供数学函数;decimal 主要用于十进制数的精确表示与运算。它们都是 Python 标准库的一部分,使用之前不需要额外安装第三方软件包。
如果想继续了解更多功能,可以查看 Python 官方文档:
综合运维实战:编写 Linux 磁盘空间检查脚本
前面我们分别学习了 math 和 decimal 模块,也通过简单的例子了解了它们的使用方法。
现在,我们把这些知识组合起来,编写一个更完整的 Linux 运维脚本。
假设你需要检查 Linux 服务器的根分区,并完成以下任务:
- 获取根分区的总容量、已使用空间和剩余空间。
- 将字节转换为 GiB,方便阅读。
- 计算磁盘使用率,并保留两位小数。
- 当磁盘使用率达到 80% 时,输出告警信息。
- 根据磁盘使用率判断当前磁盘状态,并返回相应的退出状态码,方便后续接入 Shell 自动化任务。
编写脚本
创建 disk_check.py 文件:
1 | #!/usr/bin/env python3 |
逐步理解代码
第一步:获取磁盘使用情况
1 | usage = shutil.disk_usage("/") |
shutil.disk_usage() 返回一个包含总容量、已使用空间和剩余空间的结果对象,分别通过 usage.total、usage.used 和 usage.free 获取,单位都是字节。
这里使用 try...except 捕获可能发生的 OSError,例如挂载点无法访问等情况。如果检查失败,脚本会向标准错误输出错误信息,并通过 sys.exit(2) 以非零状态退出。
第二步:将字节转换为 GiB
1 | gib = 1024 ** 3 |
这里的 ** 是乘方运算符,1024 ** 3 表示 (1024^3)。
Linux 运维中经常需要把字节转换成 GiB,否则输出一长串数字,不利于人工阅读。
第三步:计算磁盘使用率
1 | percent = usage.used / usage.total * 100 |
例如,假设总容量为 100 GiB,已使用空间为 85 GiB,那么使用率就是 85%。
这里使用普通浮点数计算即可,因为磁盘监控通常不需要财务级别的十进制精度。
第四步:使用 Decimal 格式化百分比
1 | percent_display = Decimal(str(percent)).quantize( |
这里使用 Decimal 和 quantize() 明确指定两位小数的舍入规则。
需要注意,Decimal(str(percent)) 只是将浮点计算得到的结果转换为十进制表示,并不能消除前面浮点计算本身可能产生的误差。对于本例的磁盘监控显示,这通常已经足够;如果需要严格的十进制计算,应从精确的十进制数据源开始。
实际上,在普通磁盘监控中,直接使用 f-string 的 :.2f 格式化百分比通常也足够。这里保留 Decimal 主要是为了展示它的舍入功能。
第五步:根据阈值返回退出状态码
1 | if percent >= 80: |
这里的 sys.exit(0) 表示正常结束,sys.exit(1) 表示检测到告警情况,sys.exit(2) 则用于表示检查过程中发生错误。
这样,其他 Shell 脚本就可以通过退出状态码判断检查结果。
运行脚本
在 Linux 服务器上执行:
1 | python3 disk_check.py |
假设某台测试服务器的根分区总容量为 100 GiB,已使用 85 GiB,输出可能如下:
1 | 检查挂载点:/ |
以上是演示输出,实际数值取决于运行脚本的服务器。
如果磁盘使用率低于 80%,脚本会输出正常状态,并返回退出码 0。
需要注意,脚本判断使用率时使用的是未舍入的 percent,而不是格式化后的 percent_display。这样可以避免显示时的舍入影响告警判断。
进阶练习:批量检查多个挂载点
现在,可以尝试继续扩展脚本:
- 将检查路径从固定的
/改为列表,例如["/", "/var", "/home"]。 - 使用循环逐个检查挂载点。
- 当某个挂载点使用率达到 80% 时,输出对应的告警。
- 当一个挂载点检查失败时,继续检查其他挂载点,最后通过退出状态码汇总检查结果。
- 使用
math.ceil()计算某个容量需要多少个存储单元。
最后一个问题可以这样计算。
假设有 101 GiB 的数据,每个存储单元最多存放 10 GiB,需要多少个存储单元?
1 | import math |
运行结果:
1 | 需要的存储单元数量:11 |
这里的 math.ceil() 可以确保只要还有剩余数据,就会多分配一个存储单元。
完成这些练习后,你就把数学函数、十进制舍入、条件判断、异常处理和退出状态码串联了起来。这些知识也正是后续编写自动化运维脚本时经常会用到的基础。
写在最后
本篇我们学习了 Python 标准库中的 math 和 decimal 模块。
通过 math.sqrt()、math.ceil() 和 math.floor(),我们可以完成常见的数学运算;通过 Decimal,我们也理解了为什么金额计算不能总是直接依赖 float。
在云服务器费用结算实战中,我们进一步学习了资源计费、折扣计算、金额舍入和异常输入处理;在磁盘空间检查实战中,我们将容量换算、使用率计算、告警判断和退出状态码组合起来,完成了一个可以在 Linux 环境中运行的基础检查脚本。
学习 Python,不只是记住几个函数,更重要的是理解它们适合解决什么问题,并能够将这些知识应用到真实工作中。
下一篇,我们将学习 Python 的日期与时间模块,看看如何获取当前时间、格式化日期,以及计算时间间隔,让脚本能够处理日志时间、任务执行时间和定时任务等常见场景。
