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-string | Decimal.quantize() |
|---|---|---|---|
| 舍入规则 | 银行家舍入(half even) | 随底层实现,float 走 half even | 可指定 8 种模式 |
| 受输入误差影响 | 严重 | 严重(float 输入) | 无(字符串构造时) |
| 返回值类型 | int / float | str | Decimal |
| 能否截断 | 不能 | 不能 | 能(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.24 | 1.24 |
ROUND_HALF_EVEN | 银行家舍入(默认) | -1.24 | 1.24 |
ROUND_DOWN | 朝零方向截断 | -1.23 | 1.23 |
ROUND_FLOOR | 朝负无穷 | -1.24 | 1.23 |
ROUND_CEILING | 朝正无穷 | -1.23 | 1.24 |
ROUND_UP | 远离零方向 | -1.24 | 1.24 |
ROUND_05UP | 末位为0或5时才远离零 | -1.23 | 1.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.675 | 2.68 | 2.67 | float 表示误差 |
| 中点 | 1.005 | 1.01 | 1.00 | 0.5 进位判定 |
| 负数 | -1.239 | -1.24 | -1.23 | 负数的方向 |
| 恰好整 | 1.2 | 1.20 | 1.20 | 补零 |
| 已达标 | 1.230 | 1.23 | 1.23 | 幂等 |
| 零 | 0 | 0.00 | 0.00 | 零的处理 |
| 负零 | -0.001 | -0.00 | -0.00 | 符号位残留 |
| 大数 | 1e17 | 不丢精度 | 不丢精度 | prec 是否够 |
| 微小数 | 1e-7 | 0.00 | 0.00 | 科学计数法入口 |
| 字符串 | '1,234.567' | 1234.57 | 1234.56 | 输入清洗 |
| 非法输入 | 'abc'/NaN | 抛 ValueError | 抛 ValueError | 入口拦截 |
| 取整到十位 | 1234.5,-1 | 1230 | 1230 | 负位数 |
负零那条要特别说一下。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 snot 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.67 | float 表示误差,输入小于中点 | 换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而不是1000 | str(Decimal)保留科学计数法 | 用format(d, 'f') |
输出-0.00 | 负零符号残留 | 判断为零时lstrip('-') |
format(Decimal('2.685'), '.2f')得 2.68 | 默认ROUND_HALF_EVEN | quantize显式指定模式 |
json.dumps报 TypeError | Decimal不可序列化 | 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,以及该显式指定哪个舍入模式。