1. 从0.1 + 0.2说起:浮点数里到底存了什么
如果你在 Python 里敲过0.1 + 0.2,大概都被0.30000000000000004这个结果噎过一下。很多人第一反应是"Python 有 bug",但换任何一门用 IEEE 754 双精度的语言,结果都一样。做小数精度处理这件事,绕不开的第一步不是学 API,而是先接受一个事实:你在代码里写的那个0.1,计算机里存的根本不是 0.1。这篇东西我想把"保留几位小数"这件事从头捋一遍,重点讲清楚两件事——需要四舍五入的时候怎么做才不出岔子,以及需要不四舍五入、直接砍掉小数点后多余位数的时候,哪些写法看着对、实际会少你一分钱。适合刚被金额计算坑过的新手,也适合想把这些边角料彻底钉死的老手。
1.1 二进制分数除不尽,误差从赋值那一刻就产生了
十进制里1/3写不完,只能写成0.3333...。二进制里也有写不完的数,0.1就是其中之一:它等于1/10,而 10 含有质因子 5,二进制只认 2 的幂,所以0.1的二进制展开是无限循环的0.0001100110011...。双精度浮点只有 52 位尾数可用,只能截断到最近的那个可表示值。
这个值具体是多少?0.1实际存的是0.1000000000000000055511151231257827021181583404541015625。注意,它比 0.1大了一点点。而0.2存的是0.200000000000000011102230246251565404236316680908203125,也偏大。两个偏大的数一加,误差累积,就成了0.30000000000000004。
关键在于:误差不是运算产生的,是字面量落进内存时就已经产生了。理解这一点,后面所有的坑都能自己推出来。
>>> from decimal import Decimal >>> Decimal(0.1) Decimal('0.1000000000000000055511151231257827021181583404541015625') >>> Decimal('0.1') Decimal('0.1')同样一个"0.1",从 float 构造和从字符串构造,Decimal 给出的是两个完全不同的东西。这是后面所有 Decimal 用法里最容易翻车的地方,我在第 3 章会专门展开。
1.2repr只保证"打印回去还是它",不代表这个值干净
有人会反驳:我print(0.1)明明输出0.1啊。是的,因为 Python 3 的 floatrepr用的是"最短往返表示"——在所有能唯一还原到这个 double 的十进制串里,挑最短的那个打印。这是一种显示层的体贴,跟内存里的真值没有关系。
这里有个很实用的推论:float('0.1') == 0.1是True,str(0.1) == '0.1'也是True,但Decimal(0.1) != Decimal('0.1')。也就是说,字符串是十进制真值的可靠载体,float 不是。当你从 JSON、CSV、数据库、前端表单拿到数据时,它通常先是字符串,这时候千万别急着float()一下再处理——那一步就把精度丢了。
我做数据清洗时踩过一次:接口返回单价"19.99",我顺手float()转了一下存进列表,后面做sum()汇总,几千条加起来最后总额显示成199899.99999999997。业务方看到这个数字的第一反应是"系统算错了"。实际上每一分钱都没错,只是我用错了容器。
1.3 判断两个浮点数相等,别用==
顺带提一句判等。既然误差必然存在,a == b在浮点世界里几乎是不可靠的。标准做法是用math.isclose,或者自己对差值取绝对值跟一个容差比。Python 3.5 之后math.isclose(a, b, rel_tol=1e-9, abs_tol=0.0)已经够用了,比较接近零的数时记得给abs_tol,因为rel_tol是相对量,对 0 附近失效。
import math math.isclose(0.1 + 0.2, 0.3) # True math.isclose(0.1 + 0.2, 0.3, abs_tol=1e-12) # True abs((0.1 + 0.2) - 0.3) < 1e-9 # True,手写版这个知识点看似跟"保留几位小数"无关,其实是同一件事的两面:你在做四舍五入或者截断的时候,本质上就是在跟误差打交道,只不过一个有意识地在缩小误差,一个不小心在放大误差。
2.round()的真实脾气:四舍六入五成双,还要看浮点的脸色
新手处理"保留两位小数",十个里有九个先想到round(x, 2)。这个函数能用,但它有两个让人抓狂的行为:一是round(2.5)给你2不是3;二是round(2.675, 2)给你2.67不是2.68。这两个现象经常被混为一谈,实际上成因完全不同,一个是规则设计,一个是浮点表示。分清楚了,你就知道什么时候该绕开它。
2.1 银行家舍入不是 bug,是有意为之
Python 3 的round()对恰好处于中间的值(也就是"五"的情况)采用round-half-to-even,中文习惯叫"四舍六入五成双"或者"银行家舍入":向最近的偶数靠拢。
>>> round(0.5), round(1.5), round(2.5), round(3.5) (0, 2, 2, 4) >>> round(2.5), round(3.5) (2, 4)为什么这么设计?因为大量数据做统计时,如果一律"五入",误差会单向累积,均值会系统性偏大。成双舍入让误差在正负两个方向上互相抵消,长期看更接近无偏。金融、统计、科学计算领域普遍这么干,Python 只是跟上了这个惯例。
但对普通业务来说,用户看到2.5变成2会觉得你算错了。这是产品预期和数值规范的错位,不是代码问题。我的处理原则很简单:凡是给业务方看的、需要人工核对的数字,一律不用round(),改用明确的"五入"规则;只有中间过程的统计聚合,才允许用round()那一套。
顺带说一句,Python 2 的round是"五入"(远离零方向),所以很多老代码迁到 3 之后行为变了,这类历史遗留问题在维护老项目时经常冒出来。
2.2round(2.675, 2)得到2.67:两个原因叠在一起
这个例子值得单独拆开看。2.675的二进制近似值是2.67499999999999982236431605997495353221893310546875,它比 2.675小。所以当round要把它舍入到小数点后两位时,它看到的压根不是"正好在 2.675 这个中间点",而是一个明确小于中间点的值,于是老老实实往下舍,得到2.67。
也就是说,这里根本没触发银行家舍入规则,纯粹是浮点表示误差。有人会拿round(2.5)和round(2.675, 2)做类比,其实不是一回事。
那么round(0.125, 2)呢?0.125是精确可表示的(1/8),确实是中间点,这时才会触发成双规则,结果是0.12。
| 表达式 | 结果 | 成因 |
|---|---|---|
round(2.5) | 2 | 中间点,成双舍入 |
round(2.675, 2) | 2.67 | 浮点真值小于中间点,普通舍入 |
round(0.125, 2) | 0.12 | 精确中间点,成双舍入 |
round(0.375, 2) | 0.38 | 精确中间点,成双舍入(8 是偶数) |
想让2.675变2.68,路径只有一条:把它当成十进制数处理,而不是浮点数。这就是下一章要讲的 Decimal。
2.3round的返回值类型与ndigits为负的用法
还有两个不太被注意的点。
第一,round()对 float 返回 float,对 int 返回 int,ndigits省略时返回 int。所以round(2.5)是2(int),round(2.5, 0)是2.0(float)。如果你后续要做类型敏感的序列化,比如写进 JSON 后想保持整数形态,这个差异会有影响。
第二,ndigits可以是负数,意思是舍入到十位、百位。round(1234, -2)得到1200。做报表四舍五入到万位时挺好用。
>>> round(1234, -2) 1200 >>> round(1250, -2) 1200 # 中间点,成双 >>> round(1350, -2) 1400 # 中间点,成双注意round(1250, -2)这里,1250 是整数能精确表示,所以真的触发了成双规则。这类"整数也成双"的行为,做金额取整到分、到角的时候,是实打实会遇到的。
3. 想要"人类意义上的四舍五入",把 Decimal 请出来
到这一步结论已经很清楚了:只要涉及金额、账目、发票、对账单,浮点就不能当主存储类型。正确的姿势是用decimal.Decimal,它按十进制存,按十进制算,位数和舍入规则都由你显式指定,不会给你"惊喜"。代价是它比 float 慢一个数量级左右,以及不能直接跟 float 混算。
3.1 用字符串喂Decimal,别直接传 float
这是 Decimal 用法里最高频的错误,没有之一。Decimal(0.1)得到的是那个 55 位长的精确二进制近似值,而Decimal('0.1')得到的是干净的0.1。区别在前面的Decimal(0.1)演示里已经看过了。
规则可以一句话记住:入参是字符串、整数、或者另一个 Decimal,才用 Decimal 包;入参是 float,先str()再包。当然更稳妥的做法是从源头就不产生 float。
from decimal import Decimal # 推荐 d1 = Decimal('19.99') d2 = Decimal('0.01') # 从 float 来的时候必须过一道 str raw = 19.99 d3 = Decimal(str(raw)) # 直接用 Decimal.from_float 也行,但得到的是精确值,通常不是你想要的 d4 = Decimal.from_float(0.1) # Decimal('0.1000000000000000055511151231257827')这里有个微妙之处:Decimal(str(raw))相当于"把 float 打印出来的那个十进制串当真值",这恰恰是我们想要的语义——用户在界面上看到的是 19.99,那系统内部就应该按 19.99 算。
3.2quantize配ROUND_HALF_UP才是业务意义上的四舍五入
quantize是 Decimal 的"定标"方法,参数是目标精度(也是一个 Decimal),配合rounding参数控制舍入方式。
from decimal import Decimal, ROUND_HALF_UP, ROUND_HALF_EVEN x = Decimal('2.675') x.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) # Decimal('2.68') x.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN) # Decimal('2.68') y = Decimal('0.125') y.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) # Decimal('0.13') y.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN) # Decimal('0.12')ROUND_HALF_UP的官方定义是"ties go away from zero",即遇到中间点向远离零的方向走,翻成人话就是常说的"四舍五入",负数也对称(-0.125变-0.13)。这个规则才是财务口径。
ROUND_HALF_EVEN就是银行家舍入,跟round()对 Decimal 的默认行为一致。
常用的舍入常量我列一下,方便对照查:
| 常量 | 行为 | 典型用途 |
|---|---|---|
ROUND_HALF_UP | 四舍五入(远离零) | 金额、计税、给用户看 |
ROUND_HALF_EVEN | 成双舍入 | 统计聚合、科学计算 |
ROUND_DOWN/ROUND_TRUNC | 向零截断 | 去尾、不四舍五入 |
ROUND_UP | 远离零进位 | 向上取整到分 |
ROUND_FLOOR | 向负无穷 | 负数场景的"向下" |
ROUND_CEILING | 向正无穷 | 负数场景的"向上" |
一个容易忽略的细节:quantize如果结果位数超过当前上下文精度(默认prec=28),会抛InvalidOperation。比如Decimal('1e30').quantize(Decimal('0.01'))就会炸。处理超大金额或科学计数法数据时要留意,必要时用localcontext()临时提高精度。
from decimal import localcontext with localcontext() as ctx: ctx.prec = 50 big = Decimal('1e30').quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)3.3 格式化输出的三种写法,和它们共同的边界
如果你只是想把数字打印成两位小数,不改动计算过程,那格式化字符串是最轻的方案。
x = 2.675 f"{x:.2f}" # '2.67' format(x, '.2f') # '2.67' '%.2f' % x # '2.67'三种写法在 Python 3 里语义一致,底层都是同一个浮点格式化实现。结果 2.67 的原因跟round()那条一样——真值偏小。它们同样采用成双舍入:format(0.125, '.2f')给'0.12'。
所以格式化不是四舍五入的替代品,它只是在"值已经是对的"前提下做展示。如果值本身就带误差,格式化只会把误差诚实地暴露出来。
反过来,如果原始值真的是精确的 2.675(比如来自Decimal('2.675')),那么f"{Decimal('2.675'):.2f}"得到的是'2.68'——因为 Decimal 的__format__走的是十进制舍入。这一点经常被忽略:同样的格式化语法,作用在 float 和 Decimal 上,结果可能不一样。
3.4 一张表看清round/ 格式化 / Decimal / numpy 的差异
我在项目里整理过这么一张对照表,新同事问起来直接甩过去,比口头解释快。
| 方案 | 2.675 → 两位 | 舍入规则 | 返回类型 | 适用场景 |
|---|---|---|---|---|
round(x, 2) | 2.67 | 成双 + 浮点真值 | float | 统计中间量 |
f"{x:.2f}" | '2.67' | 成双 | str | 纯展示 |
Decimal(str(x)).quantize(..., ROUND_HALF_UP) | 2.68 | 四舍五入 | Decimal | 金额、账目 |
numpy.round(x, 2) | 2.67 | 成双 + 浮点真值 | float / ndarray | 数组批量处理 |
numpy.around(x, 2) | 同上 | 同上,around是别名 | — | 同上 |
一句话总结:要数值正确,用 Decimal;要显示好看,用格式化;要批量处理并且能接受浮点误差,用 numpy。
4. 不四舍五入:截断去尾的四种实现与各自的坑
标题里那个[不]是关键。很多需求不是四舍五入,而是直接砍掉多余的位数——比如展示"已使用 3.99GB",实际是 3.999;比如积分结算算到小数点后一位,不往前进位;比如分润比例,宁可少给也不能多算。这种"截断"操作看着比四舍五入简单,实际上坑更多,因为大家习惯性地用乘法加取整,而这一步会把浮点误差放大。
4.1int(x * 10**n) / 10**n为什么会少你一分钱
这是最流行的写法,也是最容易出事的写法。问题出在乘法那一步:浮点乘法的结果同样有误差,本来应该是114.99999999999999的东西,int()一取整就成了114。
>>> 1.15 * 100 114.99999999999999 >>> int(1.15 * 100) / 100 1.141.15保留两位、截断去尾,正确答案应该是1.15,代码给的是1.14。少的这一分钱,如果放在对账系统里,几千条流水就是几十块的差额,排查起来极其费劲,因为大部分数是对的,只有特定值才出错。
再举两个:
>>> 0.29 * 100 28.999999999999996 >>> int(0.29 * 100) / 100 0.28 >>> 2.51 * 100 250.99999999999997 >>> int(2.51 * 100) / 100 2.5可以看到,出错的值并不罕见,凡是十进制分数在二进制里"除不尽"的,都有概率踩中。
有人会想到加一个极小量来补偿,写成int(x * 100 + 1e-9) / 100。这个办法能救一部分,但1e-9这个魔数跟你数据量级强相关,数据量级一变就失效,属于治标。而且如果x是负数,+1e-9的方向又反了。我的建议是:这个写法只用于临时脚本或者对精度完全不敏感的场合,进生产代码就换成 Decimal。
还有一个变体用math.floor替代int,问题一模一样,因为误差发生在乘法阶段,取整函数没责任。
4.2 字符串切片的暴力美学与它的适用边界
另一种思路是绕开算术,直接在字符串上动手。
def trunc_by_str(x, n=2): s = f"{x:.10f}" # 先补足位数 head, _, tail = s.partition('.') return float(f"{head}.{tail[:n]}")这个写法在小数点位之后直接切片,不做任何进位,逻辑上完全符合"不四舍五入"的语义。它依赖的是f"{x:.10f}"已经把误差"洗"到了第 10 位之后,前两位是可信的。
但它有几个明显的前提:一是 n 不能太大(比如 n=12 时,第 12 位本身可能就是误差),二是必须保证x不是科学计数法形式(极大或极小的数会被f格式化成1.2e+10这类),三是负号处理要小心,partition之后head里带着负号,拼回去没问题,但如果自己用split('.')再处理就得留意。
所以字符串法适合"输入本来就是字符串"的场景,比如从文本里解析出来的版本号、配置项、展示用的指标值。如果输入是计算结果,还是老老实实用 Decimal。
4.3 Decimal 的ROUND_DOWN、ROUND_TRUNC、ROUND_FLOOR
Decimal 提供的截断方式是最可靠的,语义清晰,不受浮点误差干扰。
from decimal import Decimal, ROUND_DOWN, ROUND_TRUNC, ROUND_FLOOR, ROUND_CEILING v = Decimal('1.159') v.quantize(Decimal('0.01'), rounding=ROUND_DOWN) # Decimal('1.15') v.quantize(Decimal('0.01'), rounding=ROUND_TRUNC) # Decimal('1.15') v.quantize(Decimal('0.01'), rounding=ROUND_FLOOR) # Decimal('1.15') v.quantize(Decimal('0.01'), rounding=ROUND_CEILING) # Decimal('1.16')正数情况下这四个看起来差不多,一旦换成负数就分道扬镳了,这是下一节要讲的。
需要注意的是,quantize的精度参数必须写清楚,Decimal('0.01')表示两位小数,Decimal('1')表示取整,Decimal('0.1')表示一位。别写成Decimal(0.01),那又掉进浮点坑里了。
4.4 负数截断的方向:向零还是向负无穷
这是截断里最容易搞错的一点。中文说"去掉小数点后几位",对正数是明确的,对负数就有歧义:-1.159截到两位,是-1.15还是-1.16?
>>> int(-1.159 * 100) / 100 -1.15int()对负数是向零截断,所以得到-1.15,绝对值变小了。而math.floor(-1.159 * 100) / 100会得到-1.16,绝对值变大了。这两个结果在业务上含义完全不同:一个是"少收用户一点",一个是"多收用户一点"。
Decimal 把这层区分做得很细:
| 常量 | 对Decimal('-1.159')保留两位 | 方向 |
|---|---|---|
ROUND_DOWN | -1.15 | 向零 |
ROUND_TRUNC | -1.15 | 向零(同DOWN) |
ROUND_FLOOR | -1.16 | 向负无穷 |
ROUND_CEILING | -1.15 | 向正无穷 |
我的经验是:业务规则里写"舍去"、"截取"、"不进位"的时候,一定要追问一句负数怎么处理。很多需求文档压根没考虑负数,等到真出现退款、冲正、负库存的时候才发现对不上。提前问清楚,比事后补数据省事得多。
5. 金额与工程数据里的落地做法
前面讲的都是"怎么把一个数处理对",这一章讲"怎么让整个系统一直对"。单个函数写对不难,难的是数据在数据库、接口、前端之间来回流转的过程中,每一层都保持同一个口径。我见过太多项目,单点计算都用了 Decimal,但某处日志打印用了 float,结果对账时两边对不上。
5.1 用整数最小单位存储,显示时再格式化
这是我在所有涉及钱的项目里固定下来的做法:数据库和内存里一律存整数,单位是分(或者更小的厘、毫),只有在最终展示和格式化的时候才转成带小数点的样子。
class Money: __slots__ = ('cents',) def __init__(self, cents: int): self.cents = int(cents) @classmethod def from_yuan(cls, s: str) -> 'Money': # 从 "19.99" 这类字符串构造,四舍五入到分 d = Decimal(s).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) return cls(int(d * 100)) def to_yuan_str(self) -> str: sign = '-' if self.cents < 0 else '' q, r = divmod(abs(self.cents), 100) return f"{sign}{q}.{r:02d}" def __add__(self, other): return Money(self.cents + other.cents) def __repr__(self): return f"Money({self.to_yuan_str()})"这样做的好处很直接:加减法天然精确,永远不会出现0.30000000000000004;比较大小直接用整数比;数据库用BIGINT存,索引和聚合都快;导出报表时统一走to_yuan_str(),格式一致。
需要注意的只有乘除法。比如"按 3.5% 算手续费",这时候要回到 Decimal:
fee = (Decimal(m.cents) * Decimal('0.035')).quantize(Decimal('1'), rounding=ROUND_HALF_UP)先算出以分为单位的金额,再按规则舍入到整数分。方向选ROUND_HALF_UP是常规做法,具体到你们业务上可能是"向上取整保护平台方",那就换成ROUND_CEILING,但一定要在文档里写死。
5.2 Decimal 的上下文精度与批量计算的性能
如果不方便全改成整数,全程用 Decimal 也是可行的,但要注意两件事:上下文精度和性能。
精度默认是 28 位有效数字,对绝大多数业务够了,但它影响的是运算结果的位数,不是显示位数。Decimal(1) / Decimal(3)会给你 28 个 3,不会无限展开。如果要做高精度科学计算,用localcontext()临时提高,别去改全局的getcontext().prec,那是个全局状态,改完之后整个进程的其他模块都受影响,属于典型的隐形炸弹。
性能上,Decimal 比 float 慢大概 5 到 20 倍,具体取决于运算复杂度。做百万级的批量汇总时,我一般这么安排:逐条累加用整数分,最后一次性转 Decimal 做比例计算。这样既保证了精度,又不会在循环里被 Decimal 拖慢。
total_cents = 0 for row in rows: # 几十万条 total_cents += row['amount_cents'] rate = Decimal('0.06') tax = (Decimal(total_cents) * rate).quantize(Decimal('1'), rounding=ROUND_HALF_UP)反过来如果你在循环里每条都Decimal(...)一下再相加,慢是必然的,而且还容易因为某条数据的小数位不规范导致结果不一致。
5.3 序列化与落库时的统一口径
最后这一环最容易被忽略。Decimal 转 JSON 时,json.dumps默认是不支持的,会抛TypeError。常见的做法是自定义 encoder 转成字符串:
import json from decimal import Decimal class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Decimal): return format(o, 'f') # 用 'f' 避免科学计数法 return super().default(o) json.dumps({'price': Decimal('19.90')}, cls=DecimalEncoder) # '{"price": "19.90"}'注意format(o, 'f')这个细节。直接str(Decimal('1E+2'))得到的是'1E+2',前端拿到会懵;用'f'格式化会输出'100'。这个坑我在对接第三方接口时踩过,对方解析不了科学计数法,整批数据被拒。
还有一点:接口传输用字符串,别用数字。JSON 规范里数字就是双精度浮点,你传19.90出去,对方解析完可能变成19.9,再传回来可能变成19.899999999999999。字符串是最安全的载体,前后端约定好格式(比如固定两位小数),比什么都可靠。
6. 一份我自己的踩坑清单
这一章把上面散落的点收拢成可以直接对照的清单。我把它放在项目 wiki 里,新人入职第一周必看,能省掉至少一半的数值相关 bug。
6.1 高频错误速查表
| 现象 | 根因 | 正确做法 |
|---|---|---|
0.1 + 0.2 != 0.3 | 二进制无法精确表示十进制小数 | 用 Decimal 或整数分 |
round(2.675, 2) == 2.67 | 浮点真值小于中间点 | Decimal('2.675').quantize(...) |
round(2.5) == 2 | 银行家舍入 | 业务场景改用ROUND_HALF_UP |
int(1.15 * 100) / 100 == 1.14 | 乘法引入误差,向下取整多减一位 | Decimal(str(x)).quantize(ROUND_DOWN) |
Decimal(0.1)长度爆炸 | 从 float 构造拿到二进制精确值 | Decimal(str(x)) |
json.dumps(Decimal(...))报错 | 默认 encoder 不认识 Decimal | 自定义 encoder,输出字符串 |
大额quantize抛InvalidOperation | 超过上下文prec | localcontext()提高精度 |
-1.159截断方向不对 | 没区分向零和向负无穷 | 明确用ROUND_DOWN或ROUND_FLOOR |
前端显示19.900000000000002 | float 直接序列化 | 后端格式化成字符串再传 |
| 多方对账差几分钱 | 各处舍入规则不一致 | 全链路统一规则并写进文档 |
表格里最后一条其实是最要命的。技术上的精度问题,只要用对工具都能解决;规则不一致才是无解的,因为两边算出来都"对",只是对的不是一个东西。所以我现在做涉及分账、结算的项目,第一件事不是写代码,是拉上业务方把"四舍五入还是截断""负数往哪边靠""中间结果保留几位""最终结果保留几位"这四条定死,写进接口文档。
6.2 我固定下来的一套小工具函数
最后把常用的几个封装贴出来,直接抄就能用。
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN def to_dec(x) -> Decimal: """统一入口:字符串/整数/float 都能安全转成 Decimal""" if isinstance(x, Decimal): return x if isinstance(x, float): return Decimal(str(x)) return Decimal(x) def round_half_up(x, n: int = 2) -> Decimal: """业务意义的四舍五入,保留 n 位""" q = Decimal(1).scaleb(-n) # n=2 -> Decimal('0.01') return to_dec(x).quantize(q, rounding=ROUND_HALF_UP) def truncate(x, n: int = 2) -> Decimal: """不四舍五入,直接截断到 n 位(向零方向)""" q = Decimal(1).scaleb(-n) return to_dec(x).quantize(q, rounding=ROUND_DOWN) def fmt(x, n: int = 2) -> str: """格式化成固定 n 位的字符串,用于展示和传输""" return format(round_half_up(x, n), f'.{n}f')Decimal(1).scaleb(-n)这个写法比手写Decimal('0.01')更灵活,n 是变量的时候不用拼字符串。scaleb是 10 的幂次缩放,返回的结果位数也规范。
实际用起来是这样:
>>> round_half_up('2.675') Decimal('2.68') >>> truncate('1.15') Decimal('1.15') >>> truncate('-1.159') Decimal('-1.15') >>> fmt('19.9') '19.90'有一点我在实际项目里反复强调:这套函数输入输出都用 Decimal 或者字符串,中间不要出现 float。只要在调用链的某一环有人图省事写了个float(...),前面的努力就全白费。我在代码评审时会把float(和round(这两个词当成敏感词搜一遍,尤其是账务模块,出现就得问一句为什么。
另外提一个容易被忽视的边界:to_dec(True)会得到Decimal('1'),因为 bool 是 int 的子类。如果你的数据源里可能混进布尔值(比如从表格解析出来的单元格),加一道isinstance(x, bool)的拦截会更稳。这种细节平时想不起来,真出了数据错乱才后悔。
还有个小技巧分享给做批量处理的同学:如果你需要对一整个列表做截断,别在循环里反复构造精度参数q,把它提出来复用,性能差别在小数据量上不明显,但几十万条的时候能省下可观的时间。我当时优化一个报表导出,把quantize的精度参数提到循环外,整体耗时从 40 秒降到 31 秒,改动只有两行。