第 2 章 · 数据的表示与运算

打开 Python 或者随便什么语言的 REPL,敲一句 0.1 + 0.2,你会得到 0.30000000000000004。我第一次看到这串输出以为是 bug,还煞有介事地重启了解释器。它当然不是 bug:整数在计算机里是精确的,实数从来都是「凑合着表示」。这一章讲的就是这套凑合的规则:整数怎么编码(为什么全世界的机器都选了补码),实数怎么逼近(IEEE 754 的每一个字段都在干什么)。学完这章,浮点比较、整数溢出、unsigned 陷阱这些 bug,你能在写出它之前就闻到味。

2.1 进制:先练熟两件事

进制转换的通法是位权展开:1011.1₂ = 1×8 + 0×4 + 1×2 + 1×1 + 1×0.5 = 11.5。但实际干活靠的是两个捷径,建议练到不假思索:

二进制和十六进制直接互译。16 = 2⁴,所以每 4 位二进制对应 1 位十六进制,纯分组,没有任何计算:

1010 1110 0011 0100
  A    E    3    4      → 0xAE34

一个字节正好两位十六进制,这就是为什么内存 dump、报文分析里全是十六进制:它就是二进制的「人类可读版」。看十六进制数 0xFF,你脑子里应该直接浮现 1111 1111。

十进制整数转二进制:除 2 取余,余数逆序。拿 45 练一遍:

45 ÷ 2 = 22 余 1
22 ÷ 2 = 11 余 0
11 ÷ 2 = 5  余 1
 5 ÷ 2 = 2  余 1
 2 ÷ 2 = 1  余 0
 1 ÷ 2 = 0  余 1      → 101101₂ = 45

十进制小数转二进制是乘 2 取整、整数顺序读。这里藏着本章第一个坑:0.1 这样转下去是 0.000110011001100...,无限循环。十进制的 0.1 在二进制世界里的地位,相当于十进制的 1/3:写得再长也除不尽。二进制到十进制是精确的(位权展开有限步),十进制到二进制对含小数的数基本注定要舍入,浮点误差的祸根在这里就埋下了。

2.2 补码:负数怎么存,减法怎么免

8 位能摆出 256 个编码,无符号数把它们全分给 0 到 255。要表示负数,就得切一半:最高位当符号,正数 0~127。剩下的问题是 128 到 255 这段编码分给负数时,怎么分最聪明。

原码的直觉是「最高位放符号,其余放绝对值」:-5 就是 1000 0101。人读着舒服,硬件用着难受:加法得先看符号再决定加减,1 + (-1) 按这套规则得 1000 0010,即 -2,还得为它单独修逻辑;更要命的是零有俩,0000 0000 和 1000 0000 都表示零,比较相等时得特判。

补码的规则:正数照常,负数 = 对应正数取反加一。-5 就是 0000 0101 取反得 1111 1010,加一得 1111 1011。

它赢在两条,每条都直接砍硬件成本:

第一,减法就是加法。A - B = A + (-B 的补码),进位扔掉即可。验证一下 5 - 3:

  0000 0101   (+5)
+ 1111 1101   (-3 的补码)
-----------
1 0000 0010   进位 1 丢弃 → 0000 0010 = +2 ✓

硬件从此只需要一套加法器,不需要减法器。这是模运算的自然结果:8 位寄存器满 256 归零,-3 和 253 在模 256 的世界里是同一个东西,加 253 等于减 3。

第二,零唯一。0000 0000 取反加一还是 0000 0000(加一使最低位进位环回)。于是 1000 0000 这个编码空出来了,它被定义为 -128。所以 8 位补码范围是 -128 到 127,不对称:负数比正数多一个。写 abs(INT_MIN) 会原样返回 INT_MIN,根子在这里。

补码还有个反着算也成立的优雅性质:对 -x 取补码得回 x。手算 -(-128) 会发现还是 1000 0000,溢出的必然。

顺带记一个手速技巧:求补码不用「取反加一」两步,从右往左找到第一个 1,它右边的位照抄,左边的全取反。0000 0101 → 右起第一个 1 在第 0 位,右边没位,左边全反 → 1111 1011。

2.3 溢出:补码的两个世界

加法结果装不下了就是溢出,判据很多,最实用的是看符号:两个正数相加得负,或者两个负数相加得正,必溢出(一正一负永远不会溢出,这是补码的又一红利)。

  0110 0100   (+100)
+ 0011 0010   (+50)
-----------
  1001 0110   (-110?)   正 + 正 = 负,溢出

关键是要分清两套规则。「数学上溢出了」是个客观事实,但不同语言对它的处理分成了两个世界:

  • 有符号整数溢出,C 里是未定义行为。UB 意味着编译器可以假设它永不发生(第 2 章的 C 指针课程讲过这个概念的后果)。真实案例:a + 1 > a 这类判断会被优化器直接改成 1,因为「有符号数不会溢出」是它合法的假设。
  • 无符号溢出是定义好的模 2ⁿ 回绕。255 + 1 得 0,天经地义。

Java 做了不同选择:int 溢出明确是补码回绕,不 UB。Python 的 int 干脆是任意精度,永远不会溢出,代价是每个整数都是一个堆对象,慢(计组课学到这里你会开始理解 Python 为什么慢,这个话题第 10 章还会回来)。

2.4 IEEE 754:实数的妥协方案

浮点数的思路就是科学计数法:挪小数点,让有效数字永远排在最前面。二进制版本:

(−1)s×1.f×2‘e−bias‘(-1)^s \times 1.f \times 2^`{e - \text{bias}}`

以 32 位 float 为例,32 位切成三段:1 位符号,8 位指数,23 位尾数。double 是 1 + 11 + 52。

几个设计都值得停下来想一下为什么:

隐藏位。规格化二进制数的整数部分必然是 1(小数点挪到第一个 1 之后),既然必然,就不存了,白赚一位精度。所以 float 的尾数实际是 24 位,约 7 位十进制有效数字;double 53 位,约 15 到 16 位。这也解释了为什么 float 打印 16777217 会变成 16777216:24 位尾数数不到 2²⁴+1。

指数偏置。指数要表示负数,但浮点比较想走无符号整数比较的快路(复用整数比较电路)。把真指数加 127 再存,float 的指数域变成无符号 0~255,直接比大小就是按真值比大小。偏置值(bias)这个名字的来历。

特殊值。指数全 1 是信号位:尾数全 0 表示 ±∞,尾数非 0 表示 NaN。指数全 0 是另一头:0,以及非规格化数(把隐藏位换成 0,表示比最小规格化数更贴近零的数,让下溢是「渐进的」而不是突然掉到零)。0.0 / 0.0 得 NaN,而 NaN 跟任何值比较(包括它自己)都是 false,这个性质经常变成埋伏。

手解一个数,把 0.15625 按 float 编码走一遍,这一套走通,IEEE 754 就真懂了:

0.15625 = 1/8 + 1/32 = 0.00101₂
规格化: 1.01₂ × 2⁻³
符号 s = 0
指数 = -3 + 127 = 124 = 0111 1100₂
尾数 = 01 后面补零(隐藏位不存) = 0100 0000 0000 0000 0000 000
编码: 0 01111100 01000000000000000000000 = 0x3E200000

用工具验证,结果分毫不差:

>>> import struct
>>> struct.pack('>f', 0.15625).hex()
'3e200000'

2.5 回到 0.1 + 0.2

现在可以完整解释开头那串 0.30000000000000004 了。0.1 和 0.2 存进 double 时都被舍入到各自的最近可表示值,两个近似值相加,结果落在了 0.3 的相邻两个可表示数之间,又舍入一次,恰好落在 0.3 上方最近的那格。每一眨眼的误差都不到 10⁻¹⁶,三个误差叠起来刚好越过了打印 17 位的阈值。

由此得到几条实打实的工程规则:

  • 浮点数永远别用 == 比较,用「差的绝对值小于一个 epsilon」或者 math.isclose。
  • 金额计算别用浮点。用整数(以「分」为单位)或者十进制库(Java 的 BigDecimal、Python 的 decimal),第 10 章对比语言时还会提到这一点。
  • 累加大量浮点数时,误差会滚雪球,大数吃小数(1e8 加 1.0 加一百次可能一点没变),这是真的会在数值计算里翻车的坑。

练习

  1. 手算:把 -64 转 8 位补码;再把 1000 0000 当补码读出来,验证它就是 -128。
  2. 手算:把 12.625 按 float 编码(提示:12.625 = 1100.101₂)。用 struct.pack('>f', 12.625).hex() 对答案。
  3. 动手:在你机器上跑 printf("%.1f\n", 0.1 + 0.2); 和 printf("%.17f\n", 0.1 + 0.2);,解释为什么一行是 0.3,另一行不是。
  4. 思考:为什么 IEEE 754 要专门设计 NaN,而不像整数那样让除零直接崩溃?提示:想想科学计算程序一跑几小时,中途崩了意味着什么。