Python 基础体系 · 第 45/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python Decimal 与 Fraction:精度、舍入、上下文和金融计算
在 Python 中,“精度”不是一个单一问题。至少要区分三件事:
- 表示精度:一个数能否被数据类型准确表示;
- 运算精度:多个准确或近似的数参与运算后,结果保留多少信息;
- 业务精度:结果最终允许保留几位小数、采用什么舍入规则,以及何时舍入。
float 主要使用二进制浮点数;Decimal 使用十进制浮点模型,并提供可配置的精度、舍入和异常信号;Fraction 使用整数分子与整数分母表示有理数。三者解决的问题不同,不能简单地说“Decimal 比 float 精确”或“Fraction 永远最好”。正确的选择取决于数据的来源、运算过程和结果约束。(docs.python.org)
一、先区分三个数值模型
1. float:二进制浮点近似
Python 的 float 通常对应 IEEE 754 双精度二进制浮点数。它用二进制小数保存数值,因此某些十进制小数无法有限表示。
例如:
print(0.1 + 0.2)
print((0.1 + 0.2) == 0.3)
典型输出为:
0.30000000000000004
False
这不是 Python 的加法错误,而是表示模型的结果。二进制中,0.1 和 0.2 都只能表示为附近的二进制数;加法是在这些近似值上进行的。Python 文档也明确指出,1.1、2.2 等十进制数通常不能被二进制浮点精确表示。(docs.python.org)
float 适合:
- 科学计算中的近似结果;
- 图形、物理模拟;
- 对误差有明确容忍范围的计算;
- 需要硬件浮点性能的场景。
它不适合直接承担“金额必须严格相等”的业务不变量。
2. Decimal:十进制浮点与定点计算
Decimal 的内部形式可以抽象为:
其中:
- 是符号,通常为
0或1; - 是由十进制数字组成的系数;
- 是十进制指数。
例如:
from decimal import Decimal
x = Decimal("123.4500")
print(x.as_tuple())
输出类似:
DecimalTuple(sign=0, digits=(1, 2, 3, 4, 5, 0, 0), exponent=-4)
这里的值为:
末尾的两个 0 不改变数值,但保留了输入的有效位信息。因此:
from decimal import Decimal
print(Decimal("1.30") + Decimal("1.20"))
print(Decimal("1.3") * Decimal("1.2"))
print(Decimal("1.30") * Decimal("1.20"))
输出:
2.50
1.56
1.5600
这正是金额计算中常见的“数值相等”和“表示精度”同时存在的情况。Decimal 对象本身不可变,且可以表示有限数、正负无穷、静默 NaN、信号 NaN,以及带符号的正零和负零。(docs.python.org)
3. Fraction:精确有理数
Fraction 表示:
其中 和 是整数,且分母 。Python 会自动约分,并保证分母为正。
from fractions import Fraction
a = Fraction(16, -10)
b = Fraction(6, 8)
print(a)
print(b)
print(a + b)
输出:
-8/5
3/4
-17/20
Fraction(16, -10) 实际保存的是:
因为两个整数的四则运算结果仍然是有理数,所以 Fraction 在其表示范围内可以避免舍入:
from fractions import Fraction
result = Fraction(1, 3) + Fraction(1, 6)
print(result)
print(float(result))
输出:
1/2
0.5
但“精确”不意味着“适合所有场景”。分母可能持续膨胀,最终导致对象变大、运算变慢;而金融系统通常还需要金额小数位、舍入方向、税务规则和格式化,这些不是 Fraction 的核心抽象。(docs.python.org)
二、Decimal 的精确输入:字符串和浮点数完全不同
1. 字符串输入表达十进制意图
金额应优先从字符串构造:
from decimal import Decimal
price = Decimal("19.99")
tax_rate = Decimal("0.13")
tax = price * tax_rate
print(tax)
输出:
2.5987
Decimal("19.99") 保存的是十进制数 19.99,而不是某个先前已经近似过的二进制浮点数。
2. 从 float 构造会保留浮点的真实二进制值
下面两种写法含义不同:
from decimal import Decimal
print(Decimal("0.1"))
print(Decimal(0.1))
典型输出:
0.1
0.1000000000000000055511151231257827021181583404541015625
Decimal(0.1) 并不是把源代码中的字符 "0.1" 重新解析成十进制,而是把 float 中已经存在的二进制近似值无损转换为十进制表示。Decimal.from_float(0.1) 也具有同样语义。(docs.python.org)
因此,以下代码不能修复已经发生的浮点误差:
from decimal import Decimal
bad = Decimal(0.1) + Decimal(0.2)
good = Decimal("0.1") + Decimal("0.2")
print(bad)
print(good)
应输出类似:
0.300000000000000016653345669433337482
0.3
如果输入已经是 float,可以明确选择一种语义:
from decimal import Decimal
binary_value = Decimal.from_float(0.1)
decimal_intent = Decimal(str(0.1))
print(binary_value)
print(decimal_intent)
Decimal(str(value)) 表示“采用浮点数的十进制显示结果”;Decimal.from_float(value) 表示“保留浮点数的实际二进制值”。二者都不是万能的清洗方法,关键在于你要保留哪一种语义。
Python 3.14 新增了 Decimal.from_number()。它只接受 int、float 或 Decimal,不接受字符串,因此适合在 API 类型边界上限制输入类别;它不会把 float 自动变成十进制字面量。(docs.python.org)
from decimal import Decimal
print(Decimal.from_number(10))
print(Decimal.from_number(Decimal("3.14")))
print(Decimal.from_number(0.1))
三、Decimal 的精度不是构造时精度
这是理解 Decimal 的关键。
from decimal import Decimal, getcontext
getcontext().prec = 5
x = Decimal("3.1415926535")
print(x)
y = x + Decimal("0")
print(y)
输出类似:
3.1415926535
3.1416
构造 x 时,当前上下文的 prec=5 不会截断字符串中的数字;上下文精度主要作用于运算结果。加入 Decimal("0") 后,发生了一次运算,结果才按照当前上下文舍入。(docs.python.org)
可以把运算理解为:
其中:
- 是精确输入上的数学运算;
- 是当前
Context; - 表示依据上下文进行舍入、指数检查和信号处理。
但这并不等于所有复杂表达式都天然获得无限精度。每个运算步骤都可能受到当前上下文影响。例如:
from decimal import Decimal, localcontext
a = Decimal("1.23456")
b = Decimal("7.89012")
with localcontext(prec=5):
first = a * b
result = first + Decimal("0.00001")
print(first)
print(result)
乘法结果先在 prec=5 下舍入,后续加法使用的已经是舍入后的 first。提高上下文精度,只能减少中间舍入,不能恢复已经丢失的信息。
四、上下文 Context:Decimal 运算的隐含环境
Decimal 的上下文是控制十进制运算的环境。它至少包含:
prec:有效数字精度;rounding:舍入模式;Emin、Emax:指数范围;flags:已经发生过的信号;traps:哪些信号应转化为异常。
默认上下文通常为 prec=28、ROUND_HALF_EVEN,并对 Overflow、DivisionByZero 和 InvalidOperation 启用陷阱。当前上下文可以通过 getcontext() 获取,通过 setcontext() 替换。(docs.python.org)
from decimal import getcontext
ctx = getcontext()
print(ctx.prec)
print(ctx.rounding)
print(ctx.Emin, ctx.Emax)
print(ctx.traps)
print(ctx.flags)
1. prec 控制有效数字,不是小数位数
from decimal import Decimal, localcontext
with localcontext(prec=4):
print(Decimal("123.4567") + Decimal("0"))
print(Decimal("0.1234567") + Decimal("0"))
输出类似:
123.5
0.1235
prec=4 表示结果保留四位有效数字:
123.4567→123.5;0.1234567→0.1235。
它不是“保留四位小数”。如果业务要求金额保留两位小数,应使用 quantize(),而不是把 prec 当作小数位配置。
2. localcontext() 用于局部改变状态
直接修改全局当前上下文容易让调用者之间互相影响:
from decimal import Decimal, getcontext
getcontext().prec = 6
value = Decimal(1) / Decimal(7)
# 后续代码仍然受到 prec=6 的影响
更安全的方式是:
from decimal import Decimal, localcontext
with localcontext(prec=50):
high_precision = Decimal(1) / Decimal(7)
print(high_precision)
退出 with 后,之前的上下文会恢复。localcontext() 会复制进入代码块时的上下文,并在退出时恢复原状态;Python 3.11 起还支持通过关键字参数直接设置上下文属性。(docs.python.org)
from decimal import Decimal, localcontext, ROUND_HALF_UP
with localcontext(prec=12, rounding=ROUND_HALF_UP):
result = Decimal("1") / Decimal("6")
print(result)
上下文是有状态的。对于多线程、异步请求或库代码,不应假设调用者会保持默认上下文,也不应在每个请求中无约束地修改共享配置。库函数更适合使用 localcontext(),并在边界处显式规定精度、舍入和异常策略。
五、舍入模式:同一个数字可以得到不同业务结果
Python decimal 提供多种舍入模式,包括:
ROUND_DOWN:向零舍入;ROUND_UP:远离零舍入;ROUND_FLOOR:向负无穷舍入;ROUND_CEILING:向正无穷舍入;ROUND_HALF_UP:最接近值,正好一半时远离零;ROUND_HALF_DOWN:最接近值,正好一半时向零;ROUND_HALF_EVEN:最接近值,正好一半时取偶数;ROUND_05UP:特殊的 0/5 舍入规则。(docs.python.org)
1. ROUND_HALF_EVEN 与 ROUND_HALF_UP
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP
x = Decimal("2.5")
y = Decimal("3.5")
print(x.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN))
print(y.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN))
print(x.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
print(y.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
输出:
2
4
3
4
ROUND_HALF_EVEN 在恰好一半时选择偶数,适合减少大量重复舍入时的统计偏差;ROUND_HALF_UP 更接近许多人工财务规则中“5 入”的直觉。但究竟使用哪一个,必须由业务规则、会计制度或外部协议决定,不能因为“看起来更符合常识”就自行选择。
2. 负数揭示了“向下”和“向零”的区别
from decimal import Decimal, ROUND_DOWN, ROUND_FLOOR, ROUND_UP
x = Decimal("-1.21")
print(x.quantize(Decimal("0.1"), rounding=ROUND_DOWN))
print(x.quantize(Decimal("0.1"), rounding=ROUND_FLOOR))
print(x.quantize(Decimal("0.1"), rounding=ROUND_UP))
输出:
-1.2
-1.3
-1.3
因为:
ROUND_DOWN是向零靠近;ROUND_FLOOR是向负无穷靠近;ROUND_UP是远离零。
“向下取整”在中文业务描述中经常含糊,必须明确它是“向零”还是“向负无穷”。
六、quantize():金融金额的固定小数位工具
quantize() 的作用不是简单格式化,而是把数值舍入到指定的指数。
from decimal import Decimal, ROUND_HALF_UP
CENT = Decimal("0.01")
amount = Decimal("7.325")
rounded = amount.quantize(CENT, rounding=ROUND_HALF_UP)
print(rounded)
输出:
7.33
这里:
Decimal("0.01")
表示指数为 -2,所以结果固定为两位小数。
from decimal import Decimal, ROUND_HALF_UP
for value in ["7.324", "7.325", "7.326", "-7.325"]:
result = Decimal(value).quantize(
Decimal("0.01"),
rounding=ROUND_HALF_UP,
)
print(value, "->", result)
输出:
7.324 -> 7.32
7.325 -> 7.33
7.326 -> 7.33
-7.325 -> -7.33
1. 量化不是显示格式化
from decimal import Decimal
value = Decimal("1.2300")
print(format(value, ".2f"))
print(value.quantize(Decimal("0.01")))
print(value)
输出:
1.23
1.23
1.2300
format() 产生字符串,主要影响展示;quantize() 产生新的 Decimal,改变结果的指数和舍入后的数值。原对象仍不可变。
2. 加法与乘法并不自动保持金额小数位
from decimal import Decimal
a = Decimal("102.72")
b = Decimal("3.17")
print(a + b)
print(a * 42)
print(a * b)
print(b / a)
输出:
105.89
4314.24
325.6224
0.030859...
加法以及乘以整数通常保留原有的固定小数位;非整数乘法和除法会产生新的小数位,若业务规定结果必须是两位小数,就必须显式量化。官方文档也给出了对乘法和除法追加 quantize() 的模式。(docs.python.org)
from decimal import Decimal, ROUND_HALF_UP
CENT = Decimal("0.01")
def money(value: Decimal) -> Decimal:
return value.quantize(CENT, rounding=ROUND_HALF_UP)
def money_mul(x: Decimal, y: Decimal) -> Decimal:
return money(x * y)
def money_div(x: Decimal, y: Decimal) -> Decimal:
return money(x / y)
print(money_mul(Decimal("102.72"), Decimal("3.17")))
print(money_div(Decimal("3.17"), Decimal("102.72")))
输出:
325.62
0.03
函数的真正作用是把“业务金额边界”固定下来,而不是让所有内部中间结果都立即舍入。
七、舍入时机:逐行舍入与最后舍入不是同一件事
考虑三笔金额:
from decimal import Decimal, ROUND_HALF_UP
CENT = Decimal("0.01")
items = [Decimal("0.005"), Decimal("0.005"), Decimal("0.005")]
line_total = sum(
item.quantize(CENT, rounding=ROUND_HALF_UP)
for item in items
)
final_total = sum(items).quantize(CENT, rounding=ROUND_HALF_UP)
print(line_total)
print(final_total)
输出:
0.03
0.02
推导过程如下:
逐行舍入:
因此:
最后舍入:
再进行:
两种结果都可能合理,取决于业务规则:
- 发票按每个明细行计税:可能需要逐行舍入;
- 数学上先合计再计算:可能需要最后舍入;
- 平台需要“明细合计必须等于总计”:还需要定义差额分摊规则。
因此,Decimal 只能准确执行指定的规则,不能替业务决定规则。
八、避免双重舍入:中间过程提高精度,边界统一量化
双重舍入是指一个结果先被舍入到较高位数,再被舍入到较低位数。它可能与直接舍入到目标位数不同。
例如:
from decimal import Decimal, ROUND_HALF_UP
value = Decimal("1.2451")
first = value.quantize(Decimal("0.001"), rounding=ROUND_HALF_UP)
second = first.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
direct = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print(first)
print(second)
print(direct)
输出:
1.245
1.25
1.25
这个例子结果相同,但不能据此认为双重舍入总是安全。中间步骤可能改变临界位,尤其是在不同舍入模式、负数或分步计算中。
更稳妥的原则是:
- 输入先解析为正确的数值模型;
- 中间计算保留足够的有效数字;
- 只有到明确的业务边界才
quantize(); - 输出、入库或对账前再验证目标尺度。
如果必须进行高精度中间计算,可以使用局部上下文:
from decimal import Decimal, localcontext, ROUND_HALF_UP
CENT = Decimal("0.01")
with localcontext(prec=40, rounding=ROUND_HALF_UP):
subtotal = Decimal("19.99") * Decimal("1.075")
total = subtotal.quantize(CENT)
print(total)
这里 prec=40 减少中间计算的有效数字损失,quantize(CENT) 明确规定最终金额尺度。
九、上下文信号:区分“舍入了”和“结果不精确”
Decimal 的信号用于记录特殊计算条件。常见信号包括:
Inexact:结果不是精确值;Rounded:结果发生了舍入或丢弃了数字;DivisionByZero:除以零;InvalidOperation:无效运算;Overflow、Underflow、Subnormal;FloatOperation:发生了受监控的浮点混用。(docs.python.org)
每个信号都有:
- 一个
flag,用于记录是否发生; - 一个
trap,用于决定是否直接抛出异常。
flag 是粘性的:一旦被设置,会一直保留,直到显式清除。因此监控一段计算前,应先调用 clear_flags()。(docs.python.org)
from decimal import Decimal, ExtendedContext, getcontext
ctx = getcontext()
ctx.clear_flags()
result = Decimal("1") / Decimal("7")
print(result)
print(ctx.flags)
典型结果中会出现:
Inexact
Rounded
1 / 7 的十进制展开是无限循环小数,所以:
Rounded表示超出当前精度的位被舍弃;Inexact表示被舍弃的位中存在非零数字。
1. 用 trap 把“不允许的舍入”变成异常
如果输入金额必须最多两位小数,可以利用 Inexact trap 做校验:
from decimal import Decimal, Context, Inexact
CENT = Decimal("0.01")
validation_context = Context(traps=[Inexact])
def validate_money_scale(value: Decimal) -> Decimal:
return value.quantize(CENT, context=validation_context)
print(validate_money_scale(Decimal("12.34")))
try:
validate_money_scale(Decimal("12.345"))
except Inexact:
print("输入超过两位小数")
输出:
12.34
输入超过两位小数
这与“直接舍入”不同:
Decimal("12.345").quantize(Decimal("0.01"))
会得到 12.34 或 12.35,而校验逻辑的目标是拒绝非法输入,而不是替调用者修改输入。
十、FloatOperation:阻止不明确的浮点混用
Decimal 不允许一般算术中直接混合 float:
from decimal import Decimal
try:
print(Decimal("1.1") + 0.2)
except TypeError as exc:
print(type(exc).__name__, exc)
但比较操作存在特殊规则:
from decimal import Decimal
print(Decimal("1.1") == 1.1)
即使比较结果是 True,也不能因此认为两个对象的内部表示相同。更严格的程序可以打开 FloatOperation trap:
from decimal import Decimal, FloatOperation, localcontext
with localcontext() as ctx:
ctx.traps[FloatOperation] = True
try:
Decimal(0.1)
except FloatOperation:
print("禁止从 float 构造 Decimal")
这适合在金融核心模块的测试或开发环境中发现隐式转换。生产环境是否启用,应结合已有数据边界和异常处理方式决定。(docs.python.org)
十一、Decimal.fma():避免乘加之间的中间舍入
Python 3.14 的 Decimal 提供了 fma():
x.fma(y, z)
它计算:
但不会先对中间乘积 舍入。(docs.python.org)
from decimal import Decimal, localcontext
x = Decimal("1.234567")
y = Decimal("9.876543")
z = Decimal("0.000001")
with localcontext(prec=8):
normal = x * y + z
fused = x.fma(y, z)
print(normal)
print(fused)
两者在简单例子中可能相同,但在低精度、高位数或临界舍入场景中可能不同。fma() 的价值是减少一次中间舍入,适合需要把线性表达式视为一个整体计算的场景。
它并不意味着结果自动满足金额小数位;最终仍然需要:
amount = x.fma(y, z).quantize(Decimal("0.01"))
十二、Fraction 的精确性与构造陷阱
1. 从整数和字符串构造
from fractions import Fraction
print(Fraction(3, 7))
print(Fraction("3/7"))
print(Fraction("0.125"))
print(Fraction("7e-6"))
输出:
3/7
3/7
1/8
7/1000000
从十进制字符串构造时,Fraction 会把有限十进制准确转换成有理数。例如:
2. 从 Decimal 构造通常可以保持十进制值
from decimal import Decimal
from fractions import Fraction
print(Fraction(Decimal("1.1")))
输出:
11/10
这是一种精确转换,因为有限十进制数本身就是有理数。(docs.python.org)
3. 从 float 构造会保留浮点实际值
from fractions import Fraction
print(Fraction(1.1))
print(Fraction("1.1"))
典型输出:
2476979795053773/2251799813685248
11/10
Fraction(1.1) 表示的是 float 中实际保存的二进制数,不是人们通常写下的十进制 1.1。如果业务含义是十进制输入,应使用:
Fraction("1.1")
而不是:
Fraction(1.1)
4. limit_denominator() 是近似,不是还原真相
from fractions import Fraction
f = Fraction("3.1415926535897932")
print(f.limit_denominator(1000))
输出:
355/113
limit_denominator(1000) 会寻找分母不超过 1000 的最接近有理数。它可用于把测量值或浮点值近似为简单分数,但结果是“受约束的近似”,不代表原始数据本来就是 355/113。(docs.python.org)
十三、什么时候选 Decimal,什么时候选 Fraction
适合 Decimal 的问题
Decimal 适合数值本身具有十进制业务含义的问题:
- 金额;
- 税率;
- 利率;
- 价格;
- 汇率;
- 需要固定小数位的计费结果;
- 需要指定舍入模式的报表。
它同时提供十进制表示、上下文精度、舍入模式、量化和异常信号,因此适合表达“值是多少”以及“结果如何被接受”。
适合 Fraction 的问题
Fraction 适合数学意义上的有理数:
- 分数运算;
- 配方比例;
- 音乐节拍和周期;
- 精确的概率表达;
- 有理函数;
- 需要延迟舍入的算法;
- 需要验证两个有理数是否严格相等。
例如比例计算:
from fractions import Fraction
part_a = Fraction(2, 5)
part_b = Fraction(1, 3)
total = part_a + part_b
share = part_a / total
print(total)
print(share)
输出:
11/15
6/11
如果最终要展示为百分比,再在输出边界转换:
from decimal import Decimal
percentage = (
Decimal(share.numerator) / Decimal(share.denominator)
).quantize(Decimal("0.01"))
print(percentage)
这里的转换过程明确分为两步:
- 用
Fraction保持比例的精确性; - 用
Decimal按十进制小数位生成展示结果。
十四、Decimal 与 Fraction 不能直接混合运算
下面的代码通常会失败:
from decimal import Decimal
from fractions import Fraction
try:
print(Decimal("1.2") + Fraction(1, 5))
except TypeError as exc:
print(type(exc).__name__, exc)
两种类型的核心语义不同:
Decimal的结果受上下文精度和舍入模式影响;Fraction需要保持有理数精确性。
不要依靠隐式转换,应在边界处明确转换。例如,将 Fraction 转为 Decimal:
from decimal import Decimal, localcontext
from fractions import Fraction
value = Fraction(1, 7)
with localcontext(prec=30):
decimal_value = Decimal(value.numerator) / Decimal(value.denominator)
print(decimal_value)
这里的结果不再是精确的有理数,而是当前上下文下的十进制近似。转换发生时,应明确接受这个语义变化。
将有限 Decimal 转为 Fraction 则可以精确:
from decimal import Decimal
from fractions import Fraction
value = Decimal("12.3400")
fraction = Fraction(value)
print(fraction)
输出:
617/50
Fraction 表示数值时会约分,因此不会保留 Decimal("12.3400") 的小数位展示信息。
十五、一个完整的金额计算示例
下面实现一个简单订单计算:
- 单价和数量来自字符串或整数;
- 折扣率、税率使用
Decimal; - 中间过程保留较高精度;
- 最终金额按两位小数、
ROUND_HALF_UP舍入; - 输入金额超过两位小数时拒绝,而不是静默修改。
from decimal import (
Context,
Decimal,
Inexact,
ROUND_HALF_UP,
localcontext,
)
CENT = Decimal("0.01")
scale_context = Context(traps=[Inexact])
def parse_money(text: str) -> Decimal:
value = Decimal(text)
# 只验证,不在此处悄悄舍入
return value.quantize(CENT, context=scale_context)
def calculate_order(
unit_price: str,
quantity: int,
discount_rate: str,
tax_rate: str,
) -> dict[str, Decimal]:
price = parse_money(unit_price)
discount = Decimal(discount_rate)
tax = Decimal(tax_rate)
if quantity < 0:
raise ValueError("quantity cannot be negative")
if not Decimal("0") <= discount <= Decimal("1"):
raise ValueError("discount_rate must be between 0 and 1")
if not Decimal("0") <= tax <= Decimal("1"):
raise ValueError("tax_rate must be between 0 and 1")
with localcontext(prec=40, rounding=ROUND_HALF_UP):
subtotal = price * quantity
discount_amount = subtotal * discount
taxable = subtotal - discount_amount
tax_amount = taxable * tax
total = taxable + tax_amount
return {
"subtotal": subtotal.quantize(CENT),
"discount": discount_amount.quantize(CENT),
"taxable": taxable.quantize(CENT),
"tax": tax_amount.quantize(CENT),
"total": total.quantize(CENT),
}
result = calculate_order(
unit_price="19.99",
quantity=3,
discount_rate="0.10",
tax_rate="0.13",
)
for name, value in result.items():
print(f"{name}: {value}")
典型输出:
subtotal: 59.97
discount: 6.00
taxable: 53.97
tax: 7.02
total: 60.99
计算过程为:
这个示例中有一个必须由业务确认的细节:折扣金额是否在计算应税金额前舍入。如果规则要求每个中间金额都是两位小数,则应在 discount_amount 计算后立即 quantize();如果规则要求最后统一舍入,则应保留更多中间位数。代码不能替代规则。
十六、金融系统中的金额表示:小数金额、最小货币单位和 Decimal
有些系统不把金额保存为 Decimal,而是保存为最小货币单位的整数,例如:
amount_cents = 1999
它表示 19.99。这种方案的优点是:
- 加减法是整数运算;
- 数据库约束容易表达;
- JSON 序列化简单;
- 不存在二进制浮点误差。
但它只适合小数位固定且明确的金额。对于税率、汇率、利率或不同货币的小数位,仍然需要 Decimal。
两种表示可以在边界处转换:
from decimal import Decimal
cents = 1999
amount = Decimal(cents) / Decimal(100)
print(amount)
反向转换时必须先规定舍入策略:
from decimal import Decimal, ROUND_HALF_UP
amount = Decimal("19.995")
cents = int(
amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
* 100
)
print(cents)
输出:
2000
不能直接使用:
int(amount * 100)
因为这会把“金额如何舍入”隐含为向零截断。
十七、JSON、CSV 和 TOML 的精度边界
1. JSON
JSON 本身只有通用的数字语法,并没有 Python Decimal 或 Fraction 这样的类型标记。直接序列化自定义数值对象通常会失败:
import json
from decimal import Decimal
try:
print(json.dumps({"amount": Decimal("19.99")}))
except TypeError as exc:
print(type(exc).__name__, exc)
常见边界方案有两种:
import json
from decimal import Decimal
payload = {
"amount": "19.99",
"tax": "2.60",
}
text = json.dumps(payload)
data = json.loads(text, parse_float=Decimal)
print(text)
print(Decimal(data["amount"]))
金额使用字符串的优点是避免下游系统把它当成二进制浮点;如果系统协议规定金额为最小单位整数,也可以传:
{"amount_minor": 1999}
不要让一端传 "19.99"、另一端传 19.99,再由不同语言自行推断精度规则。
2. CSV
CSV 的字段本质上是文本:
from decimal import Decimal
row = ["1001", "19.99", "3"]
price = Decimal(row[1])
quantity = int(row[2])
total = price * quantity
print(total)
解析时应显式指定类型;不要先使用 float 再转 Decimal。
3. TOML
TOML 支持数字字面量,但跨语言读取时仍应确认目标库对十进制浮点、指数和特殊值的处理。若配置项参与金额计算,读取后应在应用边界显式转换并验证尺度:
from decimal import Decimal
raw_tax_rate = "0.13"
tax_rate = Decimal(raw_tax_rate)
if not Decimal("0") <= tax_rate <= Decimal("1"):
raise ValueError("invalid tax rate")
配置文件只是传输格式,不应被视为自动提供了业务精度保证。
十八、特殊值:NaN、无穷大和负零
Decimal 支持特殊值:
from decimal import Decimal
values = [
Decimal("NaN"),
Decimal("Infinity"),
Decimal("-Infinity"),
Decimal("-0"),
]
for value in values:
print(value, value.is_nan(), value.is_finite(), value.is_signed())
金融金额通常不应允许 NaN 和无穷大进入核心计算。应在输入边界检查:
from decimal import Decimal
def require_finite(value: Decimal) -> Decimal:
if not value.is_finite():
raise ValueError(f"non-finite decimal: {value}")
return value
-0 在数值比较中通常等于 0,但它仍然携带负号:
from decimal import Decimal
x = Decimal("-0")
print(x == 0)
print(x.is_zero())
print(x.is_signed())
输出:
True
True
True
如果金额系统要求统一展示零,应在格式化或规范化边界处理负零,而不是假设所有零的内部表示完全相同。
十九、诊断失败结果:从异常、flags 和输入链路定位问题
遇到金额不一致时,应按数据流排查:
外部文本/JSON/CSV
|
v
类型解析
|
v
Decimal 或 Fraction
|
v
上下文与运算
|
v
quantize / 最小单位转换
|
v
数据库、接口或展示
重点检查以下路径:
- 是否从
float构造了Decimal; - 是否在某个函数中修改了当前上下文;
- 是否使用了错误的舍入模式;
- 是否在明细级别和总计级别采用了不同舍入时机;
- 是否把
format()的字符串误当成数值; - 是否把
Decimal转成float后又重新计算; - 是否忽略了
Inexact、Rounded等 flags; - 是否把
Fraction转换成Decimal时使用了不足的上下文精度。
对于需要严格失败的计算,可以创建专用上下文:
from decimal import (
Context,
Decimal,
DivisionByZero,
Inexact,
InvalidOperation,
Overflow,
)
strict = Context(
prec=28,
traps=[
DivisionByZero,
InvalidOperation,
Overflow,
Inexact,
],
)
try:
result = strict.divide(Decimal("1"), Decimal("7"))
except Inexact:
print("结果需要舍入,严格模式拒绝继续")
严格模式适合验证、对账和测试;宽松模式适合允许计算继续、随后检查 flags 的批处理。二者都比“完全忽略计算状态”更容易诊断。
二十、常见误解与对应修正
误解一:用了 Decimal 就不需要舍入
错误。Decimal 可以准确表示很多十进制输入,但除法、非整数乘法和有限精度上下文仍然可能产生无限或超出精度的结果。Decimal("1") / Decimal("3") 仍需要舍入。
误解二:prec=2 表示保留两位小数
错误。prec 表示有效数字位数,不是固定小数位。固定两位小数应使用:
value.quantize(Decimal("0.01"))
误解三:Decimal(float_value) 可以修复浮点误差
错误。它会精确转换 float 已保存的二进制值。若源数据的业务意图是十进制,应从字符串构造。
误解四:Fraction 结果永远适合金融系统
错误。Fraction 能精确表示有理数,却不会自动表达金额尺度、舍入制度、货币单位或报表格式。它更适合把数学过程保持为精确有理数。
误解五:格式化成两位小数就完成了金额计算
错误。格式化只改变输出文本;金额计算还需要规定中间精度、舍入模式、舍入时机和存储格式。
误解六:两个 Decimal 数值相等,就代表它们的业务表示完全相同
from decimal import Decimal
a = Decimal("12.0")
b = Decimal("12.00")
print(a == b)
print(a.compare_total(b))
数值比较中二者相等,但 compare_total() 比较的是抽象表示,可能认为它们不同。数值相等适合计算判断;表示相等适合需要保留尺度或编码形式的场景。(docs.python.org)
二十一、选择依据可以归纳为一个问题
选择数值类型时,不要先问“哪个最精确”,而应依次问:
- 输入的真实语义是什么:二进制测量值、十进制金额,还是数学分数?
- 中间结果是否必须严格保持有理数?
- 业务是否规定固定小数位?
- 舍入是在每个明细、每个阶段,还是最终输出时发生?
- 结果是否允许
NaN、无穷大或隐式近似? - 数据跨 JSON、CSV、数据库或其他语言时,协议如何表达精度?
- 出现非精确结果时,是记录 flag、继续执行,还是抛出异常?
一般而言:
- 科学和工程近似计算优先考虑
float; - 金额、税率、利率和十进制业务规则优先考虑
Decimal; - 需要保持有理数精确性的数学过程优先考虑
Fraction; - 固定货币最小单位且规则简单的存储可以使用整数;
- 跨系统传输应明确使用十进制字符串或最小单位整数,而不是让接收方猜测。
Decimal 的核心不是“永远不产生误差”,而是让十进制表示、精度、舍入和异常处理成为可配置、可验证的程序状态。Fraction 的核心不是“替代 Decimal”,而是把有理数运算延迟到最后一步再近似。理解表示模型、上下文状态和业务舍入边界,才是避免金融计算错误的基础。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python functools:缓存、偏函数、归约、分派和装饰器工具
- 下一篇:Python 随机数与 secrets:伪随机、采样、种子和密码学边界
- 延伸:Python 数值类型:int、float、complex、bool 与运算边界
- 延伸:Python 结构化数据:JSON、CSV、TOML、Schema 与精度边界
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论