本站有 13 个单位换算页,从重量、长度、面积、体积到温度、压力、功率、能量、速度、时间、数据存储,加上后来补充的燃油消耗与角度,一共 192 个单位。它们全部在浏览器里算完,一个字节都不上传。
这篇原本想写的是“防浮点误差指南”。动笔前我先把整张表跑了一遍实测——结论和预期相反:线性换算的往返误差最差只有 1 个 ulp(浮点数的最小刻度),压根不需要“防”。真正把精度弄丢的是另外两个地方,而且都比浮点误差大三个数量级以上:
- 手抄的小数。有个因子是从别处抄来的十进制数,和同一张表里的精确定义值从第 8 位起就对不上——而显示层是 12 位有效数字,宽到足以把它露出来:页面上
1 inHg显示成25.4000001975 mmHg。这是今天顺手修掉的(9b7c04e)。 - 显示层的截断策略。这个在上一篇之后单独修过:
formatNumber在 1e12 处切到科学计数法,把1 TiB显示成1.099512e+12,丢掉了 double 明明完整持有的六位数字。
所以这篇的实际主题是:在一个换算器里,精度是被数据录入和显示策略决定的,不是被算术决定的。唯一一个算术真的会吃掉精度的地方是温度,因为它是全表唯一的仿射单位——这个单独讲。
1. 中心辐射式:192 个因子,不是 18336 个转换对
最容易想到的换算实现是查表:给每一对单位存一个系数。13 个分类里,有序单位对一共 18336 个(重量类 26 个单位就有 26 × 25 = 650 对)。这条路不能走,不只是因为要填 18336 个数,而是因为它们会互相矛盾——只要有一对填错,kg → lb 和 lb → kg 就不再互为逆运算,而你没有任何机制发现。
正确做法是中心辐射式(hub-and-spoke):每个分类选一个基准单位,每个单位只存自己到基准的一个因子。整个 src/tools/units.ts 的核心就是这个 7 行的辅助函数:
/** Linear unit: value * factor converts to base unit. */
const lin = (label: string, factor: number, labelZh?: string, short?: string): UnitDef => ({
label,
labelZh,
short: short ?? (label.match(/\(([^)]+)\)/)?.[1] ?? label),
toBase: (v) => v * factor,
fromBase: (v) => v / factor,
});
于是任意两个单位之间的换算永远是同一条两步路径:
const baseValue = from.toBase(v); // 一次乘法
const out = to.fromBase(baseValue); // 一次除法
这带来三件事,每一件都不是小事:
一、数据量从 降到 。 192 个因子替代 18336 个转换对。新增一个单位只要写一个数,它和其余所有单位的换算自动成立。
二、误差上界是常数。 无论换算哪一对,都恰好是两次浮点舍入,与单位数量无关。查表式实现里 市寸 → 光年 可能是抄来的多步结果,误差不可控;这里它和 m → cm 一样干净。
三、全单位对照表的一致性是免费的。 换算页下方那张“全部单位即时换算对照表”有一个真实的风险:如果每张卡片各自算一遍,它们可能互相矛盾。实现里所有卡片都从同一个 baseValue 派生:
for (const [name, entry] of cardMap.entries()) {
const convertedVal = entry.u.fromBase(baseValue);
entry.valEl.textContent = `${formatNumber(convertedVal)} ${unitShort(cat, name)}`;
}
一个输入 → 一个基准值 → 18 张卡片。它们不可能不一致,因为它们本来就是同一个数的 18 种写法。
2. 实测:36674 次往返,线性类最差 1 个 ulp
“精度够不够”不该靠感觉。我在真实的 UNIT_CATEGORIES 上跑了完整的往返测试:每个分类里的每一对单位(含自己到自己),各用 11 个测试值(1、2、3、7、10、100、2.5、0.5、1234.5678、1/3、99999)做 A → B → A,看回到的值和出发值差多少。
round trips tested : 36674
bit-exact : 33038 (90.09%)
worst, linear cats : 2.961e-16 = 1 ulp (weight ct->hk_jin, v=3)
worst, temperature : 1.251e-13 = 563 ulp (R->C, v=1/3)
Number.EPSILON : 2.220e-16
显示串发生变化的往返 : 0
九成的往返是逐位精确的——回来的 double 和出发的 double 是同一个数。剩下一成里,线性分类最差也就 1 个 ulp,即 double 能表示的最小间隔。这个数量级不需要任何补救措施:它比任何真实测量的不确定度都小十几个数量级,也比显示层能表现的精度小三个数量级。
这里关键的反常点在于那个 563 ulp,它全部来自温度。
3. 温度是全表唯一的仿射单位,lin() 对它无效
lin() 假设换算是纯比例:。温度不是,它是仿射变换:。摄氏度和开尔文的关系带一个 273.15 的偏移,所以这五个单位只能手写闭包:
C: {
label: 'Celsius (°C)', labelZh: '摄氏度 (°C)', short: '°C',
toBase: (v) => v + 273.15,
fromBase: (v) => v - 273.15,
},
F: {
label: 'Fahrenheit (°F)', labelZh: '华氏度 (°F)', short: '°F',
toBase: (v) => ((v - 32) * 5) / 9 + 273.15,
fromBase: (v) => ((v - 273.15) * 9) / 5 + 32,
},
R: {
label: 'Rankine (°R)', labelZh: '兰氏度 (°R,绝对华氏度)', short: '°R',
toBase: (v) => (v * 5) / 9,
fromBase: (v) => (v * 9) / 5,
},
两个设计决定藏在这段里:
基准单位选开尔文,是因为它是五个里唯一没有偏移的。 如果拿摄氏度当基准,K → C → F 会多引入一次 ±273.15 的加减;拿开尔文当基准,K 自己的 toBase / fromBase 是恒等函数,而其余单位各自只承担一次偏移。
兰氏度没有偏移项,这是对的,不是漏写。 °R 是绝对温标(零点就是绝对零度),只是刻度沿用华氏度的 5/9。所以它的换算是纯比例 v * 5 / 9——它其实可以用 lin(),之所以也手写,是为了和同分类的另外四个摆在一起看得清。
为什么偏移会吃掉精度
那个 563 ulp 的最差案例是 R → C,输入 。跟一遍:
R.toBase(0.3333…) = 0.3333… * 5 / 9 = 0.18518518518518517 K
C.fromBase(0.18518…) = 0.18518… - 273.15 = -272.96481481481485 °C ← 精度在这里被换掉了
C.toBase(-272.9648…) = -272.96481481481485 + 273.15 = 0.1851851851851416
R.fromBase(0.185185…) = 0.185185… * 9 / 5 = 0.3333333333332916
第二步是关键。-272.9648… 这个 double 一共只有约 17 位有效十进制数字,而它的整数部分就占掉了 3 位——我们真正关心的那个 0.185,被存成了一个大数的尾部。第三步加回 273.15 时,那 3 位的信息量再也回不来,这就是典型的灾难性抵消(catastrophic cancellation):不是运算写错了,是“用 273.15 附近的差值来表示一个接近 0 的量”这种表示法本身在花掉尾数位。
这也解释了为什么它只在小数值上明显:换算 100 °C 时有效数字都落在整数部分,误差回到 1 ulp 量级;换算 0.333 °R 时才暴露。
显示层刚好盖住它——但余量只有 4 倍
上面那个表的最后一行值得单独说:36674 次往返里,显示出来的字符串没有一个变化过。因为显示层是 12 位有效数字:
return String(Number(n.toPrecision(12)));
12 位有效数字能分辨的相对差异取决于首位数字:首位是 9 时最细,约 ;首位是 1 时最粗,约 。而最差的仿射误差是 。也就是说 formatNumber 确实把它全部吸收了——但在最紧的那一端余量只有 4 倍,不是舒舒服服的几个数量级。如果哪天有人把显示精度提到 14 位有效数字(分辨率 到 ),温度换算的往返就会开始在末位跳字。这不是需要现在动手的事,是需要现在写下来的事。
4. 真正的精度 bug:一个手抄的小数,页面上看得见
上面那些误差全部藏在 12 位有效数字后面,属于“知道就行”的层次。写这篇时抓到的这个不是——它直接印在页面上。
压力换算表里有两个单位是同一个定义的两种写法:毫米汞柱和英寸汞柱。约定俗成的毫米汞柱是精确定义值(取汞密度 13595.1 kg/m³、标准重力 9.80665 m/s²):
英寸汞柱就是同一根汞柱换成精确的一英寸,所以两者之间必须满足 1 inHg = 25.4 mmHg。而表里存的是:
inHg: lin('inch of mercury (inHg)', 3386.3886666667, …),
这个数是从别处抄来的。25.4 × 133.322387415 的精确十进制结果是 3386.388640341,和抄来的那个从第 8 位有效数字起就不一样,相对偏差 7.8e-9。这个量级比上一节讨论的一切都大 3 到 4 个数量级,于是它穿过了 12 位有效数字的显示窗口:
| 换算 | 修复前页面显示 | 正确值 |
|---|---|---|
| 1 inHg → mmHg | 25.4000001975 |
25.4 |
| 29.92 inHg → mmHg | 759.968005908 |
759.968 |
第二行尤其难看:29.92 inHg 是航空高度计的标准海平面气压设定值,是这个单位最常被查的一个数。
同一类的第二处在功率表:
btu_h: lin('BTU per hour (BTU/h)', 0.29307107, …),
0.29307107 是 1055.05585262 / 3600 截断到 8 位的结果,于是功率表的 BTU/h 和能量表的 BTU 在第 9 位互相矛盾——1 BTU/h × 3600 s 比 1 BTU 少了 1.7e-9。
修法是同一条:凡是能由同表另一个因子按精确定义倍数推出来的,就写成那个式子,不要写成小数。
inHg: lin('inch of mercury (inHg)', 25.4 * 133.322387415, …),
btu_h: lin('BTU per hour (BTU/h)', 1055.05585262 / 3600, …),
这么写有三重好处:它自己就是文档(读者直接看到“英寸汞柱 = 25.4 倍毫米汞柱”);它不可能再漂(改了 mmHg,inHg 自动跟上);而且它没有精度代价——下一节说为什么。
两条断言已经进了 CI(e2e/converters.spec.ts):
await page.locator('select').first().selectOption('inHg');
await page.locator('select').nth(1).selectOption('mmHg');
await from.fill('1');
await expect(to).toHaveValue('25.4');
await from.fill('29.92');
await expect(to).toHaveValue('759.968');
顺带一句方法论:这个 bug 不是审阅代码看出来的——3386.3886666667 看起来比什么都像一个精确值,位数还很多。它是写脚本比对出来的:把表里所有“应当等于另一个因子的精确倍数”的关系列出来,逐条算相对偏差。26 条关系里 23 条落在 0 到 2 个 ulp 内(其中 16 条逐位精确),剩下 3 条被打上 DRIFT:两条是上面这两个真 bug,第三条是第 6 节那个不该修的。
5. 写成乘积还是写成小数字面量:看定义是怎么说的
上一节说“写成式子没有精度代价”,这需要证明,因为它不总是成立。乘积要经过两次舍入(先舍入两个因子,再舍入乘积),小数字面量只经过一次。少一次舍入通常更好。
先看 inHg 这一例:
精确十进制积 : 3386.388640341
25.4 * 133.322387415 = 3386.3886403410002 ← 同一个 double
字面量 3386.388640341 = 3386.3886403410002
两种写法落在同一个 double 上,所以这里写乘积是纯赚:得到文档和防漂移,不付任何代价。
再看反例。金衡盎司的定义是 480 格令,而格令是精确的 64.79891 mg:
字面量 0.0311034768 : 0.031103476800000002
480 * 0.00006479891 : 0.031103476799999998 ← 比字面量差 1 个 ulp
这次写乘积反而更差。原因在于金衡盎司自己就有一个精确的短十进制形式:国际标准直接写 1 oz t = 31.1034768 g。当定义值本身是个短小数时,这个小数就是定义,一次舍入必然优于两次。
所以规则不是“总写式子”也不是“总写小数”,而是:照定义的表述方式写。
- 标准说“1 oz t = 31.1034768 g” → 写
0.0311034768 - 标准说“英寸汞柱就是英寸高的同一根汞柱” → 写
25.4 * 133.322387415
同一条规则的第三种形态是分数写成分数。市制单位是精确的分数关系(1 米 = 3 市尺),所以表里是这样:
zhang: lin('zhang (市丈)', 10 / 3, '市丈 (10/3 米 ≈ 3.333 米)', '市丈'),
chi: lin('chi (市尺)', 1 / 3, '市尺 (1/3 米 ≈ 0.3333 米)', '市尺'),
cun: lin('cun (市寸)', 1 / 30, '市寸 (1/30 米 ≈ 3.333 厘米)', '市寸'),
fen_len: lin('fen (市分)', 1 / 300, '市分 (1/300 米 ≈ 3.333 毫米)', '市分'),
mu: lin('mu (市亩)', 2000 / 3, '市亩 (2000/3 平方米 ≈ 666.67 m²)', '亩'),
1 / 3 交给编译器算,得到的是最接近 1/3 的那个 double。手写 0.3333333333 得到的是最接近 0.3333333333 的 double——两者差 3.3e-11,比本文讨论的一切浮点误差都大两个数量级。二进制单位同理,写 1024 ** 4 而不是 1099511627776:前者不可能手抖,后者可能。
6. 哪些因子是精确的,哪些本来就是近似——以及一条不该修的“矛盾”
上面那条“能推就推”的规则有个前提:得先知道哪些关系是定义,哪些只是历史上的近似等式。这两者在表里长得一模一样,都是一个小数。
精确到最后一位的因子
英美单位现在全部通过国际单位制定义,所以它们是精确的有限小数,不是测量值:
| 关系 | 精确值 | 来源 |
|---|---|---|
| 1 yd | 0.9144 m | 1959 年国际码磅协定 |
| 1 in | 0.0254 m | 同上(= 1/36 yd) |
| 1 lb | 0.45359237 kg | 同上 |
| 1 gr(格令) | 0.00006479891 kg | 同上(= 1/7000 lb) |
| 1 oz t(金衡盎司) | 0.0311034768 kg | = 480 gr |
| 1 ct(克拉) | 0.0002 kg | 1907 年国际度量衡大会 |
| 1 nmi(海里) | 1852 m | 1929 年国际水文会议 |
| 1 AU(天文单位) | 149597870700 m | IAU 2012 决议 B2 |
| 1 ly(光年) | 9460730472580800 m | = c × 儒略年 |
最后一行值得展开,因为它是“精确”的一个好例子:真空光速自 1983 年起是定义值 299792458 m/s(米反过来由光速定义),儒略年是定义的 365.25 天 = 31557600 s。两个定义值相乘,得到的 15 位整数是精确的:
299792458 * 31557600 = 9460730472580800
这个数小于 ,所以 double 能逐位精确地持有它——表里就直接写成 9.4607304725808e15。
2019 年国际单位制修订把这套逻辑推到了极致:千克不再由巴黎的那块铂铱合金圆柱体定义,而是通过固定普朗克常数 J·s 来定义;秒由铯 133 的超精细跃迁频率 9192631770 Hz 定义。所以“1 lb = 0.45359237 kg”这行小数不会再变——它两端都锚在定义上了。
本来就是近似的因子
同一张表里另外一些数是货真价实的近似值,写多少位都不会变得更“对”:
mach: lin('Mach (Ma, 15°C海平面)', 340.29, …), // 声速随温度和高度变,这是一个工况
mo: lin('month (mo, 30.44d)', 2629800, …), // 平均月长,没有哪个月是这么长
mmHg: lin('millimeter of mercury …', 133.322387415, …), // 精确,但基于约定的汞密度
mach 的标签里写了 15°C海平面,mo 写了 30.44d——近似值必须把自己的前提写在标签里,否则用户会以为它是定义值。这是这类工具最容易偷懒的地方:把一个工况值印成一个通用常数。
那条不该修的矛盾:1 atm ≠ 760 mmHg
第 4 节提到扫描脚本报了三处 DRIFT,第三处是:
DRIFT pressure atm = 760 mmHg stored=101325 derived=101325.01443540001 rel=1.42e-7
这一处不能修。 每本中学物理课本都印着 1 atm = 760 mmHg,但这两个单位后来各自拿到了独立的精确定义:
- 标准大气压:精确 101325 Pa(1954 年第 10 届国际计量大会)
- 毫米汞柱:精确 133.322387415 Pa(约定汞密度 13595.1 kg/m³ × 9.80665 m/s² × 0.001 m)
两个精确定义相乘除不会刚好对上:760 × 133.322387415 = 101325.0144354 Pa。所以本站如实显示:
1 atm → 759.999891726 mmHg
这个 759.999891726 看起来像个 bug,其实是唯一诚实的答案。要“修”它只有两条路,都是错的:把 atm 改成 101325.0144(那就不再是标准大气压的定义值),或者把 mmHg 改成 101325/760 = 133.32236842…(那就不再是约定毫米汞柱)。真实世界里这两个单位就是差 1.4e-7,教科书那条等式是四舍五入过的。
所以代码里留了注释,防止后来的人(包括未来的我)看到 759.999891726 就顺手“修”掉:
// Exactly 101325 Pa by definition (10th CGPM, 1954) — *not* 760 mmHg. Both
// units later got independent exact definitions, so the identity every
// textbook prints is off by 1.4e-7: this table converts 1 atm to
// 759.999891726 mmHg, which is the honest answer. Do not "round" either one
// to make them meet.
一个换算器的正确性标准不是“结果好看”,是“每个因子都能追到它的定义”。 两者冲突时,输出难看的那个。
7. 显示层:12 位有效数字,和数据换算器逼出来的一个例外
因子对了、算术对了,还有最后一道关:怎么把 double 变成字符串。这个函数被计算器、函数绘图、三维曲面和全部 13 个换算页共用:
/** Format a number for display: 12 significant digits, no float noise. */
export function formatNumber(n: number): string {
if (Number.isNaN(n)) return 'undefined';
if (!Number.isFinite(n)) return n > 0 ? '∞' : '-∞';
if (Number.isSafeInteger(n)) return String(n);
const abs = Math.abs(n);
if (abs !== 0 && (abs >= 1e12 || abs < 1e-9)) {
return trimExp(n.toExponential(6));
}
return String(Number(n.toPrecision(12)));
}
最后一行是主策略:保留 12 位有效数字。double 有 15 到 17 位十进制有效数字,砍到 12 位刚好把浮点噪声埋掉——0.1 + 0.2 的 0.30000000000000004 会显示成 0.3,同时留足了任何真实换算需要的精度。这比 toFixed() 好,因为 toFixed 是固定小数位:toFixed(6) 会把 1e-9 显示成 0.000000,把光年显示成 20 位整数加 6 个零。
中间那个 Number.isSafeInteger(n) 的快路径不是原本就有的,是数据存储换算页逼出来的。原实现只有“12 位有效数字 + 超过 1e12 转科学计数法”两条规则,于是:
1 TiB → B 显示 1.099512e+12 实际 1099511627776
2^40 是 double 能逐位精确表示的整数,一个字节不差地存在那里,却被显示策略砍成了 7 位有效数字。而“1 TB 的硬盘为什么在系统里显示成 931 GiB”这类问题,问的就是那个精确的字节数——科学计数法在这里不是简化,是把答案丢了。
Number.isSafeInteger 是这条界线的原则性答案,不是调出来的阈值: 以下的整数 double 全部精确持有,所以完整十进制是无损的; 以上 double 本来就持不住那些位,科学计数法才是诚实的。所以:
| 输入 | 显示 | 为什么 |
|---|---|---|
| 1 TiB → B | 1099511627776 |
,安全整数,完整打印 |
| 1 PiB → B | 1125899906842624 |
,仍在安全区 |
| 1 EiB → B | 1.152922e+18 |
超出 ,double 已经持不住个位 |
这三行都在 e2e/converters.spec.ts 里钉着。
小结:一张换算表的正确性清单
| 错误做法 | 正确做法 |
|---|---|
| 给每对单位存一个转换系数 | 中心辐射式:每个单位只存到基准的一个因子,任意对换算恒为两次舍入 |
| 对照表每张卡片各算一遍 | 全部从同一个 baseValue 派生,一致性不靠自觉 |
用 lin() 硬套温度 |
仿射单位单独写闭包,基准选唯一没有偏移的那个(开尔文) |
| 从别处抄一个多位小数当因子 | 能由同表因子按定义推出的,写成式子(25.4 * 133.322387415) |
| 一律写成式子 | 定义本身是短小数时写字面量(0.0311034768),一次舍入优于两次 |
手写 0.3333333333 |
写 1 / 3、2000 / 3、1024 ** 4,让编译器给最接近的 double |
| 近似值和定义值混在一起 | 近似值把前提写进标签(Mach (Ma, 15°C海平面)) |
看到 759.999891726 就修圆 |
追到定义:两个独立精确定义就是差 1.4e-7,输出诚实的那个 |
toFixed(n) 固定小数位 |
toPrecision(12) 固定有效数字,另给精确整数一条完整打印的快路径 |
| 相信自己读代码能看出因子写错 | 写脚本把“应当成立的定义关系”逐条比对相对偏差 |
最后一行是这次真正的收获。第 4 节那两个 bug 上线了很久,我读那段代码不止一次——3386.3886666667 长得就像一个精确值,位数比周围的因子还多。它是被算出来的,不是被看出来的:26 条定义关系,一条一条比对,DRIFT 自己跳出来。
这和之前几次的教训是同一个:SQL 分词器的死循环靠的是拿裸运算符去撞,随机数生成器的死循环靠的是把范围推过 。代码审阅拦不住边界,只有可执行的检查拦得住。