卷 V · 换格CH 17深度 17/20

不能用 double

前十六章都在同一个格子里打转:IEEE 754 双精度。卷 V 换格子。第一个要换的是钱——不是因为 double 精度不够(它有 15 位,够记下全人类的财富精确到分),而是因为它的刻度画在错误的地方。这一章会看到同一门语言里的两个标准四舍五入写法,对同一个数给出 2.68 和 2.67。

1.005 → 1.00整数分 / 十进制银行家舍入

▷ 猜一下差多少

收银台要把 1.005 元四舍五入到分。写法是全世界最常见的那一种:

Math.round(1.005 * 100) / 100

问:得到什么?

A 1.01。四舍五入,5 进位 B 1(也就是 1.00 元) C 1.005。round 没起作用 D 抛异常

第二问,同一门语言、同一个数: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)/1002.68你的显示层和你的计算层会给出不同的钱数。

第二,误差不会一直待在小数点后十几位。一百万次 +0.01 之后它爬到了 1.7×10⁻⁷;第 8 章那份账上它爬到了 1.08 元。而金额相减、算差额、对账,正是第 3 章那个「相消」的最佳产地。

正确的说法是:钱的正确类型是整数或十进制,就像时间的正确类型不是字符串一样。这是类型问题,不是精度问题,所以不能用「加一点余量」来解决。

◇ 对账

正确答案是 BMath.round(1.005 * 100) / 100 得到 1,也就是 1.00 元,少收一分钱。第二问:不一样——Math.round(2.675*100)/1002.68(2.675).toFixed(2)"2.67"

A 「四舍五入,5 进位」——如果 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 位,十进制有效数字两位都不到,比这一章的钱还粗糙十万倍。而它训得出千亿参数的模型。这不是妥协,是一个非常聪明的交易:它把省下来的位,全部换成了指数。