钱不能用 double
前十六章都在同一个格子里打转:IEEE 754 双精度。卷 V 换格子。第一个要换的是钱——不是因为 double 精度不够(它有 15 位,够记下全人类的财富精确到分),而是因为它的刻度画在错误的地方。这一章会看到同一门语言里的两个标准四舍五入写法,对同一个数给出 2.68 和 2.67。
收银台要把 1.005 元四舍五入到分。写法是全世界最常见的那一种:
Math.round(1.005 * 100) / 100
问:得到什么?
第二问,同一门语言、同一个数:Math.round(2.675 * 100) / 100 和 (2.675).toFixed(2) 会给出同一个答案吗?
先看这三行
> Math.round(1.005 * 100) / 100 1 // ← 1.00 元。少收了一分钱 > Math.round(2.675 * 100) / 100 2.68 > (2.675).toFixed(2) "2.67" // ★ 同一个数,同一门语言,差一分钱
拆开看就明白了:
1.005 的精确值 = 1.00499999999999989⟨341858963598497211933135986328125⟩
↑ 它其实比 1.005 小
1.005 * 100 = 100.49999999999999 ← 四舍五入到 100,不是 101
⇒ 1.00
2.675 的精确值 = 2.67499999999999982⟨236431605997495353221893310546875⟩
↑ 它也比 2.675 小
2.675 * 100 = 267.5 ← ⟨★ 乘法这一步把它抹回了 267.5⟩
Math.round(267.5) = 268 ← 于是又变成了 2.68
而 toFixed(2) 看的是原值 2.674999… ⇒ 2.67
关键在中间那行:1.005 × 100 抹完之后落在 100.49999999999999,而 2.675 × 100 抹完之后正好落回 267.5。同样是「本来略小于 x.xx5」,一个进位失败,一个进位成功——完全取决于乘法那一步被抹到了哪一边。
答案是 B;第二问:不一样,一个 2.68 一个 2.67。
钱的问题不是「double 不够精确」,是「double 的刻度不在分上」。
钱有一个天然的最小单位(分)。任何金额都是这个单位的整数倍——它本该落在一排等距、精确的刻度上。而 double 的刻度是二进制的,0.01 根本不在上面(1/100 的分母有因子 5,第 1 章)。
所以每一笔金额从存进去的那一刻起就是近似的,之后每一次乘税率、算折扣、四舍五入,都在这份近似上再抹一次。不是误差大,是这套坐标系从一开始就没对齐。
三条出路
| 做法 | 怎么存 | 好处 | 代价 |
|---|---|---|---|
| 整数分 | 19.99 元 存成 1999(int64) | 加减完全精确;快;到处都能用 | 除法和百分比要自己处理舍入;要防溢出(int64 能到 9×10¹⁸ 分 = 九亿亿元,够) |
| 十进制 | Decimal("19.99")、BigDecimal、PG 的 numeric | 刻度就在十进制上;能配舍入模式;小数位可控 | 慢一到两个数量级;语言支持参差不齐 |
| 有理数 | Fraction(1999, 100) | 连 1/3 都精确 | 分母会爆炸式增长;到最后还是要舍入才能出报表 |
工业界的实际选择基本是前两条:存储和账目用整数分(或者数据库的 numeric),计算过程用十进制类型。
一个必须注意的陷阱:Decimal 从哪里来
>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3') ✓ 精确
>>> Decimal(0.1) # ★ 从 float 构造
Decimal('0.1000000000000000055511151231257827021181583404541015625')
从 float 构造 Decimal,等于把错误原样搬进来——而且搬得极其忠实。这是使用十进制类型时最常见的一个失误:类型换了,但数据是从一个已经抹过零的 float 转过来的,等于在木头上盖了个金库。
规矩很硬:金额必须从字符串(或整数)进入系统,全程不碰 float。包括 JSON 解析(很多库默认把数字解析成 float,要显式配置成 Decimal 或字符串)、CSV 读取、ORM 映射。只要中间有一环变成了 float,后面用什么类型都没意义了。
走一遍完整的收银流程
三件 19.99 元的商品,打九折,加 8.25% 的税:
# Decimal 路径(每一步按业务规则舍入到分) 小计 19.99 × 3 = 59.97 折后 59.97 × 0.9 = 53.97 (53.973 → 四舍五入到分) 税 53.97 × 0.0825 = 4.45 (4.4525 → 四舍五入到分) 合计 = 58.42 # float 路径 小计 59.969999999999999 折后 53.969999999999999 税 4.4500000000000002 合计 58.420000000000002
这个例子里两条路径的结果一样(都是 58.42 元)——这正是问题所在。float 在绝大多数交易上都是对的,直到某一笔不对。而账目系统的特点是:一年几亿笔交易里有一百笔差一分钱,就是一个必须查到底的事故。
再看累计的那一面:
一百万次 +0.01: float = 10000.000000171856 整数分 = 1000000 分 = 10000.00 元 ✓ 精确 差 1.72 × 10⁻⁷ 元 —— 单看无所谓。 但它意味着 `if (balance === 10000)` 会是 false, 而这种判断在对账、清算、风控里到处都是。
四舍五入其实有六七种,而它们在钱上会给出不同的答案。IEEE 754 定义了五种舍入模式,十进制类型库通常提供更多:
- ROUND_HALF_UP(四舍五入):0.5 往远离零的方向进。中国、多数零售场景的默认。
- ROUND_HALF_EVEN(银行家舍入):0.5 进到偶数。2.5→2,3.5→4。IEEE 754 的默认舍入模式就是这个,也是浮点硬件在做的事。
- ROUND_DOWN / ROUND_FLOOR:截断。第 4 章温哥华股指那 574 点,就是它。
银行家舍入存在的理由是无偏:四舍五入在大量数据上会系统性偏高(因为 0.5 总是往上),银行家舍入让一半往上一半往下,长期期望为 0。这正是第 4 章「误差方向一致 ⇒ 按 n 长;方向随机 ⇒ 按 √n 长」的直接应用——把方向从一致变成随机,就把线性增长变成了平方根增长。
但要注意:银行家舍入不是所有场合的正确选择。税务、发票、合同金额通常有法定的舍入规则,得照着来。舍入模式是业务规范,不是技术偏好。
分不掉的那一分钱
钱还有一个 double 解决不了、Decimal 也解决不了的问题:10 元三个人平分。
10.00 / 3 = 3.333333…
按分舍入:3.33 + 3.33 + 3.33 = 9.99
⇒ 少了 0.01 元
这一分钱必须有个去处,而这是业务决定,不是数学决定。常见的三种:
- 给第一个人(或最后一个):3.34 + 3.33 + 3.33。简单,但不公平。
- 最大余数法:按小数部分排序,余数分给小数部分最大的那几个。选举里的席位分配用的也是这个。
- 进「舍入差额」账户:会计上专门开一个科目装这些零头。这是账目系统的标准做法——因为它保证了「所有科目相加恒为零」这条铁律。
不管选哪种,都必须显式选一种。没选的话,那一分钱就会在某处凭空消失或凭空出现,而这是审计会揪出来的东西。
先看到问题,再看到修法:
from decimal import Decimal, ROUND_HALF_UP, ROUND_HALF_EVEN
from fractions import Fraction
# ① float 的两副面孔
print(round(2.675, 2), round(1.005, 2)) # 2.67 1.0
print(Fraction(2.675)) # 精确值,比 2.675 小
print(repr(0.07 * 100)) # 7.000000000000001
# ② Decimal:从字符串来,别从 float 来
print(Decimal('0.1') + Decimal('0.2')) # 0.3 ✓
print(Decimal(0.1)) # 0.1000000000000000055511… ✗
# ③ 舍入模式是业务决定
for m, name in ((ROUND_HALF_UP, '四舍五入'), (ROUND_HALF_EVEN, '银行家')):
print(name, [str(Decimal(s).quantize(Decimal('0.01'), m))
for s in ('2.675', '2.665', '2.685')])
# ④ 整数分:加减完全精确
cents = 0
for _ in range(1_000_000): cents += 1
d = 0.0
for _ in range(1_000_000): d += 0.01
print('整数分 =', cents / 100, ' float =', repr(d))
# ⑤ 分不掉的那一分
total = Decimal('10.00'); n = 3
each = (total / n).quantize(Decimal('0.01'), ROUND_HALF_UP)
print(f'{each} × {n} = {each * n},差额 {total - each * n}')
第三块会打出 四舍五入 ['2.68', '2.67', '2.69'] 和 银行家 ['2.68', '2.66', '2.68']——同一批数,两种规则,三个数里有两个不同。(注意 Decimal('2.675') 是精确的 2.675,所以银行家舍入按规矩进到偶数 2.68。)
python3 money.py # 纯标准库
在线:python.org/shell。JS 没有内置十进制类型,可以用 decimal.js 或者干脆用 BigInt 存分。
数据库。PostgreSQL 的 numeric 是任意精度十进制,加减乘完全精确;代价是它比 double precision 慢一到两个数量级(因为是软件实现的十进制算术)。MySQL 的 DECIMAL 类似。而 FLOAT / DOUBLE 列存钱是经典事故源——《当真》那本书里讲的「PG 把类型当真」,这里是最贵的一个例子。
加密货币把这件事做对了。比特币的内部单位是聪(satoshi,10⁻⁸ BTC),以太坊是 wei(10⁻¹⁸ ETH),全部用整数存。所有链上金额都是整数运算,没有一处浮点。这不是保守,这是唯一能让全网节点算出完全相同结果的做法——第 19 章会讲为什么浮点做不到这件事。
Java 的 BigDecimal。它强制你在做除法时指定舍入模式,不指定就抛 ArithmeticException。这个设计经常被抱怨啰嗦,但它是对的:「除不尽的时候怎么办」是一个必须由调用方回答的业务问题,不该有默认值。
不只是钱。凡是有天然最小单位的量,都该用整数:时间戳(纳秒)、像素、格子坐标、库存件数、票数、字节数、区块高度。判据和第 2 章一样:这个量是不是「某个固定单位的整数倍」?是,就用整数类型——不是因为精度,是因为类型对。
「用 double 算,显示的时候 toFixed(2) 一下就行了。反正误差在小数点后十几位。」
这句话有两个漏洞,而且都在这一章里现了原形。
第一,toFixed(2) 本身就不可靠。(1.005).toFixed(2) 是 "1.00",(2.675).toFixed(2) 是 "2.67"——而同一个 2.675 用 Math.round(x*100)/100 是 2.68。你的显示层和你的计算层会给出不同的钱数。
第二,误差不会一直待在小数点后十几位。一百万次 +0.01 之后它爬到了 1.7×10⁻⁷;第 8 章那份账上它爬到了 1.08 元。而金额相减、算差额、对账,正是第 3 章那个「相消」的最佳产地。
正确的说法是:钱的正确类型是整数或十进制,就像时间的正确类型不是字符串一样。这是类型问题,不是精度问题,所以不能用「加一点余量」来解决。
正确答案是 B:Math.round(1.005 * 100) / 100 得到 1,也就是 1.00 元,少收一分钱。第二问:不一样——Math.round(2.675*100)/100 给 2.68,(2.675).toFixed(2) 给 "2.67"。
1.005 真的是 1.005,A 就是对的。但你写下的那个字面量存进 double 之后是 1.00499999999999989…,它本来就小于 1.005,所以不该进位。浮点数在这里其实是「正确」的——它诚实地对一个略小于 1.005 的数做了四舍五入。错的是「我以为我存的是 1.005」这个假设。
C 「round 没起作用」——起作用了,只是作用在了一个你没预料到的数上。这是这本书反复出现的模式:函数完全正确地执行了,只是它拿到的输入和你以为的不一样。
D 「抛异常」——不会。Math.round 对任何有限数都有定义。又是一次安静的失败——这已经是这本书第七次说这句话了,而这一次它直接对应到「少收了一分钱」。
let total = 0; for (const item of cart) total += item.price; price 是 double
let cents = 0n; for (const item of cart) cents += BigInt(item.priceCents); 整数分,加减精确
Math.round(x * 100) / 100 —— 依赖乘法那一步被抹到哪边
用十进制类型 + 显式舍入模式:Decimal(s).quantize(Decimal('0.01'), ROUND_HALF_UP)
数据库列写成 FLOAT / DOUBLE
NUMERIC(19, 4)(PG / MySQL)或 BIGINT 存最小单位。这是最难改的一处,也是最该一开始就选对的一处。
API 返回 {"amount": 19.99} —— JSON 数字在 JS 端一律解析成 double
{"amount": "19.99"} 或 {"amountCents": 1999}。和第 2 章大整数 ID 传字符串是同一条理由。
最后一条,也是最容易被跳过的:明确写下「分不掉的零头去哪里」。拆分、分摊、按比例、多币种换算——每一处除不尽的地方都要有一个显式的归属规则,否则钱会自己长出来或者自己消失。
这一章的一句话
钱不能用 double,不是因为 double 不够精确,而是因为钱的刻度是十进制的、等距的、有最小单位的,而 double 的刻度一样都不是。
下一章要去的地方正好相反:主动搬到一个精度更差的格子里。AI 训练现在普遍用 bf16——尾数只有 7 位,十进制有效数字两位都不到,比这一章的钱还粗糙十万倍。而它训得出千亿参数的模型。这不是妥协,是一个非常聪明的交易:它把省下来的位,全部换成了指数。