数字经过程序后为什么会变
数字看起来是系统之间最安全的东西,实际上却是最常悄悄出错的东西。原因在于常用的数字类型是双精度浮点数——一个精度为 53 位的二进制分数——而人们真正在意的值是十进制小数和很长的标识符,这两样它都存不精确。更麻烦的是,显示环节会把误差四舍五入掉,所以出错时悄无声息。
十进制小数在二进制里并不存在
三分之一没有精确的十进制写法:你写下 0.3333,然后在某处停手。在二进制里,十分之一遇到的是同样的事。最接近 0.1 的存储值是 0.1000000000000000055511151231257827021181583404541015625,最接近 0.2 的那个同样略有偏差,于是两者相加就落到了一个会被打印成 0.30000000000000004 的值上。
这不是某种语言的缺陷。凡是使用同一种 64 位格式的环境,给出的答案都一样,因为这个答案是格式本身的性质。在二进制里精确的,只有整数以及分母是 2 的幂的分数,所以二分之一和四分之一表现完美,而十分之一永远不会。
超过九千万亿之后,整数不再是连续的
双精度能精确保存到 2 的 53 次方,也就是 9007199254740992。再往上一个就完全无法表示:你给它 9007199254740993,它会不声不响地还你 9007199254740992。越过这个点,能表示的整数间隔变成 2,再往后变成 4,依此类推。
而这个门槛正好落在一类常见数据的中间。六十四位的标识符——数据库主键、大型平台的消息与帖子 ID、雪花式 ID——日常就会超过它,于是一个经过双精度的 ID 出来时变成了旁边那个数:看上去仍然像个 ID,却谁也指不到。
这件事通常发生在 JSON 上
JSON 本身并不限制数字能有多大;格式是文本,每一位数字都在那里。丢掉它们的是解析器,因为多数解析器一边读一边就把每个数字变成双精度。所以损坏发生在接收方,而发送方的日志看起来完美无缺。
务实的规则是:把大的标识符当字符串传。ID 不是数量——你从不会把两个 ID 相加——所以给它加上引号不会损失任何东西,而加引号是唯一能在任意解析器之间往返后仍然存活的做法。如果你改不了报文,至少把收到的 ID 拿去和原始文本比对,而不是和解析后的数字比对。
钱是它咬人的另一个地方
价格是十进制小数,所以上面所有问题它都继承了:十分之一不精确,十分之一的三倍不等于十分之三,一长串求和会以分的零头一点点漂移,最终四舍五入成看得见的差额。请把钱存成以最小单位计的整数,或者放进为此设计的十进制类型里。
舍入是另一个决定,人们以为它天下通用,其实不是。逢半进位,和把半数舍入到最接近的偶数,这两种做法在会计里都有很长的历史,而不同语言和不同表格软件选的默认值并不一样——这就是两个在每一项输入上都一致的系统,却在总额上对不上的原因。
显示把损伤藏了起来
打印一个双精度值时,通常显示的是能被读回成同一个值的最短文本,所以一个略有偏差的数字,往往会被打印成你期待的那个干净数值。两个数字可以显示得一模一样,却通不过相等判断——这类 bug 之所以让人觉得像闹鬼,正是因为这种情形。
所以要在同一个层面上比较。想看真正存下来的值,就用十七位有效数字打印,或者转成十进制;想比较两个计算结果,就按数值大小设定一个容差,而不是要求完全相等。
写不够位数,一来一回就会丢
把双精度转成文本再转回来,只有在文本带足十七位有效数字时才是精确的;32 位单精度对应的位数是九。任何为了好看而格式化成小数点后六位的处理——一行日志、一次 CSV 导出、一个配置文件——都已经丢掉了值的一部分,读回来会得到一个和起点不同的数。
刻意降低精度也是一样。32 位单精度只保存大约七位有效数字,所以把一个双精度值塞进去再取出来,0.1 会变成 0.100000001490116119384765625。这对游戏里的坐标没问题,对一个你接下来要累加一百万次的测量值就有问题。
数字看起来不对时该查什么
别等被吓一跳,主动去试那些边界。让 0.1 加 0.2 走一遍这条链路,看看多出来的位数会不会冒出来;发一个 9007199254740993,看它是否原样回来;发一个有效数字超过十七位的值,看看还剩下什么。
发现对不上时,先分清是哪一步丢了信息:是显示,是解析器,是转成单精度,还是运算本身。每一种的修法都不同——多打印几位、给 ID 加引号、换更宽的类型、改用整数——用错了方法,问题原封不动,还变得更难看见。