本文要点
解决误差的第一步,是构造Decimal时把金额写成字符串,而不是直接传float
构造对了之后,加减本身和其他数值类型用法一样
加减做完,结果的小数位数可能不统一,比如有的是两位,有的是一位

本文目录
先看误差长什么样:float相加为什么对不上
如果你用Python算金额,先做一次对照,就能看到问题出在哪。
假设你在练习目录里新建一个脚本,把两个金额写成float相加:
print(1.1 + 2.2)按Python官方decimal文档的说明,1.1和2.2这类数在二进制浮点里没有精确表示,所以1.1 + 2.2不会显示成3.3,而会显示成3.3000000000000003。文档同时指出,这种差异会妨碍可靠的相等性判断,而且差异会累积。
这就是金额计算里最容易被忽略的一点:单个数字看起来没问题,多次相加之后尾数就偏了,用等号去比较两个“应该相等”的结果也可能判成不相等。
本文只讲三件事:用字符串构造Decimal、用Decimal做加减、用quantize把结果统一到固定小数位。不涉及投资建议,也不涉及汇率、税率等业务规则。所有示例都是自建的练习数据,不是对真实账目的记录。
动手前建议先建一个单独的练习目录,例如exercise/,不要拿真实账目文件直接试。
从字符串构造:Decimal('1.1')和Decimal(1.1)不是一回事
解决误差的第一步,是构造Decimal时把金额写成字符串,而不是直接传float。
按官方文档,Decimal可以从整数、字符串、浮点数或元组构造。从整数或浮点数构造时,执行的是对该值精确转换;如果传的是float,二进制浮点值会被无损转换成它的精确十进制等价物,这个转换往往需要53位甚至更多位精度。文档给了一个直接对照:
from decimal import Decimal
print(Decimal('1.1'))
print(Decimal(1.1))第一个输出是Decimal('1.1'),第二个输出是一长串数字,开头是1.100000000000000088817841970012523233890533447265625。也就是说,你以为传进去的是1.1,实际传进去的是float能表示的那个最接近值,误差在构造那一刻就被带进来了。
所以规则很简单:金额从字符串开始。数据来自表单、CSV、接口时,先保证它是字符串,再交给Decimal。
还有两点值得记住。第一,Decimal对象是不可变的,构造出来之后不能改。第二,新Decimal的有效位数只由输入的数字个数决定,上下文精度和舍入只在算术运算时才起作用。文档举的例子是Decimal('3.00000')会保留全部五个零,即使上下文精度只有三位。
加减示例:用自建金额做一次对账
构造对了之后,加减本身和其他数值类型用法一样。下面是一段自建练习,模拟三笔支出相加再减去一笔退款:
from decimal import Decimal
items = [Decimal('12.30'), Decimal('4.05'), Decimal('0.70')]
refund = Decimal('1.05')
total = sum(items) - refund
print(total)这段代码里,三个金额和退款都是字符串构造的Decimal,sum和减法都在Decimal之间进行,不会混入float。
有一个限制要提前知道:Decimal一般不能和float直接做算术运算。文档明确说,把Decimal和float相加这类操作会抛出TypeError。所以如果数据里混着float,要么在入口处就转成字符串再构造Decimal,要么在运算前统一类型,不要指望Python替你自动转换。
另外,Decimal之间的比较是支持的,可以用等号判断两个结果是否相等,这正是float容易出问题的地方。文档也提到,decimal模块适合有严格相等要求的记账类应用。
本节只演示加减。乘除、取余、幂运算虽然Decimal也支持,但不在本文范围内。
quantize统一到分:把结果固定成两位小数
加减做完,结果的小数位数可能不统一,比如有的是两位,有的是一位。要把金额统一显示到分,用quantize。
按文档,quantize返回一个等于第一个操作数、但指数等于第二个操作数的值,也就是把结果舍入到固定的小数位。文档给的示例是:
from decimal import Decimal, ROUND_DOWN
print(Decimal('7.325').quantize(Decimal('.01'), rounding=ROUND_DOWN))
print(Decimal('7.325').quantize(Decimal('1.'), rounding=ROUND_UP))第一个输出是Decimal('7.32'),第二个输出是Decimal('8')。可以看到,第二个参数决定保留几位:Decimal('.01')表示保留两位小数,Decimal('1.')表示舍入到整数。
把quantize接到前面的对账例子上:
from decimal import Decimal, ROUND_HALF_UP
total = Decimal('12.30') + Decimal('4.05') + Decimal('0.70') - Decimal('1.05')
result = total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
print(result)这里用Decimal('0.01')作为目标指数,rounding指定舍入方式。文档列出了多种舍入选项,包括ROUND_CEILING、ROUND_DOWN、ROUND_FLOOR、ROUND_HALF_DOWN、ROUND_HALF_EVEN、ROUND_HALF_UP、ROUND_UP等。具体用哪一种,取决于你的业务规则,本文不替你选。
还有一个边界:文档指出,如果quantize之后系数的长度超过精度,会触发InvalidOperation。默认精度是28位,但如果结果位数特别多,需要留意这一点。
检查结果:怎么确认没有混进float
算完之后,可以做一个简单检查:看结果的类型和表示。
from decimal import Decimal
value = Decimal('12.30') + Decimal('4.05')
print(type(value))
print(repr(value))如果type显示的是decimal.Decimal,说明运算在Decimal之间完成。如果中间某一步不小心混入了float,做加减运算会直接抛TypeError,而不是悄悄给出一个带误差的结果,这反而是一个好信号:出错比出错得看不出来更容易排查。
另一个检查点是构造阶段。如果结果里出现了很长的尾数,比如1.1000000000000000888…,通常说明某个金额是用float构造的,而不是字符串。回到构造那一步,把float换成字符串。
文档还提到一个可选的保护措施:如果把FloatOperation信号设为trap,那么在构造器或排序比较里意外混用Decimal和float会抛出异常。默认这个trap是关闭的。是否开启取决于你的项目,本文只说明它存在,不替你决定。
检查顺序建议是:先看构造是不是字符串,再看运算有没有抛TypeError,最后看quantize之后的小数位是不是你要的位数。
使用限制:精度、上下文和适用边界
用Decimal之前,有几条限制需要知道,避免把它当成万能工具。
第一,上下文精度默认是28位。文档说明,decimal模块有用户可修改的精度,默认28位,可以按问题需要设得更大。精度只在算术运算时起作用,不影响构造时保留的位数。
第二,上下文还控制舍入规则、指数范围、信号和trap。文档列出了多种舍入模式,并说明信号包括Clamped、InvalidOperation、DivisionByZero、Inexact、Rounded、Subnormal、Overflow、Underflow和FloatOperation。对初学者来说,最常遇到的是InvalidOperation和FloatOperation。
第三,Decimal不能和float直接做算术运算,会抛TypeError。这是设计上的限制,不是bug。
第四,本文只覆盖字符串构造、加减和quantize。乘除、取余、幂运算、上下文切换、localcontext等都没有展开。如果你的场景涉及这些,需要另外查文档。
第五,本文不提供任何投资、理财或税务建议。示例中的金额都是自建练习数据,不对应任何真实交易。
观看知识要点视频 18 秒
18秒无旁白要点导览,文字来自已审核正文;完整步骤、条件及参考资料见下方。
参考资料
以下原始资料提供本文依据;请结合日期、适用条件和产品说明查阅。
请结合原始资料的适用条件阅读。
返回表格与数据 →