打开 Python 或者随便什么语言的 REPL,敲一句 0.1 + 0.2,你会得到 0.30000000000000004。我第一次看到这串输出以为是 bug,还煞有介事地重启了解释器。它当然不是 bug:整数在计算机里是精确的,实数从来都是「凑合着表示」。这一章讲的就是这套凑合的规则:整数怎么编码(为什么全世界的机器都选了补码),实数怎么逼近(IEEE 754 的每一个字段都在干什么)。学完这章,浮点比较、整数溢出、unsigned 陷阱这些 bug,你能在写出它之前就闻到味。
进制转换的通法是位权展开: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:写得再长也除不尽。二进制到十进制是精确的(位权展开有限步),十进制到二进制对含小数的数基本注定要舍入,浮点误差的祸根在这里就埋下了。
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。
加法结果装不下了就是溢出,判据很多,最实用的是看符号:两个正数相加得负,或者两个负数相加得正,必溢出(一正一负永远不会溢出,这是补码的又一红利)。
0110 0100 (+100)
+ 0011 0010 (+50)
-----------
1001 0110 (-110?) 正 + 正 = 负,溢出
关键是要分清两套规则。「数学上溢出了」是个客观事实,但不同语言对它的处理分成了两个世界:
a + 1 > a 这类判断会被优化器直接改成 1,因为「有符号数不会溢出」是它合法的假设。255 + 1 得 0,天经地义。Java 做了不同选择:int 溢出明确是补码回绕,不 UB。Python 的 int 干脆是任意精度,永远不会溢出,代价是每个整数都是一个堆对象,慢(计组课学到这里你会开始理解 Python 为什么慢,这个话题第 10 章还会回来)。
浮点数的思路就是科学计数法:挪小数点,让有效数字永远排在最前面。二进制版本:
以 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'
现在可以完整解释开头那串 0.30000000000000004 了。0.1 和 0.2 存进 double 时都被舍入到各自的最近可表示值,两个近似值相加,结果落在了 0.3 的相邻两个可表示数之间,又舍入一次,恰好落在 0.3 上方最近的那格。每一眨眼的误差都不到 10⁻¹⁶,三个误差叠起来刚好越过了打印 17 位的阈值。
由此得到几条实打实的工程规则:
== 比较,用「差的绝对值小于一个 epsilon」或者 math.isclose。BigDecimal、Python 的 decimal),第 10 章对比语言时还会提到这一点。1000 0000 当补码读出来,验证它就是 -128。struct.pack('>f', 12.625).hex() 对答案。printf("%.1f\n", 0.1 + 0.2); 和 printf("%.17f\n", 0.1 + 0.2);,解释为什么一行是 0.3,另一行不是。