当前位置:首页>python>你的 Python 算了一笔账,差了三分钱,浮点数这个坑你踩了多久

你的 Python 算了一笔账,差了三分钱,浮点数这个坑你踩了多久

  • 2026-09-07 07:48:06
你的 Python 算了一笔账,差了三分钱,浮点数这个坑你踩了多久

你的 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 设计出来是为了科学计算的,在那个领域它的精度和性能平衡得很好。问题出在我们把一个科学计算工具拿来算账,就像用游标卡尺去量体重——精度确实不够,但那是用错了工具,不是卡尺的错。

知道什么时候用什么工具,才是工程师该干的事情。

最后,别忘了关注「有为大青年」,我们下期见~

最新文章

随机文章