计算机最大的一个特征就是能计算,这一章来了解它会算什么,又是怎么算的。
计算的对象是数字,在计算之前需要将数字表示成计算机能够处理的形式。最基础的运算就是四则运算——加减乘除。电路能够实现这些运算,这一章先不去考究具体实现运算的方式。电路元器件是有限的,只能表示和处理有限长度或精度的数据,计算机程序就要在这些限制之上正确地完成计算。深入理解计算的规则,就能够理解和解释编程中遇到的一些常见错误和编程疏忽导致的重大事故发生的原因。
第二章,表示和处理信息
人类有十根手指,自然而然会用手指来完成计数,天生适合使用十进制。对于机器而言,二值信号——有和无、高和低、顺时针和逆时针等非常好区分,因而计算机更适合使用二进制。
每一个二值状态称为 1 位(bit,比特),单个比特没有太大的用处,但将几个比特组成一组,就可以表示很多状态。一个比特只能表示高或低 2 种状态,两个比特组成在一起就能表示 4 种状态,n 个比特就可以表示 2 的 n 次方种状态。给每一种状态标记一个数字(序号),这个数字就可以认为地对应一个符号,可以是字母,也可以是数字。这样的对应关系就是编码,编码可以有很多种方式,像函数一样。
很自然地,人类要将二进制数编码成十进制数。数字可以是正的,也可以是负的,适当的编码方式可以表示正数和负数。进一步地,可以记录小数。计算机系统中,最重要的三种数值类型是无符号数、有符号数和浮点数。
- 无符号数(Unsigned),表示大于或等于 0 的数值。
- 有符号数(Signed),计算机系统中一般用补码(Two’s-complement)来表示有符号数——正数、负数和零。
- 浮点数(Floating-point),是用以 2 为底数的科学记数法来表示的实数。
计算机系统中,使用有限的比特来编码一个数字,所以能够编码的数值范围是有限的。如果经过加减乘除后的结果超出了编码能够表示的范围,就会产生溢出(Overflow),这也是一些错误产生的根源。
计算机的整数法算术运算也满足交换律和结合律,虽然几个因数相乘的积可能溢出而得到一个负数,但通过交换或结合的方式修改表达式执行的次序,得到的结果相同。 计算机的浮点数运算就没有这么和谐了。浮点数运算没有结合性,因为浮点数只能表示有限的精度,改变运算次序可能会丢失精度而造成两种计算方式结果不一致。
无符号数和有符号数统称为整数,整数能够表示的范围有限,但计算结果相对准确;浮点数能够在与整数使用相同数量比特编码条件下,表示更大范围的数值,但结果可能不精确,是一个近似值。
计算机可以采用不止一种编码数字的规则,运算也不局限于对单个数字,也可以操作表示这个数字的编码。
信息的存储
信息由比特组成,计算机访问信息时通常以 8 位比特作为一组来访问。每 8 位比特组成一个字节(Byte),字节是内存中可寻址的最小单元。至于为什么是 8,可能是历史的偶然。
对于机器来说,内存就是一块很大的字节数组,或称为虚拟内存(Virtual memory)。每一个字节都由一个唯一的数字标志,这个数字就是这个字节的地址。程序能访问的整块内存地址的集合称为虚拟地址空间(Virtual address space)。之所以称作“虚拟”,是因为这块内存空间是一个程序逻辑上的映像,这块空间实际存储的位置可能物理内存(DRAM)、磁盘或其他硬件设备。虚拟地址空间是操作系统为程序提供的一个看上去庞大而又连续的字节数组,但并非所有地址都可使用。在程序运行时,虚拟地址空间会被分成几个部分以便于管理。C 语言中的指针的值就表示一个内存地址,类型系统会将指针指向特定类型数据的第一个字节的地址。将虚拟地址转换到物理地址的过程由操作系统通过特定的硬件来完成。
十六进制记法
1 个字节里的 8 个比特可以表示的二进制范围为 00000000
至 11111111
,对应十进制为 0 到 255。很多时候,用二进制表示一个数字会显得十分冗长,而用十进制表示就不能快速对应字节中的比特值。因而,在计算机程序中通常会使用十六进制表示一些数值。十六进制记法显得简洁而又能让人够快速对应数值中的每一个比特。一个字节的范围用十六进制表示就是 00
到 FF
,每一个十六进制符号都对应 4 个二进制值。
当一个整数刚好是 2 的 n 次方时,其二进制记法就是 1 后面加 n 个 0。由于 4 个 0 对应一个十六进制的 0,可以快速将后面的 0 转为十六进制,记 \(n = i + 4j,\,0\le i \le 3\)。\(j\) 表示十六进制 0 的个数,\(i\) 的值可以对应十六进制值的最高位,0 对应 1 ,1 对应 2,2 对应 4,3 对应 8。例如,2048 是 2 的 11 次方,\(n = 11 = 3 + 4\times2\),将其转成十六进制就是 0x800
。
将十进制数转到十六进制数,只要将十进制数连续除以 16 并取余,直到商为 0。先得到的余数写在低位,后得到的写在高位。将较大的十进制数转换到十六进制时,可以求助搜索引擎,例如,搜索“123 in hex”。
数据长度
字长,是计算机的一个重要参数。字长表示指针的长度(更准确地说,字长是 CPU 一次可以处理数据的长度)。指针代表了一块数据所在的内存地址,编码虚拟地址空间大小的数据长度也是一个字长,所以字长决定了程序可寻址空间的大小,也决定了主存能使用的最大空间。对于 32 位字长的机器,虚拟地址最大 2^32
字节,也就是 4 GB,64 位则高达 16 EB。
64 位的计算机也可以运行为 32 位机器编译的程序,这种兼容性称为后向兼容(Backward compatibility)。gcc -m32 prog.c
这条指令可以编译出 32 位的程序,该程序可以顺利地运行在 32 位和 64 位计算机上。而 gcc -m64 prog.c
指令编译出的程序不能在 32 位计算机上运行,只能在 64 位计算机上运行(如果有了 128 位计算机,应该也能在这上面运行。这里指的是在同一架构下的程序)。程序是 32 位或 64 位与运行的机器的字长无关,只与编译这个程序时指定的目标字长有关。
C 语言中的整数和浮点数都可以根据精度需要使用不同的长度来编码。每种类型的数据,在不同的平台上占用的长度并不一定相同,具体由编译平台确定。典型的 32 位和 64 位程序中,int
类型都会占用 4 字节空间,long
类型分别占用 4 字节和 8 字节。为了避免相同类型在不同编译器下使用不同的类型长度,ISO C99 规定了固定长度的类型。例如,显式使用 int32_t
和 int64_t
类型会告诉编译器使用确定的 4 字节和 8 字节来编码对应整数。在不指明类型是否是有符号数时,默认都采用有符号 signed
关键字修饰,除非显式指定 unsigned
关键字。
对程序员来说,要确保程序在不同机器上有相同的表现,保证程序可以移植到不同机器上使用,不依赖于平台和编译器。例如,在 32 位机器比较主流的年代,程序员只需要使用 int
类型就可以存储指针;而在迁移到 64 位平台后,int
类型将不足以存放指针类型。
寻址和字节序
当数据对象会使用多个字节来编码时,需要建立两项约定才能让代码在不同机器间有相同表现。
- 对象地址的表示方式;
- 内存中字节的排列次序(字节序)。
绝大部分机器上,一个占用多个字节的类型对象都会占用连续的地址空间,对象的地址就是这块连续地址空间的最小地址。以一个 32 位长度的 int
类型数字为例,如果变量 x
的地址是 0x100
,这就是说,&x
的值是 0x100
。因为这个类型有 4 个字节,它占用的 4 个内存地址就是 0x100
,0x101
,0x102
,0x103
。每个地址对应的空间中存放一个字节的数据,为了读出这个整数所表示的具体值,有两种不同的读数约定。
如果一个整数占用 \(w\) 位比特,用 \(\left[x_{w-1},x_{w-2},\cdots,x_1,x_0\right]\) 表示其中的每一位值。那么 \(x_{w-1}\) 就是这个数的最高位比特,\(x_{0}\) 是最低位。假定 \(w\) 是 8 的倍数,那么将这些比特按字节分组,可以得到若干组,最高位所在的字节组称作最高字节,最低位所在的字节组称作最低字节,其他组在中间。
一些机器将整数的最高字节放在占用内存的最小地址中,最低字节放在最大地址中,这种字节序称为大端字节序(Big endian)。另一些机器则按相反的次序存放,称为小端字节序(Little endian)。大小端名称取自《格列佛游记》故事中,两个国家敲鸡蛋从大的一端敲还是小的一端敲的争执。
对于特定的机器来说,一旦操作系统确定,系统使用的字节序也确定了。大多数程序不需要考虑字节序问题,只有在程序需要通过网络向其他计算机发送数据时才需要考虑字节序问题。为了避免不同计算机之间字节序不同产生的问题,在网络编程中规定,在网络上传输的数据必须采用大端字节序,接收方根据需要转换成本机使用的字节序。
除此之外,在使用反汇编器读取二进制程序中的机器指令时也需要考虑字节序问题。目标二进制程序的大小端会影响反汇编器生成汇编代码的结果。
在 C 语言中,规避类型系统时,也会因不同机器的字节序差异而得到不同的结果。把任意 C 语言中变量的地址转换成 unsigned char*
类型后,依次打印每一个字节,就可以看到这个类型中按地址从低到高顺序存放的字节值。下面的代码可以将一个整形从低地址开始打印占用的每一个字节中的数据。使用 sizeof
操作符获取类型占用的字节数是个好习惯,它能够得到类型在实际运行平台上占用的字节数,能够帮助代码实现跨平台。
#include <stdio.h>
typedef unsigned char *byte_pointer;
void show_bytes(byte_pointer start, size_t len) {
int i;
for (i = 0; i < len; i++)
printf(" %.2x", start[i]);
printf("\n");
}
void show_int(int x) {
show_bytes((byte_pointer) &x, sizeof(int));
}
表示字符串和代码
C 语言中的字符串是以空(NULL
)为终止的字符数组。字符和值的关系参照 ASCII 码表。
对于程序源代码文件来说,其中保存的代码是字符串。在不同平台上源代码文件不会改变,而当源代码被编译成特定平台上的可执行文件后,可执行文件中的内容会大不相同。可执行文件中不会保留源代码中的信息,只按特定平台的机器指令规则生成。不同平台使用的机器指令不兼容,也采用不同的编码方式。即使是同一个处理器上运行的不同操作系统之间,指令编码约定都可能不同。因此,二进制程序一般不能在不同机器、操作系统上兼容运行。
布尔代数
二进制值能够很好地在计算机上存储和处理,布尔代数将 0 和 1 解释为逻辑上的“真”和“假”,为计算扩展了一大步。在 C 语言中,定义了 4 种布尔代数,分别是:
- 取反,符号
~
,对应逻辑操作NOT
。 - 与,符号
&
,对应逻辑操作AND
。 - 或,符号
|
,对应逻辑操作OR
。 - 异或,符号
^
,对应逻辑操作EXCLUSIVE-OR
。
布尔代数是对单个逻辑值的计算,我们可以将计算扩展到一组比特之上,同时对一组比特做逻辑计算。这一组比特可以称作位向量,向量中包含 0 和 1 的序列,长度为 \(w\)。
信息论创始人香农(Claude Shannon)首次将布尔代数和数字逻辑联系起来。1937 年,21 岁的香农在他的硕士论文中提出,将布尔代数应用于电子领域,能够构建并解决任何逻辑和数值关系。
布尔代数与算术运算有很多相同之处,但也有不同。例如,乘法对加法有分配律,&
对 |
也有分配律;反过来,|
对 &
也有分配律,这和算术运算不同。有 a & (b | c) = (a & b) | (a & c)
和 a | (b & c) = (a | b) & (a | c)
。对于 ^
、&
和 ~
三种位向量上的运算,满足布尔环这一数学形式。布尔环与算术运算有一些相同的特性,例如,每个元素 x
都有其加法逆元 -x
,满足 x + -x = 0
。^
运算类似于加法,对每个元素也有对应的“加法逆元”,只不过其逆元是其本身,即 a ^ a = 0
。此外,^
运算也满足交换律和结合律,也就是异或运算中的元素可以交换顺序,所以 (a ^ b) ^ a = b
。
C 语言中的位运算
C 语言支持位运算,实质是对每一个比特做布尔运算。在做位运算时,最好将数字转换为二进制。利用上面异或的“加法逆元”或称相反数是其本身的性质,可以写出不使用临时变量交换两个数值的函数。
void inplace_swap(int *x, int *y) {
*y = *x ^ *y; /* Step 1 */
*x = *x ^ *y; /* Step 2 */
*y = *x ^ *y; /* Step 3 */
}
位运算通常用来实现掩码操作。掩码是一个若干比特组成的标识,用于从一个字中选择或操作特定的比特。例如标识 0xFF
表示一个字中的最低一个字节。位运算 x & 0xFF
会将 x
的最低一个字节选中,并将其他比特置为 0。~0
会将所有比特置为 1,无论数据的字长是多少字节,而如果用 0xFFFFFFFF
来表示全 1,只能在 int
是 32 位的时候有效,不够通用。
位运算的使用非常需要技巧,技巧的掌握在于充分理解每种位运算的特点。这种技巧就像学习了二叉树的若干特点之后,要在这棵树上完成一些令人眼花缭乱的操作,最后实现一个巧妙而高效的算法。在本章后面有很多位运算练习,可以帮助理解位运算。一些问题可能不用位运算也能解决,而且代码可读性更好,而位运算是直接操作比特,能够更快地完成计算。
这里列举几个看似简单但在应用时不容易想到的规则:
x^0 = x
x^(~0) = ~x
C 语言中的逻辑运算
逻辑运算有三种:
- 与,符号
&&
。 - 或,符号
||
。 - 非,符号
!
。
逻辑运算与位运算有所不同,位运算操作的数值中的比特值,而逻辑运算将数值整体解释为“真”或“假”——所有非零数解释为“真”,零解释为“假”,运算的结果也只有“真”、“假”两种。在对逻辑运算结果取值时,结果为“真”时,值为 1;结果为“假”时,值为 0。
逻辑运算可以看作是位运算在参数值限制在 0 或 1 时的特殊情况。另外,参与位运算的每个数都要经过运算,而逻辑运算使用“懒惰求值”,在逻辑“与”和“或”运算时,不一定要将每个参数求值。
逻辑运算与位运算一样具有一定技巧性,例如,x == y
可以用逻辑运算 !(x^y)
代替。
C 语言中的移位运算
- 左移(Left shift)。
x << k
,会将 x 的高 k 位移出,右边移入 k 位 0。k 的值必须小于 x 的位数,同时不为负数。连续多次移位可以应用交换律。 - 右移(Right shift)。左移易于理解,而右移有两种,逻辑(Logical)右移和算术(Arithmetic)右移,两种移位在处理数值的符号位时有所不同。
- 逻辑右移。与左移类似,高位移入 k 位 0,低位移出 k 位。
- 算术右移。高位移入的 k 位数字与原数最高位相同,低位移出 k 位。算术右移能保留有符号整数的符号位。
需要注意的是,C 标准并没有明确规定右移使用逻辑右移还是算术右移,这意味着假定右移方式的代码可能会遇到兼容性问题。不过,在实际中,几乎所有的编译器和计算机的组合都对有符号整数采用算术右移。另外,无符号整数的右移一定是逻辑右移。当移位数值 k 大于或等于 x 的位数时,C 标准没有规定这种行为的处理方式。一般来说,k 会对 x 的位数取余数后再位移。
整数的表示
这一节介绍如何为整数编码。计算机中存储了一系列的二进制数,如果没有对这些二进制序列有效的编码方式,那么这些信息将毫无意义。合理的编码方式不仅能以正确的方式处理信息,还能在计算时获得更高的效率。
整数分为有符号整数和无符号整数,为了将其区分,至少会有两种不同的编码方式。对于无符号整数来说,编码方式比较直观;而编码有符号整数就需要特别的设计,以便能让计算机更快地处理。人较为容易理解的方式对计算机来说可能效率就没有那么高,整数编码充分利用了机器处理数的特点和数字的数学表示特点。
为了简化后续数字编码转换的表述,这里列出了数字转换、数值和操作的符号缩写,其中符号下标表示数字的位数。
符号 | 类别 | 含义 |
---|---|---|
\(B2T_w\) | 函数 | 将二进制数用补码表示 |
\(B2U_w\) | 函数 | 将二进制数用无符号数形式表示 |
\(U2B_w\) | 函数 | 将无符号数用二进制数表示 |
\(U2T_w\) | 函数 | 将无符号数用补码表示 |
\(T2B_w\) | 函数 | 将补码数用二进制数表示 |
\(T2U_w\) | 函数 | 将补码数用无符号数形式表示 |
\(TMin_w\) | 常数 | 补码数能表示的最小值 |
\(TMax_w\) | 常数 | 补码数能表示的最大值 |
\(UMax_w\) | 常数 | 无符号数能表示的最大值 |
\(+^{t}_{w}\) | 运算 | 补码数的加法 |
\(+^u_w\) | 运算 | 无符号数的加法 |
\(\ast^t_w\) | 运算 | 补码数的乘法 |
\(\ast^u_w\) | 运算 | 无符号数的乘法 |
\(-^t_w\) | 运算 | 补码数的减法 |
\(-^u_w\) | 运算 | 无符号数的减法 |
经过转换函数处理后,一些数字可能字面上并没有发生改变,但表示的含义不同。就像 10
可以理解十进制的十,也可以理解成二进制里的二。
整数数据类型
C 语言支持多种整数数据类型,这些类型能表示不同的整数范围。char
,short
,long
等类型可以表示正数和负数,而加上 unsigned
之后,只能表示非负数。编译为 32 位的程序和编译为 64 位的程序为同一个数据类型分配的字节数量可能会有所不同,例如 long
类型在 64 为程序中一般会被分配 8 个字节,而 32 为程序会分配 4 个字节,这样在两个平台上程序中,long
类型能表示的数据范围就会不同。
比较有符号数能表示的正负数范围可以发现,数值的范围并不对称,负数表示的范围会多一个数,这和数字的编码方式有关。C 语言标准中定义的数字范围会比我们通常认为的表示范围更小。例如 int
类型在 C 标准中只需要表示 -32767~32767
,只占 2 个字节,和 short
类型表示范围相同,并且正负数范围是对称的。而 long
类型也只需要 4 个字节,和常见的 32 位程序中的长度相同。除非使用 int32_t
,int64_t
这些固定长度的类型才和我们常见的数据表示范围相同,正负数区间也会不对称。
无符号整数编码
给出一个二进制表示的比特序列,用不同的方式编码可以得到不同的值。一个 \(w\) 位的二进制数,将其二进制形式记为 \(\vec x\),完整表示为 \([x_{w-1},x_{w-2},\dots,x_0]\)。用下标形式 \(x_i\) 单独表示其中的第 \(i\) 位,每一位只有两种可能的取值,即 0 或 1。如果第 \(i\) 位值为 1,那么这一位就为这个数字的值贡献了 \(2^i\)。
规定了二进制数的表示规则,就可以按照不同的编码方式将其转化为十进制。如果将这个数编码为无符号整数,那么对应的十进制值可以用下面的式子来计算。
\[B2U_w(\vec x)\doteq\sum_{i=0}^{w-1}x_i2^i\]如果这个 \(w\) 位的二进制数每一位都是 1,那么其编码的十进制数就是 \(w\) 位二进制数所能表示的最大值。
\[UMax_w\doteq\sum_{i=0}^{w-1}2^i=2^w-1\]从这种编码方式可以发现,每个十进制数只对应唯一一个二进制数,\(B2U_w\) 这种映射关系为一一映射。
补码编码
无符号整数编码能表示 \({0,\cdots,UMax_w}\) 范围内的数字,但不能表示负数。在计算机系统中,同时表示包含正数、负数和零的最常用编码是补码(Two’s-complement)。补码以二进制数的最高位作为符号位的标识,并给这个标识一个负的权重。用下面的式子将一个二进制数用补码编码为十进制数。 \(B2T_w(\vec x)\doteq-x_{w-1}2^{w-1}+\sum_{i=0}^{w-2}x_i2^i\)
同样地,可以计算这个式子能表示的最小值是 \(TMin_w\doteq-2^{w-1}\),最大值是 \(TMax_w\doteq\sum_{i=0}^{w-2}2^i=2^{w-1}-1\)。举例来说,一个 4 位的二进制数,用补码能表示的最小值是 -8,最大值是 7。\(B2T_w\) 也是一一映射。
可以发现,补码编码数的范围不是对称的。如果二进制数的最高位为 1,那么可以表示 -1 到最小的负数;最高位为 0,可以表示 0 到最大的正数,0 不是正数,所以补码表示的负数范围会比表示正数的范围多一个。
当 \(w=32\),观察 \(UMax_{32}\)、\(TMin_{32}\)、\(TMax_{32}\) 和 \(0\)、\(-1\) 的十六进制及十进制表示,可以发现补码 \(-1\) 的十六进制表示和 \(UMax_{32}\) 一样(都是 0xFFFFFFFF
),\(0\) 在两种编码下十六进制值都是 0x00000000
,以及 \(UMax = 2TMax+1\)。
C 标准没有规定有符号整数一定要用补码来编码,但几乎在所有机器上都使用了补码。可移植的程序代码,不能假定一种类型所能表示的范围,但现实中很多程序都做了这个假定。limits.h
头文件中定义了有符号、无符号整数的最值,分别是宏 INT_MAX
、INT_MIN
和 UINT_MAX
,使用这些宏来提高程序的可移植性。
在网络中传输信息时,不同类型数据的长度也需要统一。可以使用头文件 stdint.h
下的特定位数整数类型,intN_t
和 uintN_t
,N 表示整数位数。这个头文件下还有不同位数整数的最值,INTN_MIN
,INTN_MAX
,UINTN_MAX
。在打印不同位数整数时,可以用 PRIdN
、PRIuN
这样的宏来指定格式。
反码和原码
除了补码,还有两种编码可以用来编码有符号整数,分别是反码(Ones’ Complement)和原码(Sign magnitude)。反码相比补码差别只有最高位的权重,反码是 \(-(2^{w-1}-1)\)。原码的最高位只用于表示后面数值的符号位,没有权重。这两种编码都有一个特点,数字 0 有两种编码,分别是 \(+0\) 和 \(-0\)。\(+0\) 的编码都是 \([00\cdots0]\),而 \(-0\) 分别表示为 \([10\cdots0]\)(原码),\([11\cdots1]\)(反码)。
注意到补码和反码英文名称中撇号的位置。补码是 Two’s complement,是“2”的“补码”。用 \(w\) 位来表示一个负数 \(-x\) 的补码时 ,用 \(2^w - x\) 可得。例如,用 4 位表示 \(-5\) 的补码,只要用 \(2^4 -5\) 即可。\(2^4\) 是 \(16\),也就是 10000
,减去 \(5\),得到 1011
。这里面计算时只有一个 2 的幂次出现,结果是这个数的“补”,所以是 Two’s。
而在反码中,表示负数 \(-x\),要用一个 \(w\) 位全为 1 的二进制数来减 \(x\)。例如,计算 4 位数表示的 \(-5\) 的反码,就是用 1111
减去 \(5\),得到 1010
。得到的结果是相对于一个全为 1 的数的“补”,所以是 Ones’。
有符号数与无符号数间的转换
同一个二进制数在不同的编码下可以表示不同的数值,如果这个数在两种编码下(这里指有符号数编码和无符号数编码)表示的值相同,那么数值不会改变;如果不同,数值会怎么变化?C 语言中可以将变量做(隐式或强制)类型转换,这里先讨论强制转换的情况。
在强制类型转换时,对计算机来说只是换了一种编码,但对人来说如果要将字面值转换回二进制,再通过另一种编码方式得到新的值,会显得有些繁琐,通过计算获得转换后的值更为便捷。
举例来说,短整型 short int
的 -12345
与无符号短整型 unsigned short int
的 53191
二进制比特值相同。short int
占用 2 字节(16 位),最小值是 -32768
,由二进制数按补码编码得到结果 -12345
时,由 \(B2T_{16}\) 可知,实际是用 -32768
加上一个值后得到 -12345
,这个值是 20423
。当以无符号编码来编码同一个二进制数时,由 \(B2U_{16}\) 可知,只需要给 20423
加上 32768
即可,得到这个二进制数的无符号编码值 53191
。
观察 \(32768+20423=53191\) 和 \(-32768+20423=-12345\),可以发现两个式子的左边差了两个 32768
,也就是 65536
。在这个例子中可以发现,从补码编码转换到无符号整数编码时,只需要加上 2 的 16 次幂即可。转换只在补码数小于 0 时才需要计算,补码数大于等于 0 时,两种编码表示的值相同。由此可以得到补码转换到无符号编码的分段函数 \(T2U_w(x)\),其中 \(x\) 表示二进制数用补码编码得到的值。
另一个例子,32 位无符号整数的最大值的二进制数是 32 个 1,这个二进制数用补码编码就是 -1
。当二进制数从无符号数编码为补码时,最高位将变为负数,\(-2^{31}+2^{30}+\cdots+1\),与无符号整数编码 \(2^{31}+2^{30}+\cdots+1\) 相差两个 \(2^{31}\)。无符号整数转换为补码时,只需要减去 \(2^{32}\) 即可。转换只在最高位为 1 时需要,也就是无符号数大于 \(TMax_{w}\) 时。由此可以得到无符号编码转换到补码时的分段函数 \(U2T_{w}(u)\),其中 \(u\) 表示二进制数用无符号整数编码得到的值。
C 语言中的有符号数与无符号数
C 语言支持有符号和无符号这两种整数类型,但没有规定用什么编码表示有符号数,尽管几乎所有机器都使用了补码。C 语言中所有字面数值默认都是有符号类型,例如 12345
、0x1A2B
。如果要强制使用无符号类型,需要在数字后面加上 U
或 u
,例如 12345U
或 0x1A2Bu
。
C 语言中允许有符号数和无符号数间的转换,但没有规定转换应当保留的精度。现有几乎所有的转换实现都不会改变数字的原始二进制比特位,因而,转换可以使用上面提到的函数。转换分为显式(强制)转换和隐式转换。显示转换需要指定需要转换到的类型,而隐式转换可以自动完成。
当有符号数和无符号数作为操作符的两个操作数时,有符号数将会被隐式转换成无符号数。如果有符号数是负数,隐式转换后,将变成两个正数的运算,结果将与数学计算结果不符。
位扩展
上面提到的有符号数与无符号数的运算都是在相同位数下进行的,当两个数的二进制位数不同时,也可以进行运算,运算将会使用到位扩展。
当位数少的数与位数多的数运算时,位数少的将会扩展到与位数多的数字一致后再进行运算。位扩展有两种方式,0 扩展和符号扩展。
- 0 扩展用于无符号数,在数的前面补 0,增加使用的二进制位数,扩展后数值不变。
- 符号扩展适用于有符号数,为了让扩展后数值不变,需要在前面填充原数最高位的比特值(符号位)。
举例来说,16 位有符号数 -12345
通过符号扩展成 32 位有符号数后,其二进制值由 0xcfc7
扩展成了 0xffffcfc7
,前面增加的都是 1
。16 位无符号数 53191
通过 0 扩展成 32 位无符号数后,其二进制值由 0xcfc7
扩展成了 0x0000cfc7
。
对于用补码表示的有符号数来说,使用符号扩展可以使扩展前后的值一致。符号扩展时,只需要在数的前面补上原数的符号位。符号扩展后,数值符号不会发生改变,值也不变。可以证明,在符号扩展 1 位时,数值不变,那么扩展任意位时,数值也不变。
当位扩展和类型转换同时在表达式中出现时,先完成位扩展,再完成类型转换。例如,将短整型数 -12345
赋值给无符号整数时,无符号整数值为 0xffffcfc7 = 4294954951
而非 0x0000cfc7 = 53191
。
数值截断
既然可以给数字扩展以增加位数,那也可以减少数字的位数。当数字类型占用位数较多的数赋值给数字类型占用较少的数时,多余的位数将被截断。
较多位数的无符号整数在截断时,只需要取余就能计算得到截断后的数值。回忆 \(B2U_w\),截断前 \(i\) 位,保留后 \(k\) 位时,截断后的值与原数对 \(2^k\) 取模的结果相同。因为第 \(k\) 位至第 \(w-1\) 位的数都能整除 \(2^k\),后 \(k\) 位的值小于 \(2^k\),因而保留为余数。
对于用补码编码的有符号数来说,在截断时,需要先将值转换为无符号整数,再应用无符号整数截断规则,保留后 \(k\) 位,最后将结果转换回补码数。表示为 \(B2T_k=U2T_k(B2U_w([x_{w-1},x_{w-2},\dots,x_0])\ mod\ 2^k)\)。
使用无符号数和有符号数的建议
有符号数会通过隐式转换变成无符号数,这个过程可能会将负数变成一个很大的正数,很有可能带来错误。
- 不使用无符号整数来避免隐式转换带来的错误。一些编程语言甚至没有无符号整数类型。
- 使用一个字来记录多个“开关”状态时使用无符号整数。通过操作一个字中的每一个位来记录状态。
- 内存地址用无符号整数表示。
- 在高精度数值计算中,可以用无符号整数数组来存储数值。
整数计算
两个整数相加可能会得到一个负数;比较两个数大小时,x < y
和 x - y < 0
的结果也可能不一样。理解计算机计算的细节可以写出更可靠的代码。
无符号整数加法
两个 \(w\) 位的无符号整数相加,和的范围在 \(0\le x+y\le2^{w+1}-2\) 之间,\(w\) 位不足以保存这两个数的和,至少需要 \(w+1\) 位才能表示全部可能的结果。当一种类型不足以存放计算结果时,会产生溢出(Overflow)。虽然结果放不下,但可以不考虑溢出的值,只保留 \(w\) 位内的结果,这种计算方式称为取模计算,类似于上面的数值截断。定义这种取模计算下的加法为 \(+^u_w\),两数和用下面的函数表示。
\[x+^u_w y=\begin{cases}x+y, & x+y<2^w& Normal \\ x+y-2^w, & 2^{w}\le x+y \le 2^{w+1}&Overflow\end{cases}\]从和的二进制表示上来看,如果和不溢出,那么第 \(w+1\) 位始终为 0;当结果溢出时,\(w+1\) 为 1,代表 \(2^w\),将这一位移除,相当于减去 \(2^w\)。
在 C 语言中,溢出并不会产生一个运行时错误,但运算结果可能是错误的。可以设计一种检测溢出的方法,只需要判断和是否小于任何一个加数即可。当结果不溢出,和必然不小于任何一个加数;当结果溢出时,表示和的公式中,\(y-2^w\) 必然为负数,加上一个无符号整数的结果必然会时结果变小。
// return 1 if x + y can be added without overflow
int uadd_ok(unsigned x, unsigned y)
{
unsigned sum = x + y;
return sum >= x;
}
取模加法运算组成了群论中的阿贝尔群(Abelian group),取自挪威数学家 Niels Henrik Abel。取模加法满足结合律、交换律,群中存在单位元(0)和逆元。逆元是指群中的任何一个元素都有一个对应的元素在取模加法运算下结果等于单位元。在这个群中,逆元可以认为是相反数。记 \(-^u_wx\) 为 \(x\) 的逆元,可用下面公式计算逆元。
\[-^u_wx=\begin{cases}x,& x=0\\2^w-x,&x>0\end{cases}\]补码数加法
两个补码数和的范围在 \(-2^{w}\le x+y\le 2^{w}-2\)。如果要精确表示,可能需要扩展 1 位才能放得下。在处理无符号数时,通过取模,可以让结果保留在 \(w\) 位中,而补码数使用了不同的策略来处理溢出的情况。
定义补码加法为 \(+^t_w\),两补码数和可以用下面公式求得。当两数和大于补码数最大值时,符号位(最高位)进位为 1,最高位权值为 \(2^{w-1}\),回顾无符号数转换为补码数的规则,需要减去 \(2^w\);当两数和小于补码数最小值时,最高位进位为 0,有一位溢出,溢出位的权值为 \(-2^w\),加上 \(2^w\) 可以抵消这一位的权值。
\[x+^t_w y=\begin{cases}x+y-2^w,&2^{w-1}\le x+y&Positive\ overflow\\x+y,&-2^{w-1}\le x+y\le 2^{w-1}&Normal\\x+y+2^w,&x+y<-2^{w-1}&Negative\ overflow\end{cases}\]补码数的加法和无符号数加法的二进制计算方式完全一致,大部分计算机使用相同的机器指令完成这两种类型数的加法。在此设定下,在计算补码数加法时,完全可以先将补码数当作无符号数计算,再将结果转换成补码数。
\[x+^t_w y=U2T_w(T2U_w(x)+^u_wT2U_w(y))\]当两个加数都大于零,但和小于等于零时,正向溢出;当两个加数都小于零,但和大于等于零时,负向溢出;一个大于等于零,一个小于等于零时,不会溢出。
//return 1 if x + y can be added without overflow
int tadd_ok(int x, int y)
{
int sum = x + y;
if (x > 0 && y > 0)
{
return sum > 0;
}
else if (x < 0 && y < 0)
{
return sum < 0;
}
return 1;
}
有了判断两数和是否溢出的函数,就可以编写判断两数差是否溢出的函数。减去一个数就是加上这个数的相反数,当减数是补码数的最小值时不能简单取相反数,因为正数范围比负数范围小。
int tsub_ok(int x, int y)
{
if (y == INT_MIN)
{
return x < 0;
}
return tadd_ok(x, -y);
}
补码数的相反数
除了补码数最小值的相反数为其本身,其他数的相反数都可以加负号得到。本数和相反数之和为零。相反数在数值上容易计算,但在二进制操作上会稍微复杂一些。这里介绍两种方法。
- 将原数二进制数按位取反后加一。
~x+1
- 取原数二进制位的最右边一个 1,将这个 1 左侧的所有位取反。
无符号整数乘法
两个无符号整数的积也可能溢出,处理乘法时与处理加法时类似,只取后 \(w\) 位。定义无符号整数乘法为 \(*^u_w\),那么两无符号整数积可用下面公式计算。
\[x*^u_wy=(x\cdot y)\ mod\ 2^w\]补码数乘法
补码数乘法也是取最后的 \(w\) 位,先将数当作无符号数计算,得到结果后再转换成补码数。
\[x*^t_wy=U2T_w((x\cdot y)\ mod\ 2^w)\]判断补码乘法是否溢出会复杂一些。可以将数值强制转换到精度更高的类型,在将结果截断到原始类型做比较。假设 int
类型长度为 32 位。
//return 1 if x * y can be multiplied without overflow
int tmult_ok_cast(int x, int y)
{
int64_t pll = (int64_t)x * y;
return pll == (int)pll;
}
乘法和移位
整数乘法指令在很多机器上执行会比加法、减法、位运算慢很多,即便在较新的处理器上,乘法指令也需要数倍于加减法和位运算需要的时钟周期才能完成。因此,编译器会尽可能将乘法优化为移位运算和加法的组合以加快执行速度。
对于移位运算,在不考虑溢出时,左移 \(k\) 位相当于将原数乘上 \(2^k\)。考虑溢出时,只需将结果取模即可。考虑到乘法比移位和加法需要更多的时钟周期来执行,编译器会将变量与常数的乘法优化为移位、加法和减法的组合。例如,表达式 x*14
中,常数 14 可以拆分为 8 + 4 + 2
,因此表达式可以优化为三个移位运算和两个加法,即 (x<<3) + (x<<2) + (x<<1)
。或者是 16 - 2
,(x<<4) - (x<<1)
,两个移位运算和一个减法。在实际处理中,需要权衡多个移位运算与加减法的组合与单次乘法的时间。
除法和移位
在大多数机器上,整数除法需要的时钟周期比乘法更长。因此,可以用右移位优化整数除法运算。无符号整数使用逻辑右移,补码数用算数右移。这里只讨论除数为 2 的整数次幂的情况。
由于除法可能除不尽,整数除法总是向零取整,即舍去小数部分。
- 当商是正数时,向下取整,取不大于商的最小整数作为结果;
- 当商是负数时,向上取整。取不小于商的最小整数作为结果。
对于无符号整数来说,逻辑右移总是向下取整,移出的数值舍弃。
对于补码数来说,除法会稍微复杂一些。当补码数大于零时,移位操作与逻辑移位结果相同。当补码数小于零时,算数移位会在左边移入 1 以保留符号位不变。举例来说,取补码数 -12340
,二进制位表示为 1100 1111 1100 1100
,将其算数右移 4 位,得到 1111 1100 1111 1100
,其十进制值为 -772
,整数值为 -12340/16 = -771.25
。
可以发现,二进制值经过移位后的结果实际是向下取整,得到了不大于商的最小整数,不满足向零取整的要求。为满足向零取整(向上取整)的要求,需要在移位前给原数加上偏移量 \(2^k-1\)。写成表达式 (x + (1 << k) - 1) >> k
。
这利用了 \(\lceil x/y\rceil=\lfloor(x+y-1)/y\rfloor\)。设 \(x = q*y+r\) 且 \(q<0,0\leq r<y\)。向上取整的目标是将小数部分进位,得到一个大于 \(q\) 的最小整数。而 \(\lfloor(x+y-1)/y\rfloor=q+(r+y-1)/y\),没有 \(y-1\) 时,右边必然为小数,向下取整结果为 \(q-1\);加上 \(y-1\) 后,当 \(r>0\) 时,右边会大于 1,会使 \(q\) 增大,向下取整后的值为 \(q\)。
将这个移位运算写成表达式,同时处理被除数大于零和小于零的情况。
(x < 0 ? x + (1 << k) - 1 : x) >> k;
当除数是 2 的整数次幂时,可以通过两种右移位运算实现除法。但除法不能像乘法那样拆分成几个除法的和的形式,因而此方法不适用于除数为任意值的情况。
整数计算的更多思考
- 由于表示的数字范围有限,计算结果可能会溢出,对溢出的结果取余可以让结果在可表示范围内。
- 补码使用了一种巧妙的方式来表示正数、负数和零。
- 补码数和无符号整数在计算上完全相同,只需要在计算完成后做类型转换。
- 有符号数和无符号数在一个表达式中计算时很有可能产生错误。
这里提到的一些计算,都是为了能让人预测、理解计算机得到的是一个什么结果,而非去实现某种运算。对人来说,可以完整地得到两个整数相乘的结果,但计算机没有足够的存储空间来保存两个数的积,只能使用尽可能合理地方式来保留有限的结果。人在理解这些运算规则后,可以阅读程序语言预测执行结果,在出现结果和预期不一致的情况下能够推测原因,并在编程时采取合适的方法避免人的理解和计算机计算方式不同而产生的错误。
浮点数
从整数的表示中可以发现,整数表示的数值范围虽然已经很大了,但在一些情况下还是不够大。为了能够表示更大的数值范围,而且能够表示一定精度的有理数和非常接近 0 的数值,可以采用浮点数类型来存储数据。
在 1980 年代,不同的厂商有自己的浮点数实现方案,而且在计算时更多地关心计算速度而非计算精度。1985 年左右,在 IEEE 的推动下,一项精心设计的浮点数表示和计算方案发布。这个方案名为 IEEE 754,也是现在绝大多数计算机处理浮点数的标准。IEEE 读作 “eye-triple-ee”,它设立的委员会为从能源传输到软件工程的电子、电气等行业设立了大量标准。有了统一的标准,计算机程序,特别是需要使用科学计算的程序能够更容易地在不同机器上使用。
这一节将介绍 IEEE 浮点数表示法,并讨论计算时涉及到的舍入舍出。由于二进制的特点,并不是每个小数都能由一个浮点数精确表示,因而大多数数字只能近似的存储。同时会介绍浮点数的加法、乘法和其他相关的运算符。
二进制小数表示法
在介绍 IEEE 浮点数表示法之前,先了解二进制数是如何表示浮点数的。假设一个二进制数共 m+1+n
位,其中前 m+1
位表示整数部分,后 n
位表示小数部分。以小数点为分界,左边整数部分的权重与前面介绍的无符号整数相同,右边小数部分的权重则是一个 2 的负数次幂,与整数部分相反。举例来说,二进制比特位为
计算其表示的十进制值时使用下面求和式
\[b=\sum_{i=-n}^{m}2^i\times b_i\]如果将小数点的位置向左移,相当于将数字除以 2;向右移会将数字乘以 2。当要表示的数与 1 十分接近,但又不到 1 时,这个数的小数点左边全为 0,右边全为 1,这时,使用 \(1-\epsilon\) 表示一个浮点数能表示的最接近 1 的值。 从计算方法可以发现,浮点数只能表示一些能够写成 \(x\times2^y\) 形式的小数或这些小数的和。像 \(\frac{1}{3}\)、\(\frac{5}{7}\) 这样的数,浮点数不能精确表示,只能用一个近似值来表示。
当试图用二进制表示一个不是由有限个 2 的整数次幂能够表示的数时,这个二进制数将会变成一个“无限循环小数”。比如,当要表示 \(\frac{1}{10}\) 时,这个数就会是 \(0.000110011[0011]\cdots\),后面这部分表示无线循环。这很像在十进制计数法中表示 \(\frac{1}{3}\)。
IEEE 浮点数表示法
上面表示浮点数的方法虽然可行,但当要表示特别大的数字时,由于小数点的位置固定,需要很多位才能表示。例如表示 \(5\times 2^{100}\),需要一百多位才能表示,IEEE 浮点数表示法很好地处理了这个问题。IEEE 浮点数表示法以下面的方式表示一个数。
\[V=(-1)^s\times M\times 2^E\]- 符号位
s
。 - 有效数
M
。其范围在 1 至 \(2-\epsilon\) 之间或在 0 到 \(1-\epsilon\) 之间,分别用于表示一个大于 1 的数和一个接近 0 的数。 - 指数
E
。为有效数确定一个权重。
浮点数按二进制位长度分为 32 位和 64 位,64 位能表示的数值范围更大。最高位表示数值的符号,之后的 k
位用于存储指数值 exp
,最后 n
位用于存储有效数的小数部分 frac
。
标识/长度 | 32位 float |
64位 double |
---|---|---|
s |
1 | 1 |
exp |
8 | 11 |
frac |
23 | 52 |
根据需要表示数字的范围,浮点数有三种情况。
常见情况 Normalized
- 表示绝对值大于等于 1 的浮点数。
- 指数部分的
k
位数既非全 1 又非全 0。 - 指数表示为偏差形式,即 \(E=e-Bias\)。其中 \(e\) 为
k
位数编码的无符号整数,\(Bias\) 表示偏差,值为 \(2^{k-1}-1\),float
类型为 127,double
类型为 1023。对于float
类型,E
的范围在 -126 至 127;double
类型为 -1022 至 1023。 - 小数部分取值范围 \(0\le f<1\),有效数为 \(M=1+f\)。这里规定有效数为
frac
所表示的小数值加一,可以让表示范围更为紧凑、连续。
表示 0 和非常接近 0 的数 Denormalized
- 指数部分的
k
位数全为 0。 - 指数表示为 \(E=1-Bias\),偏差值同上。
- 有效数 \(M=f\)。这种情况,
frac
不需要加一,可以精确表示数值 0。表示+0.0
时,这个数的全部位都为 0。当只有符号位为 1 ,其他为所有位均为 0 时,这个数表示为-0.0
。大部分情况下,这两个零作用相同。但由于符号位的存在,在正负零被当作除数时,结果会有正无穷和负无穷的差别。
表示特殊值
- 指数部分的
k
位数全为 1。 - 当小数部分全为 0 时,如果符号位为 0,表示正无穷;如果符号位为 1,表示负无穷。这种情况通常用来表示计算结果溢出。例如两个非常大的数相乘,或除数为零时,结果就会是无穷。
- 当小数部分不全为 0 时,表示
NaN
,即 not a number,表示值不能用一个确切的实数来表示。例如求一个负数的算数平方根,或者计算两个正无穷数的差。
数值示例
为了直观地理解浮点数表示的数值,这里以 6 位的浮点数为例,展示它能表示的数值,其中 k = 3,n = 2
。按照上面表示浮点数的规则,这个浮点数能表示的范围在 -14 到 +14 之间,以及正负无穷和 NaN
。
下图用三种样式表示了三种情况下能表示数值,不包括 NaN
,无穷值列在数值两侧。从图中可以发现,浮点数能表示的整数并不连续,而且数值分布不均匀,离零越远的地方,数值分布越稀疏。
下图放大了在 -1 到 +1 之间的数值分布情况。接近 0 的部分是上面第二种情况。注意到,在零的位置上有 +0 和 -0 两个值。
再如,一个 8 位浮点数,其中 k = 4,n = 3
。那么 \(Bias = 2^{4-1}-1=7\)。通过计算 \(V=2^E\times M\) 可以得到能表示的数值范围(考虑正数部分)。
在接近 0 的 Denormalize
情况中,\(E=1-7=-6\),那么 \(2^E=\frac{1}{64}\)。由于 n = 3
,小数部分的间距是 \(\frac{1}{8}\)。M 共能取 8 个数,也就是在接近 +0 的部分,共有 8 个值可取。接近 0 的部分能表示的最大值是 \(\frac{7}{8}\times\frac{1}{64}=\frac{7}{512}\),最小值是 0,数值之间分布均匀,间距为 \(\frac{1}{512}\)。
在常见情况中,由于 k = 4
,那么 \(-6=2^0-7\le E\le2^{4}-2-7=7\),\(\frac{1}{64}=\le2^E\le128\)。而小数部分取值与上一种情况相同,只是计算时需要在实际值基础上加 1。因而,此种情况表示最小值时,小数部分 3 位全为 0, \(M =1+0=1\),\(V=\frac{1}{64}\times1=\frac{1}{64}=\frac{8}{512}\)。
注意到,常见情况下的最小值和接近 0 情况下的最大值的差值与接近 0 情况下各个数值间距一致,这种“平滑”过渡得益于在设计接近 0 情况下 \(E\) 值时使用了 \(1-Bias\) 而不是 \(-Bias\),用 1 补偿了这种情况下不会给小数部分加 1 的损失。这种情况下的最大值为 \(V=128\times(1+\frac{7}{8})=128+112=240\)。再大的值是正无穷。
如果将浮点数当作无符号整数处理时,会发现从正数部分最小值到最大值对应的无符号数也是逐渐变大,这种浮点数的设计能够让浮点数排序使用整数的排序方式。在处理负数部分时,从负数的最小值到最大值对应的无符号数是从大到小的顺序,处理这种情况后即可完成正确的排序逻辑。
// return 1 if x <= y
int float_le(float x,float y){
unsigned ux = f2u(x);
unsigned uy = f2u(y);
/*Get the sign bits*/
unsigned sx = ux >> 31;
unsigned sy = uy >> 31;
//哪些情况下 x<=y ?
//只使用位运算和逻辑运算
//是多种情况的逻辑组合,一种为真后不需要计算剩下的条件
//1. 两数都为零。 !((ux<<1)|(uy<<1))
//两数都为0的情况必须放在几个或逻辑的第一项,后三种情况顺序任意
//2. x 小于零,但 y 大于零。 sx>sy
//3. x,y 都小于零,但 x 更小。 sx&&sy&&(ux>=uy)
//4 x,y 都大于零,但 x 更小。 !(sx||sy)&&ux<=uy
return !(ux<<1|uy<<1)||(sx&&sy&&ux>=uy)||(!sx&&!sy&&ux<=uy)||(sx>sy) ;
}
unsigned f2u(float f){
//整数的二进制位转换到浮点数表示不能用强制类型转换
//强制类型转换只能得到整数部分
return *(unsigned*)&f;
}
小结一下
- 正数部分,常见部分最小值的二进制表示中,指数部分最低位为 1,其他所有位均为 0。那么 \(M=1+f=1\),\(E=1-(2^{k-1}-1)\),其值为 \(V=2^{-2^{k-1}+2}\)。
- 数值
1.0
的指数部分除最高位为 0,其他位均为 1。因而 \(E=0\),有效数部分所有位为 0,\(V=1\times2^0=1.0\)。 - 常见部分最大值,符号位为 0,指数部分最低位为 0,其他所有位均为 1。因而 \(f=1-2^{-n}\),\(M=2-2^{-n}\),\(E=(2^k-2)-(2^{k-1}-1)=2^{k-1}-1\),\(V=(2-2^{-n})\times2^{2^{k-1}-1}=(1-2^{-n-1})\times2^{2^{k-1}}\)。
可以通过将整数转换为浮点数,观察对应二进制位的变化来更好地理解浮点数各部分的作用。例如,正整数 12345
的二进制表示为 11 0000 0011 1001
,将其标准化为 \(1.1000000111001\times2^{13}\)。用 IEEE 单精度浮点数(1+8+23 位)编码时,省去最前面的 1
,并在小数部分后添加 10 个 0
。指数部分为 13,加上单精度浮点数对应的指数偏差值 127,得到指数部分为 140,对应二进制表示为 10001100
。最高位为 0
,整个数的二进制表示为 0 10001100 10000001110010000000000
。对比 12345
作为正整数时的二进制表示,可以发现,除了最高位的 1,剩余的数位变成了浮点数的有效数部分。
舍入舍出
由于浮点数能够表示的数值范围和精度有限,对于一个数,需要确定一个浮点数能表示且最接近原数的值来表示目标数值。IEEE 浮点数标准定义了 4 种不同的舍入舍出模式。
- 向偶数舍入(Round-to-even/Round-to-nearest)。默认模式。将数值舍去或进位到最接近的数值,当数值向上与向下距离相同时,取最低有效位为偶数的值作为最接近值舍入。
- 向零舍入(Round-to-zero)。正数向下舍入,负数向零/向上舍入。
- 向下舍入(Round-down)。正数和负数都向更小的方向舍入。
- 向上舍入(Round-up)。正数和负数都向更大的方向舍入。
向偶数舍入模式初看似乎是一种非常随意的处理方式,为何偏爱偶数而不是奇数?为何不都向上舍入?从统计角度来说,如果对一个数据集中的数字都采用向上舍入的方式,那么整体数据的平均值会偏大一点;都向下舍入则会偏小。总是向偶数舍入可以在统计意义下偏差更小。
对于二进制数,将最低有效位为 0 作为偶数,1 作为奇数。当一个二进制小数与 \(XX\cdots X.YY\cdots Y100\cdots\) 形式相同,需要舍入到最右边的 Y 时,这个数向上和向下舍入的距离相同,只有这种情况下需要考虑向偶数舍入。
举例来说,二进制小数 10.00011
要保留两位小数(即精确到四分之一,类似于十进制中精确到百分之一),那么结果就会向下舍入为 10.00
;10.00110
则向上舍入为 10.01
。这两个数离上下近似值距离不同,取较近的近似值即可。
二进制小数 10.11100
,保留两位小数时,需要使用向偶数舍入法,舍入至 11.00
,因为这个数与 11.00
的距离和与 10.11
的距离相同,都是 0.001
,而 11.00
的最低有效位为偶数,舍入到这个值。对于 10.10100
,也需要向偶数舍入,结果为 10.10
。
浮点数运算
IEEE 浮点数标准有一条规则用于判定计算结果的正确性。两浮点数(实数) \(x\) 和 \(y\),经过二元运算 \(\odot\) 得到的结果的浮点数表示应当满足 \(Round(x\odot y)\),也就是在实际的数学运算结果上执行舍入舍出后得到的浮点数能够表示的值。实际计算时,并不总能保证有足够精确的值来执行舍入舍出操作。当参与运算的两个数中有如 \(-0\),\(\infty\) 或者 NaN
等值时,结果有特别规定。
无符号整数和补码数的加法可以组成阿贝尔群,实数加法也可以。浮点数的加法需要考虑舍入舍出的影响。定义浮点数加法 \(x+^fy\) 为 \(Round(x+y)\)。对于任意两个加数,浮点数加法是满足交换律的。但浮点数加法不满足结合律,例如 (3.14 + 1e10) - 1e10 = 0.0
,3.14
由于舍入舍出被丢弃了,因为前两个数的和在单精度浮点数下没有足够长度的有效数位来存放精确值,只能以较大数为舍入目标,截断超过有效数部分的数值。而 3.14 + (1e10 - 1e10) = 3.14
,没有截断数值的情况。大部分浮点数都有对应的相反数,除了正负无穷和 NaN
。
浮点数加法不满足结合律可能会给科学计算和编译器程序员带来潜在的问题。例如,下面的代码计算了三个浮点数的和。
x = a + b + c;
y = b + c + d;
编译器可能会尝试减少一步浮点数加法而生成下面的中间代码。
t = b + c;
x = a + t;
y = t + d;
由于浮点数加法没有结合性,在计算 x
的值时,优化过的代码可能不会得到正确的结果。在大多数情况下可能不会有什么重大的影响,但编译器并不知道程序员希望保证计算的准确性还是希望计算更快一点。因而,编译器在处理这种情形时会非常保守,尽量避免优化产生的误差。
浮点数加法还满足单调性,如果 \(a\ge b\),那么 \(x+^fa\ge x+^fb\),其中数值不包括 NaN
。值得注意的是,无符号整数和补码数加法不满足单调性。
对于浮点数乘法,除了无穷和 NaN
外能满足封闭性,满足交换律,1.0
是乘法单位元。由于溢出和精度问题,浮点数乘法不满足结合律。例如 (1e20*1e20)*1e-20 = inf
,而 1e20*(1e20*1e-20) = 1e20
。
同样地,浮点数乘法对加法不满足分配律。例如 1e20*(1e20-1e20) = 0.0
,而 1e20*1e20-1e20*1e20 = NaN
。浮点数乘法也满足单调性,仅当数值不为 NaN
时成立。另外,除了 NaN
外,每一个浮点数与自身的乘积都不小于 0。
浮点数不满足结合律和分配律的特点对于科学计算和编译器编写有一定挑战性。即便是判断两条线在三维空间中是否相交这种看起来简单的任务,在运用浮点数编码时也会充满挑战。
C 语言中的浮点数
C 语言提供了两种规格的浮点数类型,float
和 double
,在支持 IEEE 浮点数标准的机器上,这两种类型对应单精度和双精度,并且使用向偶数舍入方法。但 C 语言并没有要求必须使用 IEEE 浮点数标准来实现这两种浮点数类型,因而没有修改舍入模式和获取 -0
、正负无穷和 NaN
的固定方式。一般会提供对应的头文件和函数库来使用和浮点数相关的数值,gcc
编译器在 math.h
中定义了无穷和 NaN
。
在处理 int
,float
,double
类型之间的类型转换时,有以下规则。
int
转换到float
,不会溢出,但可能被舍入舍出。int
或float
转换到double
,能够准确保留原始数值,因为double
类型能表示的数值范围更大也更精确。double
转换到float
,数值可能会溢出,得到正无穷或负无穷;不溢出的情况下也有可能被舍入舍出。float
或double
转换到int
,数值会向零取整。例如1.999
转换后将得到1
,而-1.999
转换后将得到-1
。数值也有可能溢出,但 C 标准没有规定如何处理溢出的情况。一般处理器会把补码的最小值设计为整数下的“无穷”值,当整数范围内没有数字能够合理对应浮点数时,就会使用这个无穷值。因而,将1e10
转换为int
类型后,将得到-2147483648
,一个正数被转换成了负数。
本章小结
- 计算机中的信息以位的形式存储和处理,连续的位可以分组为字节。解读这些字节含义的规则是编码,一串位可以解读成整数、实数或者字符串。不同计算机在处理连续字节的编码时可能会不一样,这是字节序。
- C 语言的设计使得其可以适应各式各样的计算机,无论计算机的字长是多少、使用的数字编码是哪一种,都可以有对应的 C 语言实现。64 位字长的计算机现在已经十分普及,在 64 位计算机上可以运行 30 年前在 32 位计算机上编译的程序。32 位程序只能访问最多 4 GB 的地址空间,而 64 位程序能使用的地址空间远远超过 4 GB。
- 现在大多数计算机都使用补码作为有符号整数的编码方案,使用 IEEE 754 浮点数标准处理浮点数。在位这一层级理解编码和编码对应的数学计算规则,能够帮助程序员在所有数值范围上正确处理数值运算。
- 大多数计算机在处理相同长度的有符号整数和无符号整数之间的类型转换时,都不会改变数字对应的位。C 语言中使用隐式类型转换时,需要格外注意。
- 计算机编码数字的比特位长度有限,当超过编码能表示的数值范围时,会出现溢出。整数和浮点数处理溢出时有所不同,浮点数还有下溢,即所要表示的数值太小,精度范围不能表示,数值就会下溢成零。
- C 语言中,有限范围内的数值运算与真实的数学运算有很大区别,例如,计算一个数的平方时,当结果过大溢出,将会得到一个负数。尽管如此,无符号整数和补码数仍有整数的一些计算特点,例如满足交换律、结合律和分配律。位运算有很多有用的特性,编译器会使用数学特性和位运算特点来优化计算过程。
- 浮点数是用二的幂形式编码实数的方案,IEEE 754 提供了单精度(32 位)和双精度(64 位)两种浮点数精度类型,其中正负无穷、正负零和
NaN
有特殊表示方式。 - 使用浮点数计算时需要多加留意,因为浮点数的精度和表示范围有限,而且不满足结合律。