JavaLYG

Python金额round(2.675, 2)得到2.67:用Decimal字符串构造与显式舍入

把金额写成 round(2.675, 2),结果却是 2.67?这次用 Python 3.14.3 跑了六组对照:保留原始字符串,用 Decimal('2.675') 配合显式舍入可得到 2.68。关键不是再补一次格式化,而是别在入口丢掉十进制含义。

一、先把“两位小数”拆成三个问题

看到少一分钱,直觉很容易把锅扣给舍入函数。我会先拆开看:输入存的究竟是什么数,计算采用什么精度,最后按什么规则输出。这里的 float 保存的是二进制浮点近似值,不是用户输入的那段十进制文本。近似值一旦偏到中点的一侧,后面的舍入就会沿着那一侧走。

“两位小数”也有歧义:界面显示两位、计算结果量化到分、每笔还是合计后才舍入,不是同一件事。排查前先问清楚这些约定,通常比反复换函数更有用。本文只复现数值行为,不把某一种约定当作所有金额业务的标准。

内置float的round在恰好中点时取偶数;本例的2.675却已略低于十进制中点,不能只归因于中点规则。Decimal的显式舍入是另一条可控路径,不是把round改名。

二、Decimal接住的是已有值,不是原输入

把浮点数直接交给 Decimal,不会自动修复输入。实验里打印出的值以 2.674999 开头,严格小于十进制的 2.675。这是对已有二进制浮点值的精确转换,不是转换函数又制造了一份随机误差。官方文档也明确:构造时的上下文精度,不会把这里保存的数字截成指定长度。

另一条常见补救是先转成字符串。对于这个短值,str(2.675) 恰好得到想看到的文本,后续舍入也能得到预期结果。但这不能推广成修复算法:浮点加法已经产生的误差可能继续出现在字符串里,更长原输入中已经丢失的数字也不会回来。数据已经经过浮点链路时,应回到来源确认,而不是猜一个“原始金额”。

三、让原始十进制字符串走进计算链

更稳妥的入口,是让原始十进制字符串直接构造 Decimal,在计算链中继续使用同一数值类型。不要先解析成浮点再补转;也别在中途为图方便转回浮点,最后才重新包装。字符串只是载体,前提是它真来自尚未丢失精度的输入。

Decimal字符串与已有浮点输入的舍入对照,两路均按ROUND_HALF_UP量化到两位小数
原始十进制字符串进入Decimal,float转换保留已有近似;quantize须显式规则,非业务结算验收。

下面的入口函数只做教学需要的三件事:要求字符串、尝试十进制解析、拒绝非有限数。它会拒绝 NaN 和 Infinity,但并不是完整的业务金额校验器。是否接受负值、科学计数法、首尾空白,允许多长、多大,仍要另定规则。解析成功不等于这笔金额可以入账。

四、先写清中点规则,再调用quantize

ROUND_HALF_UP 是向最近值舍入,恰好中点时远离零,所以负的中点会向绝对值更大的方向走,并不是一律向正方向加。ROUND_HALF_EVEN 同样先找最近值,只在中点时选末位为偶数的一侧。别只用一个正数样例就决定采用哪种规则,正负中点和进位边界都值得放进测试。

quantize 采用第二个参数的指数。参数写成 0.01 是量化到两位小数,写成 0.05 也仍然是两位小数,不代表取五分的整数倍。实验中的 1.03 因此保持不变。若业务真要求某个步长,需要另行设计缩放、舍入与还原过程,并核对中间精度。

五、六组完整实验,可以直接复制运行

下面只有标准库依赖,保存成一个脚本即可运行。六组依次核对浮点近似、字符串入口与显示、正负中点、指数而非步长、构造与计算精度、舍入发生的位置。所有断言针对本组有限的小数值,不是可处理任意长度金额的通用工具。

from decimal import (Decimal as 十进制, localcontext as 局部上下文,
                     ROUND_HALF_UP as 半上, ROUND_HALF_EVEN as 半偶,
                     InvalidOperation as 无效运算)


def 金额(文本):
    # 教学入口:只收字符串,不代替业务格式与范围校验。
    if not isinstance(文本, str):
        raise TypeError('金额须为原始十进制字符串')
    try:
        数值 = 十进制(文本)
    except 无效运算 as 原因:
        raise ValueError('金额格式无效') from 原因
    if not 数值.is_finite():
        raise ValueError('金额须为有限数')
    return 数值


def 分位(数值):
    return 数值.quantize(十进制('0.01'), rounding=半上)


def 实验():
    with 局部上下文() as 上下文:
        上下文.prec = 28  # 仅覆盖本组小数值,不承诺任意精度。
        # 一:浮点近似不是十进制中点。
        近似 = 十进制(2.675)
        assert round(2.675, 2) == 2.67 and 近似 < 金额('2.675')
        print('一 浮点:', round(2.675, 2), '已有近似:', 近似)
        # 二:保留原字符串;显示不修改原对象。
        原值 = 金额('2.675')
        显示 = format(原值, '.2f')
        assert 分位(原值) == 金额('2.68') and 原值 == 金额('2.675')
        assert 显示 == '2.68'
        print('二 字符串:', 分位(原值), '显示:', 显示, '原值:', 原值)
        # 三:中点时,负数也要核对。
        向外 = [分位(金额(文本)) for 文本 in ('2.665', '-2.665')]
        到偶 = [金额(文本).quantize(金额('.01'), rounding=半偶)
                for 文本 in ('2.665', '-2.665')]
        assert 向外 == [金额('2.67'), 金额('-2.67')]
        assert 到偶 == [金额('2.66'), 金额('-2.66')]
        print('三 中点远离零:', *向外, '中点到偶数:', *到偶)
        # 四:量化指数,不是量化到五分倍数。
        结果 = 金额('1.03').quantize(金额('.05'), rounding=半上)
        assert 结果 == 金额('1.03') and 结果 % 金额('.05') != 0
        print('四 指数量化:', 结果, '并非五分倍数')
        # 五:构造保留输入,计算才受精度约束。
        with 局部上下文() as 小上下文:
            小上下文.prec = 6
            assert 十进制(2.675) == 近似
            商 = 金额('1') / 金额('7')
            assert 商 == 金额('0.142857')
        print('五 六位有效数字:', 商, '构造未截断')
        # 六:逐项舍入与合计后舍入是两种约定。
        项目 = [金额('.005'), 金额('.005')]
        逐项 = sum(map(分位, 项目), 金额('0'))
        最后 = 分位(sum(项目, 金额('0')))
        assert 逐项 == 金额('.02') and 最后 == 金额('.01')
        print('六 逐项:', 逐项, '最后:', 最后)


if __name__ == '__main__':
    实验()

本地执行得到的关键对照是:浮点结果为 2.67,字符串入口为 2.68;正负 2.665 按中点远离零得到 2.67 与 -2.67,按中点到偶数则为 2.66 与 -2.66。两笔半分逐项舍入得到两分,合计后舍入得到一分。这些结果说明约定不同会产生差额,不能据此判定其中一种永远正确。

六、对着故障表现查,不靠“多保留几位”碰运气

还要注意,localcontext 中的精度是计算使用的有效数字位数,不是“小数点后位数”。实验把精度设为六,七分之一就只留下六位有效数字;把精度设得很大,也不能让无限循环小数突然变成有限精确值。量化后的系数超过精度时,还可能触发无效运算,不能把异常吞掉当作零金额继续走。

表现先查什么处理方向
两位舍入少一分入口是否先转了浮点保留原始十进制字符串
负中点与预期相反是否误解远离零显式规则并测正负数
五分量化仍有三分是否把指数当步长另定步长算法
量化抛出异常有效数字精度与数值范围检查预算,不静默补零
显示正确但合计不同原对象及舍入时点核对完整计算链

格式化返回的是字符串,不会改写原对象;原值仍可带着更多小数参与后续计算。十进制数和浮点数混合加法则会抛出类型错误,应该统一输入链路,而不是临时把全部值强转成浮点来消除报错。

七、验收清单要覆盖输入、过程与边界

  • 追溯原始输入,确认不是浮点运算后拼出的字符串;校验非有限数、非法文本和错误类型。
  • 把舍入模式、适用范围和舍入发生的时点写进约定,覆盖正负中点、非中点与跨整数进位。
  • 区分精度与小数位数,测试精度不足时如何失败,不承诺任意精确,也不让局部设置泄漏影响其他计算。
  • 核对逐项与合计两条路径,明确差额如何处理;显示结果、数值对象与持久化值分别检查。

随文交付的是上面六组最小对照;另存的内部扩展脚本实际完成四十七项断言,覆盖输入拒绝、已有浮点误差、上下文恢复、混合类型等边界。扩展测试并没有全部塞进随文代码,不能把内部检查数量说成读者复制这段代码就执行的数量。

八、把结论收在这次实验的边界内

这次能确认的是标准库在本地内存对象上的表示、计算与舍入行为。没有连接支付接口,没有执行税务计算,也没有迁移账本或验证数据库字段。真实系统还要结合输入协议、存储精度、历史数据和业务结算约定验收;这里不给财税建议,也不建议靠批量重算覆盖旧数据来消除差异。

参考资料采用官方文档:构造与舍入语义以模块说明为准,二进制近似的背景看浮点教程。文档属于三点一四分支,本次执行器是三点一四点三,不把文档站当前维护版本冒称本机版本。

🔕 评论已关闭