你的 Python 算了一笔账,差了三分钱,浮点数这个坑你踩了多久
上周五下班前,财务的同事跑过来找我,说对账系统出了一个诡异的问题。
明明每笔订单的金额都是对的,汇总到一起,总数硬是差了三分钱。
三分钱。不多不少,就三分。
她把 Excel 里的数据和系统导出的数据摆在一起,一笔一笔核对,每笔都对得上。但 SUM 的结果就是不一样。她盯着看了半个小时,以为是自己眼花了。
我当时的反应是:这不就是浮点数嘛。
经典的 0.1 + 0.2
如果你在 Python 里跑这么一行:
print(0.1 + 0.2)
你觉得输出是什么?
不是 0.3,是 0.30000000000000004。
这不是 Python 的 bug,这是 IEEE 754 浮点数标准本身的特性。计算机用二进制存小数,而 0.1 这个数在二进制里是一个无限循环小数,就像十进制里的 1/3 一样——你永远没法精确表示它,只能截断。
截断就会有误差。单笔看不出问题,但当你把一万笔订单金额加在一起,误差就累积了。三分钱就是这么来的。
你可能觉得三分钱无所谓,但如果是一百万笔交易呢?如果是银行结算呢?如果是科研数据里小数点后第八位的精度呢?
为什么 round 也救不了你
很多人碰到这个问题,第一反应是加个 round:
print(round(0.1 + 0.2, 2))
这次确实输出了 0.3。看起来解决了?
没有。看看这个:
print(round(2.675, 2))
你期望 2.68,但实际输出是 2.67。
原因还是一样:2.675 这个数在二进制里也不精确,它实际存储的值比 2.675 略小一点,round 的时候被舍掉了。round 操作本身就是基于一个已经不精确的值做的,你在一个错误的基础上做四舍五入,结果还是错的。
这就好比你用一把刻度不准的尺子量东西,量完之后再"精确地"修整一下,修整得再认真也没用,因为原始数据就是歪的。
decimal 模块:换一把准的尺子
Python 标准库有个模块叫 decimal,专门解决这个问题。它的思路很直接:不用二进制浮点数了,用十进制。
from decimal import Decimal
print(Decimal('0.1') + Decimal('0.2'))
输出:0.3。
干干净净,没有任何多余的尾巴。
关键在于传给 Decimal 的参数。注意我用的是字符串 '0.1',不是数字 0.1。如果你写成 Decimal(0.1),它会把 0.1 那个不精确的二进制值传进去,结果还是 0.1000000000000000055511151231257827021181583404541015625。
用字符串,Python 就会按十进制来解析,存的就是精确的 0.1。
对账场景完整复现
回到开头那个对账问题。假设有一批订单金额,每笔都是带两位小数的:
from decimal import Decimal, getcontext
# 模拟 1000 笔订单
order_amounts = [19.99, 29.99, 9.99, 49.99, 99.99] * 200
# 浮点数求和
float_total = sum(order_amounts)
print(f"浮点数求和: {float_total}")
# Decimal 求和
decimal_total = sum(Decimal(str(a)) for a in order_amounts)
print(f"Decimal 求和: {decimal_total}")
输出:
浮点数求和: 41990.00000000001
Decimal 求和: 41990.00
浮点数求和结果多了 0.00000000001。看起来很小,但如果你拿这个数去做后续计算,比如乘以一个税率、再乘以一个汇率,误差会像滚雪球一样放大。
实际业务中更常见的场景是单价乘以数量再乘以折扣,中间每一步都有精度问题:
from decimal import Decimal, ROUND_HALF_UP
price = Decimal('19.99')
quantity = Decimal('3')
discount = Decimal('0.85') # 八五折
subtotal = price * quantity
total = subtotal * discount
# 保留两位小数,四舍五入
total = total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
print(f"小计: {subtotal}")
print(f"折后总价: {total}")
输出:
小计: 59.97
折后总价: 50.97
如果你用浮点数算同样的东西:
subtotal = 19.99 * 3
total = subtotal * 0.85
print(f"浮点数折后总价: {total}")
输出:50.974499999999996。
你拿这个数去 round(, 2),得到 50.97。碰巧对了。但换个数字就不一定了。问题在于你没法保证每次都"碰巧对了",而这恰恰是金融场景里最不能接受的事情。
quantize:精确控制小数位
Decimal 给你提供了一个 quantize 方法,用来控制保留几位小数。它的参数看起来有点奇怪,是一个 Decimal 对象:
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN, ROUND_CEILING
value = Decimal('2.675')
# 不同舍入策略
print(value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)) # 2.68
print(value.quantize(Decimal('0.01'), rounding=ROUND_DOWN)) # 2.67
print(value.quantize(Decimal('0.01'), rounding=ROUND_CEILING)) # 2.68
Decimal('0.01') 的意思是"精确到百分位"。如果你想精确到千分位,就用 Decimal('0.001')。想精确到整数,用 Decimal('1')。
ROUND_HALF_UP 是最常见的四舍五入,跟我们小学学的一样。但金融场景里有时候需要别的策略:ROUND_DOWN 是直接截断(不管后面是什么都舍掉),ROUND_CEILING 是向正无穷方向取整。这些在计算利息、手续费的时候会用到,因为不同场景对"多收"还是"少收"的容忍度不一样。
Context:全局精度控制
如果你整个项目都在做高精度计算,每行都写 quantize 太烦了。Decimal 提供了一个 Context 机制,可以全局设置精度:
from decimal import Decimal, getcontext, setcontext, Context
# 查看默认精度
print(getcontext().prec) # 28
# 设置全局精度为 10 位有效数字
getcontext().prec = 10
a = Decimal('1') / Decimal('3')
print(a) # 0.3333333333
注意 prec 控制的是有效数字位数,不是小数位数。 prec=10 意味着总共保留 10 位有效数字,不管小数点在哪。
你还可以用 localcontext 做临时修改,不影响全局:
from decimal import Decimal, localcontext
a = Decimal('1') / Decimal('7')
print(f"默认精度: {a}")
with localcontext() as ctx:
ctx.prec = 50
b = Decimal('1') / Decimal('7')
print(f"50位精度: {b}")
c = Decimal('1') / Decimal('7')
print(f"退出后: {c}")
输出:
默认精度: 0.1428571428571428571428571429
50位精度: 0.14285714285714285714285714285714285714285714285714
退出后: 0.1428571428571428571428571429
这个在科研计算里特别有用。有些场景你需要中间过程保持高精度,只在最终结果处截断,这样可以最大程度减少累积误差。
什么时候用 Decimal,什么时候不用
Decimal 不是万能的,它有代价。
最大的代价是性能。Decimal 是用软件模拟十进制运算的,比 CPU 原生支持的浮点运算慢得多。做个简单的对比:
from decimal import Decimal
import time
n = 100000
# 浮点数
start = time.perf_counter()
s = 0.0
for i in range(n):
s += 0.1
float_time = time.perf_counter() - start
# Decimal
start = time.perf_counter()
s = Decimal('0')
for i in range(n):
s += Decimal('0.1')
decimal_time = time.perf_counter() - start
print(f"float: {float_time:.4f}s")
print(f"Decimal: {decimal_time:.4f}s")
print(f"差距: {decimal_time / float_time:.1f}x")
在一台普通机器上,输出大概是这样:
float: 0.0040s
Decimal: 0.3200s
差距: 80.0x
慢了 80 倍。
所以用不用 Decimal,取决于你的场景:
涉及钱的、涉及精确计量的、对账的——用 Decimal,没有商量。科学计算、机器学习、图像处理——用 float,Decimal 的精度在这些场景没有意义,但性能损失是实打实的。
还有一类场景容易忽略:JSON 序列化。如果你用 Decimal 存了金额,直接 json.dumps 会报错,因为 JSON 标准不支持 Decimal 类型:
import json
from decimal import Decimal
data = {"total": Decimal('19.99')}
# json.dumps(data) # TypeError
# 解决方案:转成字符串或 float
data["total"] = str(data["total"])
print(json.dumps(data)) # {"total": "19.99"}
转成字符串是最安全的,保留完整精度。转成 float 又把精度丢了,等于白费。
修复那个对账系统
回到开头的故事。最终的修复方案很简单,把所有金额字段从 float 换成 Decimal,从数据库读取的时候就用字符串接收,计算全程用 Decimal,只有最终展示的时候才转成人类可读的格式。
改完之后,对账系统再也没有差过一分钱。
财务同事问我:为什么不一开始就用这个?
我说:因为 float 快啊,大多数时候也"看起来"没问题。等到问题暴露的时候,已经积攒了十万行代码的浮点运算。
她翻了个白眼:所以你们程序员就是先制造问题再解决问题?
我没法反驳。
但说实话,浮点数不是"问题",它只是在不该用的场景被用了。IEEE 754 设计出来是为了科学计算的,在那个领域它的精度和性能平衡得很好。问题出在我们把一个科学计算工具拿来算账,就像用游标卡尺去量体重——精度确实不够,但那是用错了工具,不是卡尺的错。
知道什么时候用什么工具,才是工程师该干的事情。
最后,别忘了关注「有为大青年」,我们下期见~