进制、字节单位,以及那些人们争论不休的单位

十六进制如何对应到二进制位,为什么 1 TB 的硬盘显示成 931 GB,0.1 加 0.2 到底等于多少,以及舍入、百分比和区域格式如何悄悄改变一个数字。

写下来的数字携带着两样东西:一个值,以及一整套关于该如何读它的假设。0xFF2550o37711111111 是同一个量的四种记法。太字节和太比字节相差百分之十。1,234 在伦敦大约是一千,在柏林大约是一。多数数值方面的 bug 来自那些假设,而不是来自算术。

二进制、八进制、十进制与十六进制

所有位置计数法的运作方式都一样。每一位的权重是它右边那一位的基数倍,数字从零取到基数减一。十六进制需要六个额外的数字,于是它借用 AF 表示十到十五。

十六进制被用于字节和颜色,是因为 16 是 2 的 4 次方,所以一位十六进制正好是四个二进制位,一个字节正好是两位十六进制,两者之间不会有进位。

0 0000   4 0100   8 1000   C 1100
1 0001   5 0101   9 1001   D 1101
2 0010   6 0110   A 1010   E 1110
3 0011   7 0111   B 1011   F 1111

这张表就是全部的转换规则。从十六进制到二进制,把每一位换成它的四个二进制位:0x2F 变成 0010 1111。反过来,从右往左把二进制位四个一组分开,左侧不够就补零,再对着表把每一组读出来。

要转十进制,就用位权。0x2F2 x 16 + 15,也就是 47。反方向则反复除以 16,再自下而上读余数:

200 / 16 = 12 remainder 8   -> 8
 12 / 16 =  0 remainder 12  -> C
200 decimal = 0xC8

八进制每一位打包三个二进制位,这就是 Unix 权限写成 755 的原因:每一位对应一组 rwx71115101。像 #FF8800 这样的 CSS 颜色是三个字节,每个通道一个,各自的范围是 0 到 255,而三位简写 #F80 的展开方式是把每一位重复一遍,而不是补零。数制转换器一次覆盖这全部四种进制。

二进制补码,以及取值范围是从哪来的

一个字节能容纳 256 种不同的位模式。按无符号读,它们是 0 到 255。按有符号读,最高位标记负数那一半,而一个负值就是它的位模式减去 256。这套方案叫二进制补码,之所以选它,是因为这样普通的二进制加法对有符号和无符号的值是完全一样的。

要对一个数取负,就把每一位取反再加一。取值范围之所以不对称,是因为零占掉了非负那一半 128 个位置中的一个,所以一个有符号字节的范围是 -128 到 127。在范围顶端加一,就会绕回底端:

  0111 1111    127
+ 0000 0001      1
= 1000 0000   -128

在多数语言里,什么警告都不会有;那个值就这么出现在了范围的另一端。同样的绕回发生在 32 位上,就是 2038 问题,有符号 32 位的 Unix 时间戳在 2147483647 秒处走到头。无符号 16 位给出 0 到 65535,这正是 TCP 端口号到此为止的原因,也是随机端口生成器从这个范围里取值、同时避开 1024 以下保留端口的原因。

千字节、千比字节,以及那些“消失的”千兆字节

存储厂商按十的幂计数。操作系统历来按二的幂计数,却给结果贴上十进制的前缀,争论就是从这里开始的。二进制前缀(KiB、MiB、GiB)的存在就是为了消除这种歧义。

十的幂十进制值二进制前缀二进制值差距
kB, 10^31,000KiB, 2^101,0242.4%
MB, 10^61,000,000MiB, 2^201,048,5764.9%
GB, 10^91,000,000,000GiB, 2^301,073,741,8247.4%
TB, 10^121,000,000,000,000TiB, 2^401,099,511,627,77610.0%
PB, 10^1510^15PiB, 2^501,125,899,906,842,62412.6%

一块按 1 TB 出售的硬盘装着 10^12 个字节。用它除以 2^30 得到 931.32,于是 Windows 用十进制的标签去标一个二进制的量,报告出大约 931 GB。什么都没有丢;只是同样的字节被另一个数除了一遍。macOS 和多数 Linux 工具现在报告的是十进制的 GB,与包装盒上一致。内存容量则确实是二进制的,所以 16 GB 的内存真的就是 16 GiB。

网络速率按位而不是按字节计数,而且一律是十进制。一条 100 Mbps 的线路每秒承载 100,000,000 位,也就是在扣除开销之前每秒 12.5 MB。协议开销要占掉百分之几,所以每秒 11 到 12 MB 是现实的上限。把宣传的兆位数字除以八是快速的心算办法,而单位换算器涵盖了各种存储单位。

浮点数,以及为什么钱不能用浮点数

二进制小数只能表示二分之一、四分之一、八分之一之类的和。十分之一没有精确的二进制形式,就像三分之一没有精确的十进制形式一样,所以按 IEEE 754 双精度存储的 0.1 略大于十分之一。把两个这样的近似值相加,误差就浮出水面:

0.1 + 0.2 = 0.30000000000000004

双精度有 53 位有效数字位。这让 2^53(9,007,199,254,740,992)以内的每一个整数都能被精确表示,任何分母是 2 的幂的分数也一样。超过 2^53 之后,可表示的值之间的间隔变成 2,于是 2^53 + 1 等于 2^53。这就是为什么经由 JSON 传输的 64 位数据库标识符应该以字符串的形式传送:JavaScript 的数字就是双精度浮点数,一个 19 位的 ID 会悄悄丢掉末尾几位。

对于钱,请存储整数形式的最小货币单位(便士、分),或者使用一种以十为基数工作的十进制类型。绝不要用相等来比较浮点数;应该拿差值去和一个很小的容差比较。表达式计算器同样在双精度浮点数上工作,也继承了同样的限制。

舍入,以及舍入两次

四舍五入把恰好一半的情况向远离零的方向进位。银行家舍入(也叫“四舍六入五成双”)把恰好一半的情况送到最近的偶数位上,这消除了四舍五入在一长列数字上累积出的向上偏差。它是 IEEE 754 的默认规则,也是若干会计准则里的规定。

四舍五入银行家舍入
0.510
1.522
2.532
3.544
-0.5-10

只舍入一次,而且要从原始值出发。把 2.4449 舍入到三位小数得到 2.445,再把它舍入到两位得到 2.45,而把原始值直接舍入到两位得到 2.44。经由中间列层层串联的舍入,是合计数差一分钱的常见来源。还有一个相关的意外:在多数语言里 2.675 会舍入成 2.67,因为存储下来的双精度值是 2.67499999999999982。

百分比与百分点

一个比率从 4% 变到 5%,是上升了一个百分点,或者说相对上升了 25%。两种说法都对,但必须把单位说清楚。

百分比变化不会互相抵消。先下跌 50% 再上涨 50%,剩下的是 75 而不是 100,因为第二个百分比是在一个更小的基数上取的。要从 p 的跌幅中恢复,需要 p / (1 - p) 的涨幅,所以跌 50% 需要涨 100%,跌 80% 需要涨 400%。连续的变化是相乘的,构成的是几何数列而不是求和,而这正是数列生成工具所建模的那种增长。

从旧值到新值的变化是 (new - old) / old x 100,这也是百分比计算器作为差异报告出来的那个数。去掉 20% 的增值税意味着除以 1.2,而不是减去 20%。把规模不同的各组的百分比取平均,除非做加权,否则得到的总体数字是错的,而求和与求平均工具在均值旁边显示中位数,能让这种偏斜显现出来。

按区域格式化的数字

格式化是一个展示层面的决定,而且在不知道区域设置的情况下它是不可逆的。1.234 在英国是一点几,在德国是一千。1,234 则正好相反。有些区域用空格或者撇号来做分组。

区域1234567.89
en-GB, en-US1,234,567.89
de-DE1.234.567,89
fr-FR1 234 567,89
de-CH1'234'567.89
en-IN12,34,567.89

印度的分组方式是第一个分隔符在三位之后,此后每两位一个,于是用的是十万(lakh)和千万(crore),而不是千和百万。把格式化后的字符串解析回数字之所以是有损的,正是这个原因,所以存储和传输时请使用未经格式化的值,并以点作为小数分隔符,只在展示的那一刻才做格式化。当一个数字必须让读者毫无歧义时,用数字转文字工具把它拼写出来是支票和合同上的惯常做法。罗马数字则完全在这套体系之外:它没有位权、也没有零,所以罗马数字转换器处理的是一种计数记号,而不是一种进制。