浮点数:实数在代码里的残影
前四页造出的
是一个不可数集,每个元素要么是无穷不循环的小数展开,要么是一个 Cauchy 列的等价类。这两种东西都装不进有限的存储。代码里那个叫 number 的类型是另一种对象,本页量清它到底是什么,以及由此产生的几条必须知道的行为。
1 · 可表示数的有限性
IEEE 754 的双精度格式(binary64)用 个二进制位存一个数: 位符号、 位阶码、 位尾数。位数有限,取值就有限。
定义 1.1(可表示数) binary64 的每个有限取值都可写成 ,其中 是不超过 的非负整数、 是整数。这样的数都是有理数,且分母是 的幂。
由此立刻得到两条结论。第一,任何无理数都不是可表示数:、、
在代码里存的一律是某个邻近的二进制有理数。第二,可表示数的个数是可以数清的——实测 FINITE_DOUBLE_COUNT 为
,约
个,含
无穷与 NaN 之外的全部取值。
这些数在数轴上的分布极不均匀。实跑 countDoublesIn:区间
内有
个可表示数,而
内有
个,是前者的
倍,占全部有限可表示数的
。四分之一的双精度值挤在
与
之间,另有四分之一挤在
与
之间;剩下一半铺满整条数轴的其余部分。
2 · 十分之一在二进制下的循环节
这个十进制小数不是可表示数。理由与 有理数与小数展开 §3 完全同源,只是换了底:
的分母含因子
,而
的幂里没有
,所以
在二进制下是循环小数。实跑 binaryExpansion(1, 10) 得到
,前置段一位、循环节四位——那个
就是
在模
下的乘法阶。
存进 double 时循环节被截断,得到的是最靠近 的那个可表示数。它的精确值写得出来,因为分母是 的幂:
位小数,比
略大。同理 0.2 存进去的值也略大,两者相加再舍入,落到的可表示数不是存 0.3 时落到的那一个:
例 2.1 0.1 + 0.2 的精确值是
,而 0.3 的精确值是
。两个数的位模式相差
,也就是相邻的两个可表示数,所以 0.1 + 0.2 !== 0.3。打印时看不出来是因为 Number.prototype.toString 输出的是能唯一还原该值的最短十进制串。
同一个原因让加法失去结合律。实测 (0.1 + 0.2) + 0.3 得
,而 0.1 + (0.2 + 0.3) 得
:每一步各自舍入,先后次序不同则舍入的位置不同。把 0.1 累加十次得到的是
,累加一百次得到
,与
相差
。误差随累加次数增长,这是数值求和要先排序或走 Kahan 补偿的原因。
3 · 间距与判等容差
相邻两个可表示数之间的距离叫 ulp(unit in the last place)。它不是常数:尾数位数固定,绝对间距随量级翻倍。
| 取值 | 相邻间距 | 说明 |
|---|---|---|
| 比 处的间距还小一个量级 | ||
等于 Number.EPSILON,即
|
||
| 绝对容差 在此处已经不起作用 | ||
| 此后整数不再全部可表示 | ||
| 间距本身已是天文数字 |
Number.EPSILON 是
处的间距,不是「浮点的误差上限」。这个误读会直接写出错的判等:绝对容差
在
附近够用,到
处已经小于该处的间距
,判等退化成 ===。反方向的坑同样存在:相对容差在
附近失效,因为两个数的量级都趋于零时相对差可以任意大。
建议 · 判等取绝对与相对两条容差的较宽者:|a − b| <= absTol || |a − b| <= relTol * max(|a|, |b|)。绝对容差管
附近,相对容差管大量级。closeTo 就是这么实现的,单测里
与
这一对(相邻的两个可表示数)在绝对容差
下判为不等、在相对容差
下判为相等,正好把两条各自的适用范围划出来。
这条界值得单独记。超过它之后间距是
,2 ** 53 + 1 === 2 ** 53 成立,奇数一律存不下。这是 JavaScript 里 Number.MAX_SAFE_INTEGER 取
的来历,也是数据库的 64 位整数 ID 传到前端要用字符串的来历。
4 · 精确算术的代价
要精确,就得放弃「一个数一个格子」这件事。用 BigInt 的分子与分母表示有理数,加减乘除全部精确:本系列的 core/completeness.ts 就是这么做的,二分
步之后端点仍是精确分数,分母
位十进制数,一位不差。
代价有两处。一是分母会长:那 步里分母按 的幂增长,位数与步数成正比,运算时间随之上升。二是这条路只覆盖有理数——开方与超越函数出不来。 本身写不成分数(见 无理数:反证、递降与超越),所以精确有理算术对 能给出的只是一列越来越窄的区间,给不出那个数。
于是「实数」在代码里只有两种落法:要么放弃精确,取一个邻近的二进制有理数,接受每步舍入;要么放弃「得到一个数」,只保留一个可以任意收窄的区间与一套区间上的运算。前者是 double,后者是符号计算与区间算术。两者都不是 ,而 的完备性正来自那些装不下的对象。
5 · 参考文献
- Double-precision floating-point format. Wikipedia. binary64 的位布局、取值范围与特殊值。https://en.wikipedia.org/wiki/Double-precision_floating-point_format
- Unit in the last place. Wikipedia. ulp 的定义与几种常见约定。https://en.wikipedia.org/wiki/Unit_in_the_last_place
- Machine epsilon. Wikipedia. 机器 epsilon 的两种定义,以及它同 ulp 的关系。https://en.wikipedia.org/wiki/Machine_epsilon
- Goldberg, D. (1991). What every computer scientist should know about floating-point arithmetic. ACM Computing Surveys, 23(1), 5–48.
- Number.MAX_SAFE_INTEGER. MDN. 这条界的来历与它的实际后果。https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER