原创

Python Decimal 的 quantize 是舍入操作:严格金额输入要单独验证

把价格改成Decimal以后,输入的 12.345 仍然被保存成 12.34,并没有报错。代码用了quantize,以为它在“验证两位小数”;实际上它按当前规则完成了舍入。

Decimal能准确表示许多十进制输入,却不会替业务决定是否允许舍入。接收金额、计算折扣、生成展示文本,是三个可能采用不同规则的阶段。把它们统一交给一个默认上下文,错误就会表现成一笔完全合法、只是数值不符合预期的结果。

下面使用Python 3.12,把严格接收与主动舍入放在不同函数里。金额限制是演示协议:只接收普通十进制字符串,非负、少于一百万元,必须能精确表示到分。它不是货币处理的通用模板。

接收阶段:超过精度时拒绝

保存为 decimal_amount.py,执行 python3 decimal_amount.py。

import re
from decimal import (
    Decimal, Context, localcontext, Inexact, InvalidOperation,
    ROUND_HALF_EVEN, ROUND_HALF_UP,
)

CENT = Decimal('0.01')
AMOUNT = re.compile(r'(?:0|[1-9][0-9]{0,5})(?:\.[0-9]{1,6})?\Z')

def parse_amount(text: str) -> Decimal:
    if not isinstance(text, str) or not AMOUNT.fullmatch(text):
        raise ValueError('plain non-negative decimal amount required')
    value = Decimal(text)
    if not value.is_finite() or not Decimal('0') <= value < Decimal('1000000'):
        raise ValueError('amount out of range')
    context = Context(prec=16, rounding=ROUND_HALF_EVEN)
    context.traps[Inexact] = True
    try:
        return value.quantize(CENT, context=context)
    except (Inexact, InvalidOperation) as exc:
        raise ValueError('amount must be exact to cents') from exc


def round_for_display(text: str, mode) -> str:
    value = Decimal(text)
    if not value.is_finite():
        raise ValueError('finite value required')
    with localcontext(Context(prec=16, rounding=mode)):
        return format(value.quantize(CENT), 'f')


def main() -> None:
    for text in ['12', '12.30', '12.3400', '12.345', 'NaN', '1e2', '-0.01']:
        try:
            print(f'accept {text} -> {parse_amount(text)}')
        except ValueError:
            print(f'reject {text}')
    print('half_even:', round_for_display('2.345', ROUND_HALF_EVEN))
    print('half_up:', round_for_display('2.345', ROUND_HALF_UP))
    with localcontext(Context(prec=4)) as context:
        context.clear_flags()
        value = Decimal('1') / Decimal('3')
        print('division:', value, 'inexact:', context.flags[Inexact])
        exact = Decimal('1') + Decimal('2')
        print('next exact:', exact, 'flag still:', context.flags[Inexact])
    assert parse_amount('12.3400') == Decimal('12.34')
    assert round_for_display('2.345', ROUND_HALF_EVEN) == '2.34'
    assert round_for_display('2.345', ROUND_HALF_UP) == '2.35'

if __name__ == '__main__':
    main()

接收结果中,12.3400被接受,12.345被拒绝。前者只丢掉不改变数值的尾零,后者需要改变数值。这里限制的是“数值必须精确到分”,不是“原始字符串最多出现两位小数”。如果协议限制文本格式,应把小数位规则直接写进输入语法,不能拿Inexact替代。

这也是没有同时开启Rounded陷阱的原因。Rounded可以在只丢弃零、数值未改变时置位,Inexact关注实际不精确。两个信号表达的合同不同,把所有信号都设为异常并不等于验证更正确。

展示阶段:允许舍入,但明确选规则

同一个2.345,half-even得到2.34,half-up得到2.35。两者都属于明确可解释的舍入方式,不能仅凭名称中的“普通”或“财务”判断整个系统都应采用哪一种。

结算通常还需要决定舍入发生在哪里。逐项舍入后求和,与先求和再统一舍入,结果可能不同;税费、优惠分摊和退款可能各有约定。Decimal只执行你写下的顺序,不会修正顺序本身的业务含义。

示例的round_for_display只有固定的有限输入和16位有效数字预算,适合这个小实验。若扩展成接收任意大指数的公共接口,需要额外限定文本长度、指数和数值范围,避免格式化生成巨大字符串。parse_amount已经用受限语法挡住指数形式,两份函数的输入边界不能相互冒充。

精度不是小数位数

Context(prec=4)控制有效数字数量,不是保留四位小数。对1/3得到0.3333只是这份输入恰好让两者看起来一致;对更大的整数部分,同样上下文可以让小数部分更早损失。

Decimal从字符串构造通常保留输入值,不会因为当前prec较小就立即把构造结果截短。运算与quantize才会按相应上下文处理。若只在程序启动时设置一个prec,再以为所有对象从此都具有相同小数位,数据边界就没有真正建立。

也别写Decimal(0.1)来表示原始十进制0.1。那是在把已有二进制浮点值转换成十进制,浮点近似已经发生;字符串输入的Decimal('0.1')表达的是另一项来源合同。数据经过float再转Decimal,不能把先前丢失的原意自动找回来。

本例每个关键阶段自己创建Context,不依赖调用方留下的舍入模式或陷阱。localcontext则把临时设置限制在作用域里,离开时恢复此前上下文。库函数默默改getcontext,可能让同一调用链里的其他金额运算改变行为,问题很难从返回类型看出来。

入口格式也要和数值规则分别测试

parse_amount只接收非负普通小数,小数部分最多允许六位。这个六位是请求格式预算,不是Decimal能力限制;12.3400000虽然可以精确到分,也会因为格式越界被拒绝。若系统应接受任意数量尾零,应该调整这项预算并保留文本长度上限,不能把拒绝结果误报成数值不精确。

正负零也需要约定。Decimal可以保留零的符号,但本例不接受任何负号,因此-0.00同样会在语法阶段失败。若退款允许负数,应重新设计范围与符号合同,并核对舍入方向,不能只把正则前面加一个可选减号就算完成。

上游JSON若先把金额解成float,接收函数再用str转换,也不再是从原始十进制文本开始验证。可以把金额在接口中约定为字符串,或者让解析层直接采用合适十进制类型;无论选哪一种,都要保留明确字段语法,避免普通数字、字符串和空值被随意互转。

异常也应保留成可解释的拒绝。输入非法与计算过程中出现意外精度损失,可能属于不同错误类别。这里为了让程序短小统一转成ValueError,业务接口若需要错误码,应在边界处细分,而不是让调用方从英文错误文本里解析原因。

读信号之前,先清掉上一次的记录

程序最后一次除法触发Inexact,随后做精确加法,标志仍然为True。这不是加法突然不精确,而是信号标志具有粘性,直到显式清理。

如果要用flags审计一批运算,先clear_flags,再运行那一批,然后读取结果。要定位具体哪一步不允许精度损失,可以在那一步开启对应陷阱。把一个长期复用上下文的flags放在函数末尾检查,却不知道它从哪次调用留下来,无法支持有效判断。

运行程序后应看到接收函数拒绝12.345,展示函数按两种模式生成不同结果,最后的精确加法没有清除此前Inexact。这三组输出分别验证输入合同、舍入规则和信号生命周期,不能只看最终都打印了两位小数。

正文到此结束
评论插件初始化中...
Loading...
本文目录