☰
Python3浮点数精度处理:四舍五入、截断与Decimal实战
2026/11/3 6:24:40 网站建设 项目流程

1. 浮点数为什么“不老实”:先把根因搞清楚

“小数点后保留两位”这句话,我在需求评审里听过没有一百遍也有八十遍。听起来是句人话,落到 Python3 代码里就变成一串暗坑:round(2.675, 2)给你2.67而不是2.68;round(0.5)给你0而不是1;你按四舍五入写完,验收的人拿个计算器一按说对不上。Python 的浮点数精度处理和四舍五入保留小数位,属于那种“看着五分钟能搞定、真动手要查半小时文档”的活。

这篇东西想解决的就是这一类问题:Python3 里怎么把小数按指定位数处理,既要会“四舍五入”,也要会“不四舍五入直接截断”。我会把round、format、decimal三条路线拆开讲清楚,告诉你每条路在什么场景下能用、在什么场景下会翻车,最后给一套封装好的工具函数和一堆踩过的坑。不管你是刚学 Python 写作业的学生,还是在写订单金额、报表统计、传感器读数的工程人员,都能直接抄走。

1.1 十进制小数在二进制里的无限循环

先讲根子上的事。计算机里的浮点数按 IEEE 754 双精度存储,64 个二进制位分成三段:1 位符号、11 位指数、52 位尾数。这个结构决定了它能精确表示的只有形如m / 2^k的分数,剩下的十进制小数只能近似。

拿最经典的0.1说。它在二进制里是0.00011001100110011...,0011这四位无限循环下去,永远写不完。52 位尾数只能截取前面一段,剩下的部分被舍掉,于是内存里存的其实是一个比0.1略大一点点的数:0.1000000000000000055511151231257827021181583404541015625。

0.2、0.3、2.675同理。2.675在内存里的实际值是2.67499999999999982236431605997495353221893310546875,比2.675小。你对着这个数做四舍五入保留两位,它当然给你2.67——这不是round算错了,是它拿到的原料本来就不够2.675。

提示:这不是 Python 的锅。C、Java、JavaScript、Go 用同样的 IEEE 754 双精度,结果一模一样。JavaScript 里0.1 + 0.2 === 0.3也是false。

1.2 误差是怎么在链式计算里滚雪球的

单个数的表示误差大概在1e-16量级,看着不起眼,但它会传播。最直观的例子:

print(0.1 + 0.2) # 0.30000000000000004 print(0.1 + 0.2 == 0.3) # False print(1.1 * 3) # 3.3000000000000003 print(sum([0.1] * 10)) # 0.9999999999999999

十个0.1加起来不等于1.0,这个结果第一次见会愣一下,但它就是事实。累加次数越多,误差堆积越明显。做对账、做统计的时候,几百上千条带小数的记录求和,最后一位跟预期差个零点零几是常有的事。

更麻烦的是比较。if a == b这种写法在浮点世界里基本等于埋雷,因为两个数值上应该相等的量,经过不同路径计算后二进制尾数可能不同。经验做法是用math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12)做容差比较,或者干脆全程用Decimal、用整数分单位运算,从源头绕开。

我在一个传感器采样的项目里就吃过这个亏:设备上报的电压值累加后跟校准值比对,差0.0000001就判定为“设备异常”,结果每天误报几十次。后来把比对逻辑改成abs(a - b) > 1e-6才算异常,问题立刻消失。浮点数不是不能比,是不能用“精确相等”去比。

1.3 三条技术路线的前提认知

Python3 里处理小数位,能走的路线其实就三条,理解它们的定位比记 API 重要得多:

路线代表写法底层行为适用定位
内置round()round(x, 2)对二进制浮点做舍入快速估算、展示用
字符串格式化f"{x:.2f}"走__format__协议只负责输出,不参与计算
decimal模块Decimal(...).quantize(...)十进制定点运算金额、账单、需要可复现结果

这三条路线的分野在于“你要的结果是给人看的,还是要继续参与计算的”。给人看的,格式化成字符串就完事;要参与计算的,尤其是钱相关的,decimal几乎是唯一稳妥选择;round()夹在中间,够快但坑多,适合对精度不敏感的场合。

还有一条更土但更可靠的路子:把小数换算成整数。金额一律以“分”为单位存整数,19.99元存成1999,算完再除回去。整数运算没有精度问题,加减乘都精确,只有除法需要小心。这条路线在支付、账务系统里是主流做法,后面会展开说。

2. 四舍五入保留N位:round、format、Decimal 的真实表现

2.1 内置 round() 的两个坑:银行家舍入与输入误差

round()是最容易上手也最容易翻车的。它有两个独立的坑,很多人只踩过其中一个就以为搞懂了。

第一个坑是银行家舍入。Python3 的round()对“恰好一半”的情况采用“向偶数靠拢”的规则,也就是 round half to even:

round(0.5) # 0 round(1.5) # 2 round(2.5) # 2 round(3.5) # 4 round(-0.5) # 0

学校里教的“四舍五入”是2.5 → 3,Python 给你2。这不是 bug,是刻意设计:大量数据做统计时,如果每次都向上进位,结果会有系统性偏高;向偶数靠拢能把这个偏差抵消掉。但如果你写的是账单、评分、成绩,业务方要的就是传统四舍五入,那就不能直接用round()。

第二个坑是输入本身就带误差。前面说过2.675实际存储值是2.674999...,round拿到的原料小于中点,自然向下舍。类似的还有:

round(2.675, 2) # 2.67 round(1.005, 2) # 1.0 (1.005 实际是 1.00499999...) round(2.685, 2) # 2.69 (这个恰好凑对了,纯属运气)

看到没,同样的写法,2.675翻车、2.685没事,这种“时好时坏”最容易让人误判为“代码没问题,是数据有问题”。

还有一个细节:round(x)不带第二个参数时返回int,round(x, 0)返回float。

type(round(2.5)) # <class 'int'> type(round(2.5, 0)) # <class 'float'>

在需要保持类型一致的地方(比如 Pandas 的列、JSON 序列化)这个差异会咬人。

实操心得:round()我在生产代码里基本只用于日志打印、图表坐标轴刻度这类“错一位没人管”的地方。凡是结果要入库、要参与金额计算的,一律不用它。

2.2 字符串格式化只负责显示,不负责计算

f"{x:.2f}"、"{:.2f}".format(x)、"%.2f" % x这三种写法本质一样,都是把浮点转成指定精度的字符串。它们内部走的也是浮点的舍入逻辑,所以format(2.675, '.2f')同样是'2.67'。

它的价值在于输出层的统一:

value = 3.1 print(f"{value:.2f}") # 3.10 —— 补零,视觉整齐 print(f"{value:8.2f}") # 3.10 —— 右对齐占8位,报表好看 print(f"{value:.0f}") # 3 —— 注意这里没有小数点 print(f"{0.000012:g}") # 1.2e-05 —— g 格式自动决定用不用科学计数法

这里要强调一件事:format支持的格式符里没有“截断”这一说。.2f永远会舍入,.2对字符串是截断、对数字却是保留有效数字,完全是另一回事。想要“不四舍五入”,format帮不了你,必须换路线。

另外,如果格式化的对象是Decimal,情况会好很多:

from decimal import Decimal format(Decimal('2.675'), '.2f') # '2.68' format(Decimal('2.685'), '.2f') # '2.68'

注意第二个结果:2.685格式化成了2.68而不是2.69。因为Decimal.__format__使用的是当前上下文的舍入模式,默认是ROUND_HALF_EVEN,2.685保留两位时末位 8 是偶数,不加进。所以用Decimal做格式化输出,也别忘了确认舍入模式。

2.3 decimal.Decimal 的正确打开方式

decimal模块是标准库里的定点十进制实现,它用十进制位序列存储数值,不受二进制表示限制,Decimal('0.1')就是精确的0.1。核心用法是quantize():

from decimal import Decimal, ROUND_HALF_UP d = Decimal('2.675') result = d.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) print(result) # 2.68 print(type(result)) # <class 'decimal.Decimal'>

quantize(exp)的含义是“把结果对齐到exp这个量级”。传Decimal('0.01')就是保留两位、Decimal('0.1')一位、Decimal('1')取整到个位、Decimal('1E+1')十位。它比round(x, n)更明确,因为你写的是“目标量级”而不是“位数”。

最关键的一步是构造Decimal时别直接把 float 塞进去。

Decimal(2.675) # Decimal('2.674999999999999822364316059975...') 一长串 Decimal('2.675') # Decimal('2.675') 精确 Decimal(str(2.675)) # Decimal('2.675') 走最短表示,稳妥

Decimal(float)会把浮点数的二进制精确值完整展开,你会看到那串长得吓人的尾巴,用它去做quantize结果依然是错的。所以规则很硬:从 float 转Decimal,一定先str()一次,或者用Decimal.from_float()明确表示“我要的就是这个二进制值”(这通常不是你想要的)。

还有一个必须知道的坑:Decimal的运算受上下文精度getcontext().prec限制,默认 28 位有效数字。当整数部分很长、再要求很多小数位时,总位数超过 28 会直接抛InvalidOperation:

from decimal import Decimal, ROUND_HALF_UP, getcontext getcontext().prec = 5 Decimal('123456.789').quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) # decimal.InvalidOperation

解决办法不是改全局prec(全局副作用很脏),而是用localcontext()局部放大:

from decimal import Decimal, ROUND_HALF_UP, localcontext def quantize_safe(value, exp='0.01', rounding=ROUND_HALF_UP): d = Decimal(str(value)) if not isinstance(value, Decimal) else value with localcontext() as ctx: ctx.prec = max(28, d.adjusted() + 10) # 整数位数 + 安全余量 return d.quantize(Decimal(exp), rounding=rounding)

adjusted()返回的是指数调整后的位数,大致对应整数部分的量级。留 10 位余量基本够用,这个prec的算法下面还会再提一次。

2.4 三种方案横向对比与选型建议

把上面的内容压成一张表,方便对号入座:

维度round()format/ f-stringDecimal.quantize()
舍入规则银行家舍入(half even)随底层实现,float 走 half even可指定 8 种模式
受输入误差影响严重严重(float 输入)无(字符串构造时)
返回值类型int / floatstrDecimal
能否截断不能不能能(ROUND_DOWN)
相对性能极快快慢一到两个数量级
典型用途坐标轴、粗略估算报表输出、日志金额、账单、结算
推荐指数展示场景只做最后一步计算场景首选

性能这块给个大致量级参考:单次round()通常在百纳秒级,Decimal构造加quantize在微秒级,差一到两个数量级,具体取决于机器和 Python 版本。所以别一听“Decimal 慢”就吓退——一万笔订单的金额计算,用 Decimal 也就多花几十毫秒,跟一次数据库查询比完全不是瓶颈。真正需要担心的是一次处理几百万行的批处理场景,那种情况更适合“整数分单位 + 最后统一转”。

3. 不四舍五入(截断)保留N位:没有内置函数就自己控方向

3.1 截断的本质是“控制舍入方向”

需求里说“不要四舍五入,直接去掉后面的”,业务含义往往是这几类:

  • 金额向上取整到分(不允许少收钱)
  • 折扣计算向下取整(不允许超发优惠)
  • 传感器读数只显示不估算(多余位数直接丢弃)
  • 分页、分片数量计算(不足一片算一片)

这几种需求对应的舍入方向完全不同。所以“截断”不是一个动作,而是一组方向的集合。Python 的decimal提供了完整的 8 种舍入模式,先认识一下最常用的几个:

模式含义Decimal('-1.239')→ 两位Decimal('1.239')→ 两位
ROUND_HALF_UP传统四舍五入-1.241.24
ROUND_HALF_EVEN银行家舍入(默认)-1.241.24
ROUND_DOWN朝零方向截断-1.231.23
ROUND_FLOOR朝负无穷-1.241.23
ROUND_CEILING朝正无穷-1.231.24
ROUND_UP远离零方向-1.241.24
ROUND_05UP末位为0或5时才远离零-1.231.23

很多人第一次看到ROUND_DOWN描述为“向零舍入”会以为是“向下”,结果负数上就出错了。ROUND_DOWN砍掉的是绝对值,ROUND_FLOOR才是数学意义上的向下。“截断”这个词在代码里默认对应ROUND_DOWN,但业务口径一定要跟需求方确认清楚是“去尾”还是“抹零向下”。

3.2 ROUND_DOWN、ROUND_FLOOR、ROUND_CEILING 到底差在哪

用一组对照跑一遍最清楚:

from decimal import Decimal, ROUND_DOWN, ROUND_FLOOR, ROUND_CEILING, ROUND_UP def show(v, mode): return Decimal(v).quantize(Decimal('0.01'), rounding=mode) for v in ['1.239', '-1.239', '1.231', '-1.231']: print(v, 'DOWN ', show(v, ROUND_DOWN), 'FLOOR', show(v, ROUND_FLOOR), 'CEIL ', show(v, ROUND_CEILING), 'UP ', show(v, ROUND_UP))

输出大致是这样:

1.239 DOWN 1.23 FLOOR 1.23 CEIL 1.24 UP 1.24 -1.239 DOWN -1.23 FLOOR -1.24 CEIL -1.23 UP -1.24 1.231 DOWN 1.23 FLOOR 1.23 CEIL 1.24 UP 1.24 -1.231 DOWN -1.23 FLOOR -1.24 CEIL -1.23 UP -1.24

规律很清晰:DOWN和UP是成对的,以零为界;FLOOR和CEILING是成对的,以数轴为界。做金额的时候,商家角度看“少收钱”是DOWN,“多收钱”是UP;但从“余额不能为负”的角度看,可能就需要FLOOR。

还有个实际场景要注意:ROUND_DOWN处理1.230这种已经是两位的数不会变,但处理1.2会补成1.20,因为quantize是补齐到目标量级,不是简单砍字符串。这个特性在展示场景里其实是好事——金额字段总有两位小数,接口返回1.2会被前端吐槽。

Decimal('1.2').quantize(Decimal('0.01'), rounding=ROUND_DOWN) # Decimal('1.20')

3.3 字符串截取法和数学法的适用边界

网上流传最多的写法是字符串截取,因为不依赖decimal:

def trunc_str(x, n=2): s = str(x) if '.' not in s: return s head, _, tail = s.partition('.') return head + '.' + tail[:n].ljust(n, '0')

这个函数在x = 2.679时返回'2.67',看着能用。但它有三个致命缺陷:

一是科学计数法直接崩。str(0.0000123)是'1.23e-05',里面根本没有小数点后的正常结构,截出来是垃圾。

二是大数会被转成科学计数法。str(1e17)是'1e+17',同样崩。

三是 float 的最短表示不保证是“你以为的那个数”。str(0.1 + 0.2)是'0.30000000000000004',截两位得'0.30',这次恰好对;但如果输入是2.675,str给'2.675',截断得'2.67',也对——所以它在展示场景里“碰巧”能用,但它永远只是字符串处理,不能参与后续计算,也没法控制舍入方向。

数学法的版本长这样:

import math def trunc_math(x, n=2): factor = 10 ** n return math.floor(abs(x) * factor) / factor * (1 if x >= 0 else -1)

多乘一次、多除一次,浮点误差又回来了。trunc_math(2.679, 2)结果是2.67,但trunc_math(1.9999999999999998, 2)可能出1.99或者2.0,取决于误差落点,非常不稳。

实操心得:字符串法和数学法我只在“脚本里临时打印一下”这种场合用。只要这段代码会被别人调用、会被复制进项目,就老老实实写 Decimal 版本。为省一次 import 埋的雷,后头排查起来成本高得多。

3.4 截断方案对照表

方案优点缺点建议场景
Decimal.quantize(ROUND_DOWN)方向可控、结果精确、可复现需要构造 Decimal,稍慢生产代码首选
字符串截取无依赖、代码短科学计数法崩、无法计算、无方向控制临时调试、日志
数学乘除法直观引入新的浮点误差、负数需特判不推荐
转整数再整除精确、快只适合固定倍数(如分、厘)高频批量场景
先加极小值再 round能“掰”回四舍五入属于 hack,边界仍可能错不推荐

最后那行“先加极小值再 round”,指的是round(x + 1e-9, 2)这种民间偏方。它确实能让round(2.675, 2)变成2.68,但你没法确定1e-9对所有量级都够用——数据到了1e10量级,1e-9早就被浮点精度吃掉了。这种写法看着聪明,实际上是把不确定性从一处搬到了另一处,我不建议在任何正式代码里用。

4. 把精度处理封装成可复用工具:完整实操

4.1 输入归一化:所有坑的源头都在这一步

写工具函数之前先想清楚一件事:调用方传进来的可能是float、str、int、Decimal,甚至numpy.float64。这几种类型进Decimal的结果差别巨大,所以第一步永远是归一化。

int安全,Decimal直接用,str直接用。麻烦的是float和numpy.float64。对float用Decimal(str(x)),走的是 Python3 的“最短可逆表示”,2.675得到'2.675',1e-7得到'1E-7',都能正确解析。对numpy.float64,str()出来的形式跟 Python float 一致,同样适用。

需要拦掉的输入有两种:NaN和无穷大。它们进Decimal不报错(会得到Decimal('NaN')),但后续quantize会抛出InvalidOperation,错误信息还很不友好。与其让它在中途炸,不如在入口就明确拒绝。

import math from decimal import Decimal, InvalidOperation def to_decimal(value, field='value'): if isinstance(value, Decimal): d = value elif isinstance(value, bool): raise TypeError(f'{field} 不接受 bool') elif isinstance(value, int): d = Decimal(value) elif isinstance(value, float): if math.isnan(value) or math.isinf(value): raise ValueError(f'{field} 为 NaN 或无穷大:{value}') d = Decimal(str(value)) elif isinstance(value, str): s = value.strip().replace(',', '').replace(' ', '') try: d = Decimal(s) except InvalidOperation: raise ValueError(f'{field} 不是合法数字:{value!r}') else: # 兜底:numpy 标量、自定义数值类型等 d = Decimal(str(value)) if d.is_nan() or d.is_infinite(): raise ValueError(f'{field} 为 NaN 或无穷大') return d

这里有几个细节值得说。bool是int的子类,True会变成Decimal(1),在金额场景里这绝对是误用,所以单独拦掉。字符串里的千分位逗号和前后空格做一次清洗,能避免大量“用户输入1,234.56报错”的工单。兜底分支用str()转换,兼容numpy.float64这类第三方数值类型。

4.2 核心函数实现

归一化解决后,两个核心函数就很好写了:

from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN, localcontext def _exp(ndigits: int) -> Decimal: """把位数转成 quantize 需要的量级,ndigits=2 -> Decimal('0.01')""" if ndigits < 0: return Decimal(1).scaleb(-ndigits) # 十位、百位 return Decimal(1).scaleb(-ndigits) # 统一用 scaleb,避免拼字符串 def round_half_up(value, ndigits: int = 2) -> Decimal: """传统四舍五入(0.5 一律进位)""" d = to_decimal(value) with localcontext() as ctx: ctx.prec = max(28, d.adjusted() + ndigits + 10) ctx.rounding = ROUND_HALF_UP return d.quantize(_exp(ndigits), rounding=ROUND_HALF_UP) def truncate(value, ndigits: int = 2) -> Decimal: """向零截断,不四舍五入""" d = to_decimal(value) with localcontext() as ctx: ctx.prec = max(28, d.adjusted() + ndigits + 10) return d.quantize(_exp(ndigits), rounding=ROUND_DOWN)

Decimal(1).scaleb(-ndigits)等价于Decimal('1E-2'),比Decimal('0.' + '0'*(n-1) + '1')拼字符串干净得多,而且天然支持负位数(ndigits=-2变成1E+2,即取整到百位)。这个技巧很多人不知道,写出来能省一堆判断。

adjusted()返回Decimal的调整指数,对123.45返回2(因为1.2345E+2),对0.00123返回-3。用它算prec的公式是:整数部分位数 + 目标小数位数 + 安全余量。安全余量取 10 足够覆盖各种进位连锁。

再补一个辅助函数,把结果转成字符串输出:

def to_fixed_str(value, ndigits: int = 2, rounding=ROUND_HALF_UP) -> str: d = to_decimal(value) with localcontext() as ctx: ctx.prec = max(28, d.adjusted() + ndigits + 10) q = d.quantize(_exp(ndigits), rounding=rounding) return format(q, 'f') # 'f' 保证不用科学计数法

注意最后用的是format(q, 'f')而不是str(q)。str(Decimal('1E+3'))会输出'1E+3',而format(..., 'f')输出'1000'。做接口返回、做报表导出时必须用后者,否则前端拿到的是一串科学计数法。

4.3 边界用例与自测清单

工具函数写完,自测清单比代码本身更重要。下面这些用例我每次封这类函数都会跑一遍,覆盖了绝大多数翻车点:

用例输入期望(四舍五入两位)期望(截断两位)考点
经典误差2.6752.682.67float 表示误差
中点1.0051.011.000.5 进位判定
负数-1.239-1.24-1.23负数的方向
恰好整1.21.201.20补零
已达标1.2301.231.23幂等
零00.000.00零的处理
负零-0.001-0.00-0.00符号位残留
大数1e17不丢精度不丢精度prec 是否够
微小数1e-70.000.00科学计数法入口
字符串'1,234.567'1234.571234.56输入清洗
非法输入'abc'/NaN抛 ValueError抛 ValueError入口拦截
取整到十位1234.5,-112301230负位数

负零那条要特别说一下。Decimal('-0.001').quantize(Decimal('0.01'), rounding=ROUND_DOWN)会得到Decimal('-0.00'),str()出来是'-0.00'。金额字段突然返回-0.00,前端和验收的人都会问“这是欠我钱吗”。处理方式是在to_fixed_str里加一条:如果结果按数值等于零,就把符号抹掉。

def to_fixed_str(value, ndigits=2, rounding=ROUND_HALF_UP): ... s = format(q, 'f') if not q: s = s.lstrip('-') return s

not q对Decimal('0.00')和Decimal('-0.00')都是True,lstrip('-')把符号去掉,简单有效。

另外1e17那条,双精度能精确表示的整数上限是2^53 ≈ 9.007e15,超过这个量级 float 本身就开始丢精度了。所以如果你的输入是float类型且数量级超过1e15,问题出在源头而不是工具函数,得回头改成str或int传递。这种“精度不够是因为输入就已经丢了”的判断,排查时要优先想清楚。

4.4 精度上下文与性能取舍

localcontext()每次进出都有开销,高频调用时它是成本大头。如果确认整个调用链都在同一精度下,可以省掉它,改在函数外部统一设置一次上下文,但一定要在模块顶层或者明确的初始化位置设置,别散落在各处。全局上下文被别的库改了,prec就可能不是你预期的值,这类问题排查起来特别费劲。

性能上,我做过一次粗略对比(同一台机器、同一个 Python 版本,跑一百万次):

  • round(x, 2):耗时基线
  • format(x, '.2f'):跟round差不多
  • Decimal(str(x)).quantize(...):大约是基线的 40 到 80 倍
  • Decimal(str(x))构造本身:占了其中的大头

也就是说,Decimal的开销主要在字符串解析,quantize反而不算贵。所以优化思路是减少重复构造:同一批数据如果每个值要用多次,先一次性转成Decimal列表,后面复用,不要在循环里反复Decimal(str(...))。

# 慢:每次循环都做两次字符串解析 totals = [round_half_up(v, 2) for v in raw_values] # 快:先批量转换,后续复用 decimals = [to_decimal(v) for v in raw_values] totals = [round_half_up(d, 2) for d in decimals]

对于百万级以上的批处理,还有一个更彻底的方案:全程用整数分单位。读入时int(round(x * 100))转成分,中间全是整数加减,最后输出时再格式化。这条路又快又准,代价是所有中间层都得清楚“这个字段单位是分”,团队协作时要写进接口文档,否则下一个人接手直接当成元来计算,就是灾难。

5. 踩坑实录与速查手册

5.1 三个典型场景的问题分析

场景一:Pandas 读进来的金额就不对了。某项目做销售报表,把 CSV 直接pd.read_csv()读入,金额列自动识别成float64。后续用df['amount'].round(2)汇总,跟财务系统差了几分钱。根因是read_csv默认走 C 解析器转 float,19.99一进内存就有误差,再叠加round的 half even 规则,偏差就被放大了。改法是读取时指定dtype={'amount': str},再用setup或者逐列apply(to_decimal)转Decimal,或者更简单——CSV 里金额直接按“分”存整数。

场景二:JSON 接口返回的浮点数少了一位。某后台用json.dumps(result)输出,result里的Decimal直接抛TypeError: Object of type Decimal is not JSON serializable。有人图省事写了default=float,结果Decimal('0.1')转回float又是0.1的近似值,序列化后变成0.10000000000000001。正确做法是用default=lambda o: format(o, 'f')输出字符串,或者约定金额字段统一以“分”的整数传输,前端自己除。

场景三:前端展示 3.10 变成 3.1。后端辛苦算出来Decimal('3.10'),format(..., 'f')得到'3.10',结果进 JSON 时被转成数字,前端拿到3.1,尾零丢了。这不是精度问题,是类型问题——要保留尾零就必须用字符串传,数字类型天然不保留无意义的零。这类问题在价格展示上几乎每个项目都会遇到一次。

这三个场景的共同点是:精度问题往往不是在你写的那行代码里爆的,而是在上下游的类型转换环节。所以排查顺序建议是:先看输入是什么类型、以什么形式来的,再看中间有没有隐式转换,最后才怀疑舍入函数本身。

5.2 常见问题速查表

现象最可能原因处理方式
round(2.675, 2)得 2.67float 表示误差,输入小于中点换Decimal(str(x))+ROUND_HALF_UP
round(2.5)得 2银行家舍入用Decimal+ROUND_HALF_UP
0.1 + 0.2 != 0.3二进制表示限制用math.isclose比较,或改 Decimal
Decimal(2.675)一长串float 被精确展开改Decimal(str(2.675))
quantize抛InvalidOperation上下文prec不足localcontext里放大prec
截断后变成 -1.24用了ROUND_FLOOR而非ROUND_DOWN确认业务方向,换模式
输出1E+3而不是1000str(Decimal)保留科学计数法用format(d, 'f')
输出-0.00负零符号残留判断为零时lstrip('-')
format(Decimal('2.685'), '.2f')得 2.68默认ROUND_HALF_EVENquantize显式指定模式
json.dumps报 TypeErrorDecimal不可序列化default转字符串
大批量计算慢循环里反复构造Decimal批量预转,或改整数分单位
1e17加减小了float 超过 2^53 已丢精度源头改 str 或 int

这张表我建议直接贴在自己项目的文档里。遇到相关问题先扫一遍,能省掉大量“打开浏览器搜一遍”的时间。

5.3 几条我个人的操作习惯

用久了我形成几个固定习惯,分享出来供参考。

第一,凡是涉及钱的字段,从入口开始就用字符串或整数。数据库里DECIMAL(18,2),ORM 层直接映射成Decimal,接口层用字符串传输,前端用字符串或专门的金额库处理。整条链路不出现 float,问题就少一大半。这条规矩立下来之后,我在金额相关的 bug 上花的時間明显下降。

第二,舍入模式在代码里永远显式写出来。quantize(exp, rounding=ROUND_HALF_UP),一个参数都不省。默认值是ROUND_HALF_EVEN,代码里不写清楚,下一个人读代码时根本不知道你是“刻意用银行家舍入”还是“不知道有这回事”。显式写出来,代码即文档。

第三,封装函数一定配自测用例。上面那张边界用例表,我每次新项目都会抄一遍改成 pytest 参数化测试。花十分钟写,能挡住后面几个月里反复出现的回归。

import pytest from decimal import Decimal @pytest.mark.parametrize('raw,expect', [ ('2.675', Decimal('2.68')), ('1.005', Decimal('1.01')), ('-1.239', Decimal('-1.24')), ('1.2', Decimal('1.20')), ('0', Decimal('0.00')), ]) def test_round_half_up(raw, expect): assert round_half_up(raw, 2) == expect @pytest.mark.parametrize('raw,expect', [ ('2.679', Decimal('2.67')), ('-1.239', Decimal('-1.23')), ('1.2', Decimal('1.20')), ]) def test_truncate(raw, expect): assert truncate(raw, 2) == expect

还有一点提醒:比较Decimal和float时不要掉以轻心。Decimal('2.68') == 2.68的结果是False,因为比较时会把这个 float 精确展开,而2.68的精确值不是2.68。要比较就两边都转成Decimal(str(...)),或者直接比float(result)加容差。这个小细节我在代码评审里见过不止一次,写单测时尤其容易踩。

最后分享一个我常用的判断口诀,遇到精度问题先默念一遍:输入是什么类型、结果要给人看还是要继续算、舍入方向是哪边、量级会不会撑爆上下文。这四问过一遍,基本就能定位到该用round、format还是Decimal,以及该显式指定哪个舍入模式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询