第 5 章:基本值与数据
本章涵盖:
- 理解抽象状态机
- 使用类型和值
- 初始化对象
- 使用具名常量
- 类型的二进制表示
现在,我们要把注意力从“如何执行操作”(即语句和表达式)转移到 C 程序处理的对象上:值和数据。在任一时刻,运行中的程序都必须用某种具体形式来表示它所处理的值。人类也是这么做的:如今我们主要用阿拉伯数字,按十进制把数写在纸上。当然,记数的方式还有很多,比如罗马数字(i、ii、iii、iv 等),或者直接用文字写出数。要认出单词 twelve 代表数值 12,并不是理所当然的事;这也提醒我们,并非所有欧洲语言都只用十进制表示数。例如,英语和德语里混杂着十二进制的痕迹,法语则带有十六进制和二十进制的成分。像我这样母语不是法语的人,很难立刻把 quatre vingt quinze(四个二十加十五)这个词与数值 95 联系起来。
同样,计算机中值的表示也会因体系结构而呈现不同的“文化”,也可能取决于程序员赋予该值的类型。因此,若想编写可移植代码,首先应围绕值推理,而非围绕表示推理。
如果你已经具备一定的 C 使用经验,也熟悉字节和位的操作,那么在本章的大部分内容中,你需要努力主动“忘掉”这些知识。执着于值在自己计算机上的具体表示,对理解往往弊大于利。
要点 5 #1
C 程序首先围绕值推理,而不是围绕值的表示推理。
在大多数情况下,某个值究竟采用何种表示,不应成为你的关注点。编译器会安排值与表示之间的双向转换。
本章将说明这种转换的各个组成部分应当如何协作。通常,你会在 C 的抽象状态机(第 5.1 节)这个理想世界中“论证”程序。它描绘了程序如何执行,而且这幅图景基本不依赖程序运行的平台。这台机器的状态由对象组成:每个对象都有固定的解释(即其类型),其值则随时间变化。第 5.2 节介绍 C 的基本类型;第 5.3 节说明如何写出这些基本类型的特定值;第 5.4 节说明类型如何在表达式中组合;第 5.5 节说明怎样确保对象一开始就具有期望的值;第 5.6 节说明怎样为反复出现的值命名;第 5.7 节则说明抽象状态机如何表示这些值。
5.1 抽象状态机
C 程序可以视为一种操纵值的机器:它操纵程序中各对象在给定时刻所具有的具体值,也操纵表达式计算所得的中间值。来看一个基本示例:
double x = 5.0;
double y = 3.0;
...
x = (x * 1.5) - y;
printf("x is %g\n", x);2
3
4
5
这里有两个对象 x 和 y,它们的初值分别为 5.0 和 3.0。第 4 行计算了若干表达式:
x它对 x 求值,得到值 5.0;
(5.0 * 1.5)它产生值 7.5;
y它对 y 求值,得到值 3.0;
7.5 - 3.0它产生值 4.5;
x = 4.5它把 x 的值改为 4.5;
x它再次对 x 求值,但这一次得到值 4.5;最后,
printf("x is %g\n", 4.5)向终端输出一行文本。
并非所有操作及其结果值都能从程序内部观察到。只有当这些结果存入可寻址内存,或写入输出设备时,它们才可观察。在这个示例中,printf 语句通过求取对象 x 的值,再把该值的字符串表示写到终端,在一定程度上“观察”了上一行所做的事情。其他子表达式及其结果(例如乘法和减法)本身却不可观察,因为我们从未定义一个用于保存这些值的对象。
在称为“优化”的过程中,C 编译器可以省略任何步骤,只要最终结果不变。就这个小示例而言,基本有两种可能。第一种是程序后面不再使用对象 x,它得到的值只对 printf 语句有意义。这时,这段代码的唯一效果就是向终端输出,编译器完全可以(而且确实会)把整段代码换成如下等价形式:
printf("x is 4.5\n");也就是说,编译器在编译期完成所有计算,生成的可执行程序只会打印一个固定字符串。其余代码,乃至对象定义,都会消失。
第二种可能是,程序后面还会使用 x。此时,一个像样的编译器可能生成近似于以下形式的代码:
double x = 4.5;
printf("x is 4.5\n");2
也可能生成:
printf("x is 4.5\n");
double x = 4.5;2
因为以后使用 x 时,赋值发生在 printf 之前还是之后并不重要。
优化要成立,C 编译器只需生成一个能复现可观察状态的可执行程序。可观察状态由某些对象(以及稍后介绍的类似实体)的内容和输出构成,并随程序执行而演变。整个状态变化机制称为“抽象状态机”。
为了说明抽象状态机,我们首先需要考察值(当前处于什么状态)、类型(该状态表示什么)以及表示(如何区分各个状态)这些概念。正如“抽象”一词所暗示的那样,C 的机制允许不同平台按自身需求和能力,以不同方式实现同一程序的抽象状态机。这种自由度正是 C 具备优化潜力的关键之一。
5.1.1 值
C 中的值是一种抽象实体,它通常独立于你的程序、程序的具体实现,以及某次运行中该值所采用的表示。例如,0 这个值(及其概念)在所有 C 平台上都应当具有相同的效果:把它加到另一个值 x 上,结果仍是 x;控制表达式求值为 0 时,控制语句总会走假分支。
到目前为止,我们见到的值大多是某种数。这并非偶然,而是与 C 的一个核心观念有关。
要点 5.1.1 #1
所有值都是数,或者能够转换为数。
这项性质确实涵盖 C 程序所处理的一切值,无论它们是打印的字符或文本、真值、测量结果,还是所研究的关系。应当把这些数看作数学实体,它们独立于程序及其具体实现。
一次程序执行的数据,由某一时刻所有对象的全部值共同组成。程序执行的状态取决于:
- 可执行程序;
- 当前执行位置;
- 数据;
- 外部介入,例如来自用户的输入和输出。
如果不考虑最后一点,那么同一个可执行程序从同一个执行位置、以相同数据开始运行,必定得到相同结果。但 C 程序应当能在不同系统之间移植,所以我们的要求不止于此。我们不希望计算结果依赖可执行程序(它与平台相关),而希望它在理想情况下只取决于程序规约本身。实现这种平台独立性的重要一步,就是“类型”概念。
5.1.2 类型
类型是 C 为值附加的一项性质。到目前为止,我们已经见过若干类型,其中最醒目的是 size_t,另外还有 double 和 bool。
要点 5.1.2 #1
所有值都有一个静态确定的类型。
要点 5.1.2 #2
可对一个值执行哪些操作,由它的类型决定。
要点 5.1.2 #3
一个值的类型决定所有操作的结果。
5.1.3 二进制表示与抽象状态机
遗憾的是,计算机平台纷繁多样,C 标准无法完全规定某个类型上所有操作的结果。例如,标准并未完全规定 double 浮点操作采用何种精度(即浮点表示)。[1] C 只要求表示满足若干性质,使操作结果能够根据以下两方面预先推导出来:
- 操作数的值;
- 描述特定平台的若干特征值。
例如,size_t 类型上的操作,只要知道操作数和 SIZE_WIDTH 的值,就能完全确定。[2] 某个类型的值在给定平台上的表示模型,我们称为该类型的“二进制表示”。
要点 5.1.3 #1
一个类型的二进制表示决定所有操作的结果。
一般来说,确定这一模型所需的全部信息,任何 C 程序都能获取。C 库头文件通过具名值(例如 SIZE_MAX)、运算符和函数调用,提供必要信息。
要点 5.1.3 #2
一个类型的二进制表示是可观察的。
二进制表示仍然是一种模型,因而仍属抽象表示:它并不完全决定值如何存入计算机内存、磁盘或其他持久存储设备。那是“对象表示”。与二进制表示不同,对象表示通常不必过分关注,除非我们要在主存中拼凑对象的值,或者要让平台模型不同的计算机互相通信。很久以后(第 12.1 节)我们会看到:只要对象存放在内存中,并且知道它的地址,我们甚至可以观察其对象表示。
因此,一切计算都由程序所规定的值、类型及其二进制表示确定。程序文本描述了一台抽象状态机,规定程序如何从一个状态转到下一个状态。这些转换仅由值、类型与二进制表示决定。
要点 5.1.3 #3(如同规则)
程序的执行,如同遵循抽象状态机。
5.1.4 优化
具体可执行程序究竟如何遵循抽象状态机的描述,留给编译器开发者自行决定。大多数现代 C 编译器生成的代码并不逐字照搬代码开出的“处方”:能取巧的地方都会取巧,只保证抽象状态机的可观察状态不变。例如,一串常量加法:
x += 5;
/* 与此同时做一些不使用 x 的事情。 */
x += 7;2
3
在许多情况下,可以如同写成下面这样来执行:
/* 做一些不使用 x 的事情。 */
x += 12;2
或者:
x += 12;
/* 做一些不使用 x 的事情。 */2
只要结果不存在可观察差异,编译器就可以改变执行顺序。例如,只要我们不打印 x 的中间值,也不把这个中间值用于其他计算,就可以这样做。
不过,如果编译器无法证明某项操作不会导致程序终止,这种优化也可能不被允许。在这个示例中,能否优化很大程度上取决于 x 的类型。如果 x 的当前值可能接近该类型的上限,看似无害的操作 x += 7 就可能溢出。不同类型处理溢出的方式不同。我们已经看到,无符号类型的溢出不成问题,合并后的操作与分开的两个操作结果总是一致。对于其他类型,例如有符号整数类型(signed)和浮点类型(double),溢出可能引发异常并终止程序。这时就不能做这种优化。
如前所述,程序描述与抽象状态机之间允许留有余地,这是一项极有价值的特性,通常称为“优化”。这一特性再加上相对简单的语言描述,正是 C 能胜过其他功能花哨的编程语言的主要原因之一。由此可得一个重要结论。
要点 5.1.4 #1
类型决定优化机会。
5.2 基本类型
C 拥有一系列基本类型,还提供从它们构造“派生类型”的手段;我们将在第 6 章介绍派生类型。
主要由于历史原因,基本类型系统略显复杂,用来指定这些类型的语法也不太直观。第一层说明完全由语言关键字完成,例如 signed、int 和 double。这一层主要按照 C 的内部机制组织。在此之上还有第二层说明,它通过头文件提供;我们已经见过 size_t 和 bool 等示例。第二层按照类型的语义组织,规定某个类型为程序员提供哪些性质。
我们先从这些类型的第一层说明开始。前面已经讨论过(要点 5.1.1 #1),C 中所有基本值都是数,但数有不同种类。首先,数分为两大类,每一类又分两个子类:无符号整数、有符号整数、实浮点数与复浮点数。这四个类别各自包含若干类型。它们按“精度”区分,精度决定某个类型允许的有效值域。[3] 表 5.1 概览了 18 种基本类型。
表 5.1 按四个主要类型类别划分的基本类型。 灰色背景对应的类型不能直接参与算术;执行算术之前,它们会先被提升。char 类型较为特殊:视平台而定,它可能是无符号的,也可能是有符号的。即使表中某些类型具有相同类别和精度,也一律视为不同类型。
| 大类 | 类别 | 系统名称 | 其他名称 | 等级 |
|---|---|---|---|---|
| 整数 | 无符号 | bool | _Bool | 0 |
| 整数 | 无符号 | unsigned char | 1 | |
| 整数 | 无符号 | unsigned short | 2 | |
| 整数 | 无符号 | unsigned int | unsigned | 3 |
| 整数 | 无符号 | unsigned long | 4 | |
| 整数 | 无符号 | unsigned long long | 5 | |
| 整数 | 符号由实现决定 | char | 1 | |
| 整数 | 有符号 | signed char | 1 | |
| 整数 | 有符号 | signed short | short | 2 |
| 整数 | 有符号 | signed int | signed 或 int | 3 |
| 整数 | 有符号 | signed long | long | 4 |
| 整数 | 有符号 | signed long long | long long | 5 |
| 浮点 | 实数 | float | ||
| 浮点 | 实数 | double | ||
| 浮点 | 实数 | long double | ||
| 浮点 | 复数 | float _Complex | float complex | |
| 浮点 | 复数 | double _Complex | double complex | |
| 浮点 | 复数 | long double _Complex | long double complex |
从表中可以看到,有六种类型不能直接参与算术,也就是“窄类型”。它们在算术表达式中参与运算之前,会先“提升”为较宽的类型。如今在任何实际平台上,无论原窄类型是否有符号,提升结果都是值相同的 signed int。
要点 5.2 #1
执行算术之前,窄整数会提升为 signed int。
请注意,窄整数类型中有两个醒目的成员:char 和 bool。前者是 C 用来处理文本中可打印字符的类型,后者保存真值 false 和 true。如前所述,在 C 看来,就连它们也不过是某种数。其余 12 种无需提升的类型恰好分属四个类别。
要点 5.2 #2
四类基本类型各有三种互不相同、无需提升的类型。
与许多人的看法不同,C 标准并未规定这 12 种类型的精度,只对它们施加约束。它们取决于许多由实现定义的因素。
标准确实规定了一件事:各种有符号类型的可能值域,必须按照等级彼此包含:
signed char ⊆ short ⊆ int ⊆ long ⊆ long long但这种包含不必是严格包含。例如,在许多平台上,int 和 long 的值集合相同,但两者仍是不同类型。
六种无符号类型也有类似的包含关系:
bool ⊆ unsigned char ⊆ unsigned short ⊆ unsigned
⊆ unsigned long ⊆ unsigned long long2
不过请记住,无论执行何种算术或比较,窄无符号类型都会提升为 signed int,而不是这幅关系图可能暗示的 unsigned int。
比较有符号类型与无符号类型的范围要困难得多。显然,无符号类型绝不可能包含有符号类型的负值。对于非负值,相同等级的类型具有如下包含关系:
有符号类型的非负值 ⊆ 无符号类型的值也就是说,对于给定等级,有符号类型的非负值都能装入无符号类型。在你会遇到的任何现代平台上,这都是严格包含:无符号类型拥有一些无法装入有符号类型的值。例如,常见的一组最大值是:signed int 为 2³¹ − 1 = 2,147,483,647,unsigned int 为 2³² − 1 = 4,294,967,295。
整数类型之间的关系取决于平台,因此要以可移植方式为特定用途选择“最佳”类型,可能相当繁琐。幸好编译器实现能帮上忙:它提供 size_t 之类的 typedef 来表示特定性质。
要点 5.2 #3
大小、基数或序数应使用 size_t。
无符号类型最为方便,因为只有它们的算术始终按数学上的模运算定义。无符号溢出不会引发信号,也最利于优化。第 5.7.1 节将更详细地介绍它们。
要点 5.2 #4
对不可能为负的小数量使用 unsigned。
如果程序所需的值可能为正或负,但不包含小数部分,应使用有符号类型(见第 5.7.5 节)。
要点 5.2 #5
对带符号的小数量使用 signed。
要点 5.2 #6
对带符号的大差值使用 ptrdiff_t。
如果要对 0.5 或 3.771’89E+89 之类带小数的值做计算,请使用浮点类型(见第 5.7.8 节)。
要点 5.2 #7
浮点计算使用 double。
要点 5.2 #8
复数计算使用 double complex。
表 5.2 若干用于专门场景的语义算术类型
| 类型 | 头文件 | 定义语境 | 含义 |
|---|---|---|---|
size_t | <stddef.h> | “大小”和基数所用类型 | |
ptrdiff_t | <stddef.h> | 大小之差所用类型 | |
uintmax_t | <stdint.h> | 预处理器使用的无符号整数类型 | |
intmax_t | <stdint.h> | 预处理器使用的有符号整数类型 | |
time_t | <time.h> | time(0)、difftime(t1, t0) | 从纪元起计、以秒表示的日历时间 |
clock_t | <time.h> | clock() | 处理器时间 |
C 标准还定义了许多其他类型,其中包括一些为专门用途建模的算术类型,表 5.2 列出了一部分。中间两个类型是预处理器做算术或比较时所用的类型。C23 之前,它们是编译器所支持的最大宽度类型;如今这一限制已经放宽,某些情况下可能存在更宽的扩展整数类型(见第 5.7.6 节)。
time_t 和 clock_t 用于处理时间。它们属于语义类型,因为时间计算的精度可能因平台而异。若要得到一个以秒为单位、能参与算术的时间,可以使用 difftime 函数:它计算两个时间戳之差。clock_t 值反映的是平台对处理器时钟周期的建模,因此其时间单位通常远小于一秒;可以用 CLOCKS_PER_SEC 把这类值换算成秒。
5.3 指定值
我们已经见过多种指定数值常量(字面量)的方法:
123十进制整数字面量- 对我们大多数人而言,这是最自然的选择。
077八进制整数字面量- 它由一串数字指定,第一个数字是
0,后续数字介于0到7之间。例如,077的值是 63。这种写法如今只有历史价值,很少使用。唯一常用的八进制字面量就是0本身。 0xFFFF十六进制整数字面量- 它以
0x开头,后跟由0…9和a…f组成的数字序列。例如,0xbeaf的值是 48,815。a…f和x也可以大写,例如0XBEAF。 0b1010二进制整数字面量- 它以
0b开头,后跟由0或1组成的数字序列。例如,0b1010的值是 10。开头的0b也可以写成0B。二进制字面量由 C23 引入。 1.7E-13十进制浮点字面量- 带小数点的形式大家都很熟悉;此外还有带指数的“科学记数法”。一般形式
mEe解释为 m · 10ᵉ。 0x1.7aP-13十六进制浮点字面量- 这种写法通常用来描述浮点值,便于写出那些具有精确表示的值。一般形式
0XhPe解释为 h · 2ᵉ,其中 h 写成十六进制小数,指数 e 仍写成十进制数。 'a'整数字符字面量- 字符放在撇号
'之间,例如'a'或'?'。C 标准只间接规定了它们的值。例如,'a'对应拉丁字母字符 a 的整数编码。字符字面量中的反斜线\具有特殊意义;例如,我们已经见过表示换行符的'\n'。 "hello"**字符串字面量**- 它指定文本,例如
printf和puts函数所需的文本。与字符字面量一样,反斜线\具有特殊意义。[^13]
除了最后一种,所有字面量都是数值常量,也就是指定数的常量。[4] 字符串字面量是例外,它可以指定编译期已知的文本。如果不能把字符串字面量拆成几段,在代码里嵌入大段文本就会十分繁琐:
puts("first line\n"
"another line\n"
"first and "
"second part of the third line");2
3
4
要点 5.3 #1
相邻的字符串字面量会连接在一起。
数的规则要稍微复杂一些。
要点 5.3 #2
数值字面量从不为负。
也就是说,如果写出 -34 或 -1.5E-23,开头的符号并不是数字的一部分,而是作用于其后数字的取负运算符。我们很快会看到这一点在哪里至关重要。听起来也许古怪,但指数中的负号确实属于浮点字面量的一部分。
我们已经看到(要点 5.1.2 #1),所有字面量不仅有值,还有类型。不要把“字面量具有正值”与其类型混为一谈;它的类型完全可能是有符号类型。
要点 5.3 #3
十进制整数字面量是有符号的。
这是一项重要性质:我们自然希望表达式 -1 是有符号的负值。
为了确定整数字面量的确切类型,C 总是采用“首个适配”规则。
要点 5.3 #4
十进制整数字面量采用三种有符号类型中第一个能够容纳其值的类型。
这条规则可能产生令人惊讶的效果。假设某个平台上 signed 的最小值是 −2¹⁵ = −32,768,最大值是 2¹⁵ − 1 = 32,767。字面量 32’768 无法装入 signed,所以它的类型是 signed long,表达式 -32’768 的类型也就是 signed long。因此,在这样的平台上,signed 类型的最小值无法写成一个字面量。
练习 15
证明:如果 signed long long 的最小值和最大值具有类似性质,那么该平台的最小整数值无法写成一个字面量与负号的组合。
要点 5.3 #5
同一个值可以具有不同类型。
推导二进制、八进制或十六进制字面量的类型要更复杂一些。如果值无法装入有符号类型,这些字面量也可以采用无符号类型。在前面的示例中,十六进制字面量 0x7FFF 的值是 32,767,因此类型是 signed。与十进制字面量不同,字面量 0x8000(以十六进制写出的值 32,768)的类型是 unsigned,表达式 -0x8000 的类型也仍是 unsigned。
练习 16
证明:如果 unsigned 的最大值是 2¹⁶ − 1,那么 -0x8000 的值仍是 32,768。
要点 5.3 #6
不要用二进制、八进制或十六进制字面量表示负值。
因此,负值只剩下一种选择。
要点 5.3 #7
负值应使用十进制字面量。
表 5.3 假定 signed 和 unsigned 采用常见的 32 位表示时,若干字面量及其类型
| 字面量 x | 值 | 类型 | −x 的值 |
|---|---|---|---|
2’147’483’647 | +2,147,483,647 | signed | −2,147,483,647 |
2’147’483’648 | +2,147,483,648 | signed long | −2,147,483,648 |
4’294’967’295 | +4,294,967,295 | signed long | −4,294,967,295 |
0x7FFF’FFFF | +2,147,483,647 | signed | −2,147,483,647 |
0x8000’0000 | +2,147,483,648 | unsigned | +2,147,483,648 |
0xFFFF’FFFF | +4,294,967,295 | unsigned | +1 |
1 | +1 | signed | −1 |
1U | +1 | unsigned | +4,294,967,295 |
一个常见错误是把十六进制字面量赋给 signed,并期望它表示负值。考虑声明 int x = 0xFFFF’FFFF。这种写法假定该十六进制值与有符号值 −1 的二进制表示相同。在 signed 为 32 位的体系结构上,这一点大多成立(但并非全部)。然而,没有任何规则保证实际值 +4,294,967,295 会转换成值 −1。表 5.3 给出了一些值得注意的字面量及其值和类型。
因此,可选前缀(0、0b 或 0x)不仅指定读取整数字面量所用的进制,还可能间接影响推导出的类型。在字面量末尾追加后缀,可以改变推导结果。
表 5.4 整数字面量的后缀及其类型
| 后缀 | 类型 |
|---|---|
l 或 L | 至少为 long |
ll 或 LL | long long |
wb 或 WB | 对某个足够大的 N,采用 _BitInt(N) |
u 或 U | 强制采用无符号类型 |
例如,1U 的值为 1,类型为 unsigned;1L 的类型是 signed long;1ULL 的值同样为 1,但类型是 unsigned long long。请注意,本书用等宽字体表示 1ULL 之类的 C 字面量,以区别于用普通字体表示的数学值 1。
练习 17
证明:表达式 -1U、-1UL 和 -1ULL 分别具有三种无需提升的无符号类型,并分别取这些类型的最大值。
有了后缀,就可以强制整数字面量的类型至少达到某个等级。对于十进制整数字面量,如果有一个 l 或 L,那么值能装入时类型为 long,否则为 long long。如果有两个(ll 或 LL),类型固定为 long long。对于带前缀的整数字面量(0、0b 或 0x),这些后缀仍可能视值的大小得到 unsigned long 或 unsigned long long。要强制采用无符号类型,可以在后缀中加上 u 或 U。
C23 引入的后缀 wb 或 WB 强制字面量采用特定的位精确类型。它可以与 u 或 U 组合;无论采用什么进制,只有这种后缀能保证得到指定符号性的类型。我们稍后会在第 5.7.7 节介绍这些类型。
请记住,值 0 十分重要,重要到有好几种等价写法:0、0x0 和 '\0' 都是同一个值,即类型为 signed int 的 0。零没有十进制整数写法:0.0 是值 0 的十进制写法,但它被视为类型为 double 的浮点值。
要点 5.3 #8
不同字面量可以具有相同的值。
对于整数,这条规则看起来近乎不言自明;对于浮点字面量,却没有那么明显。浮点值只是对字面所写数值的近似,因为小数部分的二进制位可能被截断或舍入。
要点 5.3 #9
十进制浮点字面量的实际值可能不同于其字面值。
例如,在我的机器上,字面量 0.2 的值是:
0.200,000,000,000,000,011,1因此,字面量 0.2 和 0.200’000’000’000’000’011’1 具有相同的值。
十六进制浮点字面量旨在更好地对应浮点值的二进制表示。事实上,在大多数现代体系结构上,只要这种字面量的位数不过多,它就会与字面值精确对应。遗憾的是,这些怪物对凡人来说几乎无法阅读。例如以下两个字面量:
0x1.9999’9AP-3
0xC.CCCC’CCCC’CCCC’CCDP-62
它们分别对应:
1.600,000,023,84 × 2⁻³
12.800,000,000,000,000,000,2 × 2⁻⁶2
因此,以十进制浮点数表示时,它们的值近似为:
0.200,000,002,98
0.200,000,000,000,000,000,0032
可见两个字面量的值非常接近,写成十六进制浮点字面量后却显得相距甚远。
最后,浮点字面量后面可以跟 f 或 F,表示 float;也可以跟 l 或 L,表示 long double。没有后缀时,类型为 double。请注意,对于同一个字面文本,不同字面量类型通常会得到不同的值。下面是一个典型示例:
float | double | long double | |
|---|---|---|---|
| 字面量 | 0.2F | 0.2 | 0.2L |
| 值 | 0x1.9999’9AP-3F | 0x1.9999’9999’9999AP-3 | 0xC.CCCC’CCCC’CCCC’CCDP-6L |
要点 5.3 #A
字面量具有值、类型和二进制表示。
5.3.1 复数常量
并非所有 C 平台都一定支持复数类型。可以检查 STDC_NO_COMPLEX 来确认这一点。要获得复数类型的完整支持,应包含头文件 <complex.h>。如果你已经为数值函数使用了 <tgmath.h>,这一步就已隐式完成。
遗憾的是,C 没有能直接写出复数值的标准字面量。它只提供若干宏,[5] 让复数类型用起来更方便。
指定复数值的第一种方式是宏 CMPLX,它把两个浮点值——实部和虚部——组合成一个复数值。例如,CMPLX(0.5, 0.5) 是一个 double complex 值,实部与虚部均为二分之一。类似地,还有用于 float complex 的 CMPLXF,以及用于 long double complex 的 CMPLXL。
另一种更方便的方式由宏 I 提供。它表示一个类型为 float complex 的常量,满足 I*I 的值为 −1。程序常用单个大写字母作宏名,表示在整个程序中固定不变的数。这本身就不是什么好主意(单字母名称就那么几个),而 I 你更是绝对不该碰。
要点 5.3.1 #1
I 保留用于虚数单位。
借助 I,可以用接近常规数学记法的方式写出复数类型的常量。例如,0.5 + 0.5*I 的类型是 double complex,0.5F + 0.5F*I 的类型是 float complex。如果实部和虚部混用了不同类型的字面量(例如 float 与 double),编译器会把结果隐式转换为两种类型中较宽的那一种。另一种写复数常量的方式是复数字面量:在浮点字面量后附加一个 i,例如 0.5i 或 0.5IF。这种字面量如今已得到广泛支持,但遗憾的是(尚)未标准化,因此不能依赖它。
挑战 5:复数
你能否把求导程序(挑战 2)扩展到复数域,也就是编写接收并返回 double complex 值的函数?
5.4 隐式转换
从 -1 和 -1U 这样的示例可以看到,操作数的类型会影响运算符表达式的类型。前者是 signed int,后者是 unsigned int。对初学者而言,后者尤其令人意外:unsigned int 没有负值,因此 -1U 的值是一个很大的正整数。
要点 5.4 #1
一元 - 和 + 的类型,就是其提升后操作数的类型。
因此,这两个运算符通常不会改变类型。在确实会改变类型的场合,就要依靠 C 的隐式转换策略:把某一类型的值转换为所需类型。仍假定 −2,147,483,648 和 2,147,483,647 分别是 signed int 的最小值与最大值,来看以下示例:
double a = 1; // 无害:值能装入该类型
signed short b = -1; // 无害:值能装入该类型
signed int c = 0x8000’0000; // 危险:值对该类型过大
signed int d = -0x8000’0000; // 危险:值对该类型过大
signed int e = -2’147’483’648; // 无害:值能装入该类型
unsigned short g = 0x8000’0000; // 丢失信息:结果值为 02
3
4
5
6
a 和 b 的初始化无害。相应值完全位于目标类型的范围内,因此 C 编译器可以静默地转换它们。
接下来,对 c 和 d 的两次转换都有问题。前面已经看到,0x8000’0000 的类型是 unsigned int,无法装入 signed int。所以 c 得到的是一个由实现定义的值;我们必须知道平台决定怎样处理这种情况。它可能只是复用右侧值的位模式,也可能终止程序。与所有由实现定义的性质一样,平台应当把所选方案写入文档;但要注意,这种选择可能随编译器版本更新而改变,也可能随编译器实参切换。
d 的情况更加复杂。0x8000’0000 的值是 2,147,483,648,我们可能以为 -0x8000’0000 就是相应负值。但由于 -0x8000’0000 的实际结果仍是 2,147,483,648,它会遇到与 c 相同的问题。
练习 19
假定 unsigned int 的最大值是 0xFFFF’FFFF,证明 -0x8000’0000 == 0x8000’0000。
e 则又是无害的,因为我们用的是 -2’147’483’648:它是取负后的十进制字面量,类型为 signed long,实际值也正合预期。这个值能装入 signed int,所以转换毫无问题。
最后一个示例 g 的后果比较含混。对无符号类型而言,过大的值会按模转换。具体到这里,如果假定 unsigned short 的最大值是 2¹⁶ − 1,结果值就是 0。这样的“窄化”转换是否符合预期,往往难以判断。
要点 5.4 #2
避免窄化转换。
要点 5.4 #3
不要用窄类型执行算术。
对于加法和乘法等具有两个操作数的运算符,类型规则还会更复杂,因为两个操作数可能具有不同类型。下面是一些涉及浮点类型的操作:
1 + 0.0 // 无害;double
1 + I // 无害;complex float
INT_MAX + 0.0F // 可能损失精度;float
INT_MAX + I // 可能损失精度;complex float
INT_MAX + 0.0 // 通常无害;double2
3
4
5
前两个示例无害:整数字面量 1 的值完全能装入 double 或 complex float。事实上,在大多数这样的混合操作中,只要一种类型的范围能装入另一种类型,结果就采用范围较宽的类型。
接下来两个示例有问题,因为 INT_MAX(signed int 的最大值)通常无法装入 float 或 complex float。例如,在我的机器上,INT_MAX + 0.0F 与 INT_MAX + 1.0F 相同。最后一行说明,在大多数平台上,使用 double 执行该操作不会出问题。不过,在现有或未来采用 64 位 int 的平台上,仍可能出现类似的精度问题。
由于不同整数类型的值域并不存在严格的包含关系,混合有符号值和无符号值的操作,其类型推导可能极其棘手:
-1 < 0 // true,无害,符号性相同
-1L < 0 // true,无害,符号性相同
-1U < 0U // false,无害,符号性相同
-1 < 0U // false,危险,符号性混合
-1U < 0 // false,危险,符号性混合
-1L < 0U // 结果依平台而定,危险,符号性相同或混合
-1LL < 0UL // 结果依平台而定,危险,符号性相同或混合2
3
4
5
6
7
前三项比较是无害的,因为即使混用了不同类型的操作数,也没有混合符号性。在这些情况下,可能值的范围彼此整齐地包含,C 只需把另一个类型转换成较宽的类型,再在该类型中比较。
接下来两项比较的结果明确无误,却可能出乎新手的意料。事实上,两项比较中的所有操作数都会转换为 unsigned int。因此,两个取负后的值都转换成很大的无符号值,比较结果为 false。
最后两项比较的问题更大。在满足
INT_WIDTH < LONG_WIDTH的平台上,0U 会转换成 0L,因此倒数第二项比较为 true。而在满足
INT_WIDTH == LONG_WIDTH的平台上,-1L 会转换成 -1U(即 UINT_MAX),因此该比较为 false。最后一项比较也有类似结论,但要注意,这两项比较的结果很可能并不相同。
像最后两项这样的示例,可能引发关于有符号类型与无符号类型孰优孰劣的无休止争论。但它们只说明一件事:混合有符号操作数与无符号操作数时,语义并不总是清晰。有些情况下,无论选择哪一种隐式转换,都会产生问题。
要点 5.4 #4
避免对符号性不同的操作数执行操作。
要点 5.4 #5
只要能够做到,就使用无符号类型。
要点 5.4 #6
选择算术类型时,应确保隐式转换无害。
5.5 初始化器
我们已经看到(第 2.3 节),初始化器是对象定义的重要组成部分。初始化器有助于保证程序执行始终处于已定义的状态:每次访问对象时,它都有一个已知的值,由此确定抽象状态机的状态。
要点 5.5 #1
所有对象都应初始化。
这条规则只应为必须高度优化的代码破例。[6] 对于我们目前能写出的大部分代码,现代编译器都能把一个值追溯到它最后一次赋值或初始化。多余的初始化或赋值会被直接优化掉。
对于整数和浮点数等标量类型,初始化器只包含一个能转换为相应类型的表达式。我们已经见过许多这样的示例。初始化表达式外面也可以加上 {}:
double a = 7.8;
double b = 2 * a;
double c = { 7.8 };
double d = { 0 };2
3
4
其他类型的初始化器必须包含这些 {}。例如,数组初始化器包含各个元素的初始化器,每项后面都有逗号:
double A[] = { 7.8, };
double B[3] = { 2 * A[0], 7, 33, };
double C[] = { [0] = 6, [3] = 1, };2
3
[0]
A double 7.8
[0] [1] [2]
B double 15.6 double 7.0 double 33.0
[0] [1] [2] [3]
C double 6.0 double 0.0 double 0.0 double 1.02
3
4
5
6
7
8
如前所述,如果没有长度说明,数组就具有“不完整类型”。随后,初始化器会补全该类型,完全确定数组长度。这里,A 只有一个元素,C 则有四个元素。在前两个初始化器中,每个标量初始化作用于哪个元素,由它在列表中的位置推导;例如,B[1] 初始化为 7。C 采用的形式称为“指派初始化器”。这种形式要可取得多,因为声明稍有变动时,代码也更容易保持稳健。
要点 5.5 #2
所有聚合数据类型都应使用指派初始化器。
如果不知道怎样初始化类型 T 的对象,默认初始化器:
T a = { }总能完成任务。
要点 5.5 #3
{ } 对所有对象都是有效的初始化器。
这项特性直到 C23 才引入;此前必须使用 {0},并且要经过相当复杂的推理才能说明它为什么有效。默认初始化器也可以用于变长数组(见第 6.1.3 节),而变长数组过去没有初始化器语法。
在初始化器中,我们经常需要指定一些对程序具有特定含义的值。
5.6 具名常量
即使是小程序,也常有这样的问题:某些用于特定目的的特殊值,在代码中到处重复出现。一旦这些值因故改变,程序就会支离破碎。来看一个人为构造的情境:我们有几个字符串数组,[7] 并希望对它们执行一些操作。
char const*const bird[3] = {
"raven",
"magpie",
"jay",
};
char const*const pronoun[3] = {
"we",
"you",
"they",
};
char const*const ordinal[3] = {
"first",
"second",
"third",
};
...
for (unsigned i = 0; i < 3; ++i)
printf("Corvid %u is the %s\n", i, bird[i]);
...
for (unsigned i = 0; i < 3; ++i)
printf("%s plural pronoun is %s\n", ordinal[i], pronoun[i]);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
这里在多处使用了常量 3,而它有三种彼此并无多少关联的“含义”。例如,鸦科鸟类集合中每增加一种鸟,就要分别修改两处代码。在真实环境里,可能有更多代码位置依赖这个特定值;在大型代码库中,维护起来会相当繁琐。
要点 5.6 #1
具有特定含义的所有常量都必须命名。
同样重要的是,要区分那些只是碰巧相等的常量。
要点 5.6 #2
含义不同的所有常量都必须彼此区分。
早期 C 用来指定具名常量的手段少得出奇,其术语甚至让人对“哪些构造真正产生编译期常量”困惑不已。因此,我们先要理清术语(第 5.6.1 节),再考察 C23 之前 C 唯一真正的具名常量:枚举常量(第 5.6.2 节)。借助枚举常量,可以把示例中几个用途不同的 3 换成更能说明含义的写法。第二种通用机制是宏(第 5.6.3 节),它以简单的文本替换补充了这一功能。只有当替换内容由前文所见基本类型的字面量组成时,宏才会产生编译期常量。最后,我们还会区分一种称为复合字面量的无名临时对象(第 5.6.4 节),以及 C23 新增的 constexpr 对象;后者同样可以充当真正的具名或无名常量(第 5.6.5 节)。
5.6.1 只读对象
不要把“常量”一词与不可修改的对象混为一谈;“常量”在 C 中具有非常明确的含义。例如,在前面的代码中,按照这里的术语,bird、pronoun 和 ordinal 都不是常量,而是 const 限定的对象。这个限定符规定我们无权改变该对象。对于 bird,数组项和实际字符串都不能修改;如果试图修改,编译器应当给出诊断。
要点 5.6.1 #1
具有 const 限定类型的对象是只读的。
这并不意味着编译器或运行时系统绝不会改变这种对象的值:程序的其他部分可能以不带该限定的方式看到同一对象,并修改它。你不能直接改写银行账户余额(只能读取它),并不意味着余额会永远不变。
还有一类只读对象,可惜它们的类型并不能防止它们被修改:字符串字面量。
要点 5.6.1 #2
字符串字面量是只读的。
如果今天才引入字符串字面量,它的类型几乎肯定会是 char const[],也就是由 const 限定字符组成的数组。遗憾的是,const 关键字加入 C 语言的时间远晚于字符串字面量;为了向后兼容,现状便一直保留了下来。[8]
bird 这样的数组还用到了另一种处理字符串字面量的技巧:用指针类型 char const*const “引用”字符串字面量。这种数组可以画成:
[0] [1] [2]
bird char const*const char const*const char const*const
↓ ↓ ↓
"raven" "magpie" "jay"2
3
4
也就是说,字符串字面量本身并不存储在 bird 数组内部,而是存放在其他位置;bird 只引用那些位置。这种机制的原理,要到很久以后的第 6.2 节和第 11 章才会讲到。
自 C23 起,还有一种由关键字 constexpr 标示的构造,它同样会产生只读对象。但与仅由 const 限定的对象不同,这些对象保证永不改变,其值在编译期已知。主要区别可以从以下示例中看出:
extern double const factor;
constexpr double π = 3.141’592’653’589’793’238’46;2
factor 的声明只告诉我们(以及编译器):某处存在一个我们无权改变的 double 对象,至于它的值在何时、何地确定,则未作说明。另一方面,π 的值随声明一并给出,并且在整个程序编译期间保持不变。稍后将更详细地介绍 constexpr。[9]
5.6.2 枚举
C 有一种简单的机制,能像示例所需的那样为小整数命名,称为“枚举”:
enum corvid { magpie, raven, jay, corvid_num, };
char const*const bird[corvid_num] = {
[raven] = "raven",
[magpie] = "magpie",
[jay] = "jay",
};
...
for (unsigned i = 0; i < corvid_num; ++i)
printf("Corvid %u is the %s\n", i, bird[i]);2
3
4
5
6
7
8
9
这段代码声明了一个新的整数类型 enum corvid,我们知道它有四个不同的值。你可能已经猜到,按位置确定的值从 0 开始:在示例中,magpie 的值为 0,raven 为 1,jay 为 2,corvid_num 为 3。最后这个 3 显然就是我们关注的那个 3。
要点 5.6.2 #1
枚举常量要么具有显式指定的值,要么具有按位置确定的值。
[magpie] [raven] [jay]
bird char const*const char const*const char const*const
↓ ↓ ↓
"magpie" "raven" "jay"2
3
4
请注意,这里数组项的顺序与之前不同。这正是枚举方法的优点之一:无须手动跟踪数组所用的顺序,枚举类型中固定的次序会自动处理好这一点。
现在,如果想增加另一种鸦科鸟,只需把它放进列表中 corvid_num 之前的任意位置。
清单 5.1 一个枚举类型及其相关字符串数组
enum corvid { magpie, raven, jay, chough, corvid_num, };
char const*const bird[corvid_num] = {
[chough] = "chough",
[raven] = "raven",
[magpie] = "magpie",
[jay] = "jay",
};2
3
4
5
6
7
与大多数其他窄类型一样,声明这里所给枚举类型的对象其实意义不大;这些对象在索引和算术中反正都会转换为 signed 或 unsigned。就连枚举常量本身也未必具有枚举类型。
要点 5.6.2 #2
如果简单枚举类型的所有枚举常量都能装入 signed int,这些常量就具有该类型。
因此,对于较小的值,真正有用的是常量,而不是新创建的类型。我们甚至无须为类型名提供标记,就能为所需的任意 signed int 常量命名:
enum { p0 = 1, p1 = 2*p0, p2 = 2*p1, p3 = 2*p2, };定义这些常量时,可以使用“整数常量表达式”(ICE)。ICE 提供编译期整数值,所受限制十分严格。它的值不仅必须能在编译期确定(不允许函数调用),计算时也不能以普通对象的求值作为操作数:
signed const o42 = 42;
constexpr signed c42 = 42;
enum {
b42 = 42, // 正确:42 是字面量。
c52 = o42 + 10, // 错误:o42 是对象。
b52 = b42 + 10, // 正确:b42 不是对象。
d52 = c42 + 10, // 正确:c42 是具名常量。
};2
3
4
5
6
7
8
这里的 o42 尽管带有 const 限定,仍然是对象,因此 c52 的表达式不是“整数常量表达式”。而 constexpr 对象 c42 可以自由用于这种语境。
要点 5.6.2 #3
整数常量表达式只能对声明为 constexpr 的对象求值。
因此,从原则上说,ICE 可以由整数与枚举字面量、枚举常量、constexpr 对象、alignof[10] 与 offsetof 子表达式,以及某些 sizeof 子表达式上的任意操作组成。[11]
C23 以前,即使某个值是 ICE,要用它定义枚举常量,也必须确保该值能装入 signed int。C23 改变了这一点。
要点 5.6.2 #4
如果枚举常量无法装入 signed int,那么只要可能,枚举类型就会调整为能够存放所有枚举常量的类型。
要点 5.6.2 #5
如果枚举常量无法装入 signed int,这些常量就采用枚举类型。
请注意,完全可能不存在能够容纳所有常量值的类型:
enum tooLarge { minimus = LLONG_MIN, maximus = ULLONG_MAX, };除非编译器找到一种比 signed long long 更宽的扩展整数类型,否则这一行很可能无法编译。
如果我们并不真正关心类型,枚举类型的这种自动调整可能很方便:
enum wide { minimal = LONG_MIN, maximal = LONG_MAX, };
typedef enum wide wide;2
long 是否比 signed 更宽取决于平台,所以 wide 的底层类型也视平台而定,可能是两者中的任何一种。C23 还引入了新语法,可以强制采用特定的底层类型:
enum wider : long { minimer = LONG_MIN, maximer = LONG_MAX, };
typedef enum wider wider;2
冒号后跟整数类型表示底层类型;即使各值原本都能装入 signed,这也会强制枚举常量采用枚举类型:
enum narrow : unsigned char { zero, one, };
typedef enum narrow narrow;2
枚举类型会调整到足以容纳全部常量,这一性质可能给类型的使用者带来意外,大概不应滥用。因此,只要可能,最好显式指定底层整数类型。
要点 5.6.2 #6
如果枚举常量有可能无法全部装入 signed int,就应指定枚举类型的底层整数类型。
当底层类型既可能是有符号类型,也可能是无符号类型时,这一点尤其重要:
enum large { down = 0, up = 0xFFFF’FFFF, }; // 有歧义,不要使用
typedef enum large large;2
这里,常量 0xFFFF’FFFF 可能采用 signed、unsigned、signed long、unsigned long、signed long long 或 unsigned long long 中的任意类型,具体取决于 signed int 的宽度;底层整数类型因而也可能成为其中任意一种。为了照顾偶尔读到这段代码的人,最好明确写出意图:
enum eInt : signed int { dInt = 0, uInt = 0xFFFF’FFFF, };
typedef enum eInt eInt;
enum eSig : typeof(4’294’967’295) {
dSig = 0,
uSig = 4’294’967’295,
};
typedef enum eSig eSig;
enum e32 : uint32_t { d32 = 0, u32 = 0xFFFF’FFFF, };
typedef enum e32 e32;2
3
4
5
6
7
8
9
只有当 signed int 的宽度大于 32 时,第一个定义 eInt 才能编译。第二个定义 eSig 使用 typeof 特性(很久以后,第 18 章会详细介绍它),明确表示我们要的是该十进制常量的类型,而这个类型总是有符号的。第三个定义 e32 使用类型定义 uint32_t(见后文),表示所求类型是宽度恰为 32 的无符号类型。(译者注:原书此处作“宽度至少为 32”,那是 uint_least32_t 的含义,已改正。)
5.6.3 宏
C23 之前,除了 signed int,C 语言没有别的机制能声明其他类型的严格意义上的常量。C 提供的是另一种强大机制:对程序代码执行文本替换,也就是“宏”。宏由预处理器指令 #define 引入:
# define M_PI 3.141’592’653’589’793’238’46这个宏定义的效果是:后续程序代码中的标识符 M_PI 会被替换为该 double 常量。这样的宏定义由五个部分组成:
- 起始字符
#,它必须是该行第一个非空白字符; - 关键字
define; - 要声明的标识符,这里是
M_PI; - 替换文本,这里是
3.141’592’653’589’793’238’46; - 结尾的换行符。
借助这个技巧,我们可以为 unsigned、size_t 和 double 常量声明文本替换。事实上,由实现规定的 size_t 上限 SIZE_MAX,以及已经见过的许多其他系统特性,也都以这种方式定义:
EXIT_SUCCESS、not_eq、complex……本书通常用深红色印刷这些 C 标准宏。
C 标准中这些示例的拼写方式,并不代表绝大多数软件项目的通行约定。多数项目都有相当严格的规则,让宏在周围代码中一眼就能认出来。
要点 5.6.3 #1
宏名全部使用大写字母。
只有在理由充分时才偏离这条规则,尤其是在进入第 3 级之前不要偏离。
5.6.4 复合字面量
如果某个类型没有能描述其常量的字面量,事情就更复杂了。这时,宏的替换文本中必须使用“复合字面量”。复合字面量具有如下形式:
(T){ INIT }也就是括号中的类型,后跟一个初始化器。下面是一个示例:
# define CORVID_NAME /**/ \
(char const*const[corvid_num]){ \
[chough] = "chough", \
[raven] = "raven", \
[magpie] = "magpie", \
[jay] = "jay", \
}2
3
4
5
6
7
有了它,就可以省略 bird 数组,并重写循环:
for (unsigned i = 0; i < corvid_num; ++i)
printf("Corvid %u is the %s\n", i, CORVID_NAME[i]);2
借助宏定义中的复合字面量,我们可以声明一种行为近似于所选类型常量的东西,但它并不是前面所说意义上的常量。
要点 5.6.4 #1
复合字面量定义一个对象。
总的来说,这种形式的宏有若干陷阱:
- 到目前为止所介绍的复合字面量不适用于 ICE。
- 就这里声明具名常量的目的而言,类型
T应当具有const限定。这样,优化器就有更多余地为这类宏替换生成良好的二进制代码。 - 宏名与复合字面量的
()之间必须至少有一个空格;这里用/**/注释表示。否则,它会被解释为类函数宏定义的开头。类函数宏要到很久以后才会介绍。 - 行尾最后一个字符可以是反斜线
\,用来把宏定义延续到下一行。 - 宏定义末尾绝不能有
;。请记住,这一切都只是文本替换。
要点 5.6.4 #2
不要把结尾分号藏进宏里。
此外,为了让宏易读,请体谅偶尔阅读你代码的可怜人。
要点 5.6.4 #3
把宏的续行标记右对齐到同一列。
从示例可以看出,这有助于一眼辨明整个宏定义的范围。
5.6.5 constexpr 构造
在某些语境中,这些技巧并没有多大帮助。例如,我们可能需要为复杂类型提供具名常量,并把它用作文件作用域中的初始化器;这里的初始化器必须是常量表达式。C23 引入了 constexpr 构造,它既可以用于声明,也可以用于复合字面量。与前面宏 M_PI 等价的声明如下:
constexpr double π = 3.141’592’653’589’793’238’46;使用这样的 constexpr 有一个优点:常量在声明处就会受到编译期检查,凡是会改变值的转换都是错误。例如:
constexpr unsigned πflat = 3.141’592’653’589’793’238’46; // 错误这会产生编译错误,因为右侧转换为左侧的 unsigned 类型时,小数点后的有效数字丢失。
要点 5.6.5 #1
constexpr 的初始化器必须精确适配。
constexpr 也可用于复合字面量:
# define CORVID_NAMES /**/ \
(constexpr char[8][corvid_num]){ \
[chough] = "chough", \
[raven] = "raven", \
[magpie] = "magpie", \
[jay] = "jay", \
}2
3
4
5
6
7
请注意,这里改成了一个包含 corvid_num 个元素的数组,而每个元素又是一个含 8 个字符的数组。每个 8 字符数组先用指定值初始化,其余部分以零填充。因此,整个复合字面量大致可以画成:
[magpie] [raven] [jay] [chough]
char const[8] char const[8] char const[8] char const[8]2
例如,第一个数组元素本身也是数组,可以表示为:
[0] [1] [2] [3]
char const 'm' char const 'a' char const 'g' char const 'p' …
[4] [5] [6] [7]
char const 'i' char const 'e' char const 0 char const 02
3
4
有了 constexpr,编译器就知道这些字符在执行期间都不会改变,并会隐式加上 const 限定。编译器可以利用这一点提高程序效率:也许让程序在某种意义上更快,也许减少空间占用。如果直接对宏背后的数组取下标(例如 CORVID_NAMES[raven]),就不需要整个数组,编译器可以直接使用相应的字符串字面量(这里是 "raven")。更进一步,编译器可以让所有以 raven 为下标的出现位置共享同一个字符串字面量 "raven",让所有以 magpie 为下标的位置共享另一个字面量,依此类推。
5.7 二进制表示
类型的二进制表示是一种模型,用来描述该类型可能具有的值。它不同于内存中的对象表示;后者描述的是给定类型的值在物理存储中(或多或少具体地)怎样存放。
要点 5.7 #1
同一个值可以具有不同的二进制表示。
5.7.1 无符号整数
我们已经看到,对无符号整数类型而言,标准算术操作可以用简洁、封闭的数学方式描述。正如要点 4.2.2 #4 所述,它们对算术操作封闭。用数学术语说,它们实现了一个环 ℤN,也就是对某个数 N 取模所得的整数集合。可表示值为 0, …, N − 1。最大值 N − 1 完全决定这种无符号整数类型;该值通过名称以 _MAX 结尾的宏提供。对于基本无符号整数类型,这些宏是 UINT_MAX、ULONG_MAX 和 ULLONG_MAX,由 <limits.h> 提供。前面见过的 size_t 上限,则是来自 <stdint.h> 的 SIZE_MAX。
非负整数值的二进制表示始终名副其实:这样的数由称为“位”的二进制数字 b₀、b₁、…、bp−1 表示。每一位的值均为 0 或 1。该数的值按下式计算:
p−1
(1) Σ bᵢ · 2ⁱ
i=02
3
这种二进制表示中的 p 称为底层类型的“精度”;对于无符号类型,它也等于宽度。所有无符号类型的这一数值,都可以通过相应的宏得到,例如 UINT_WIDTH、ULONG_WIDTH 和 ULLONG_WIDTH。b₀ 称为最低有效位(LSB),bp−1 称为最高有效位(MSB)。
在所有值为 1 的位 bᵢ 中,索引 i 最小者称为“置位的最低有效位”,索引最高者称为“置位的最高有效位”。例如,对于精度 p = 16 的无符号类型,值 240 的 b₄、b₅、b₆ 与 b₇ 均为 1。二进制表示中的其他所有位都为 0;置位的最低有效位是 b₄,置位的最高有效位是 b₇。由式 (1) 立即可见,2ᵖ 是该类型无法表示的第一个值。因此 N = 2ᵖ,并有如下结论。
要点 5.7.1 #1
任何整数类型的最大值都具有 2ᵖ − 1 的形式。
请注意,在讨论非负值的表示时,我们尚未涉及类型的符号性。这些规则同等适用于有符号类型与无符号类型。幸运的是,对无符号类型来说,前面的内容已足以完整描述它们。
要点 5.7.1 #2
无符号整数类型上的算术由其精度决定。
最后,表 5.5 列出本书常用若干标量类型的界限。
表 5.5 本书所用标量类型的界限
| 名称 | [最小值, 最大值] | 所在头文件 | 典型范围 |
|---|---|---|---|
size_t | [0, SIZE_MAX] | <stdint.h> | [0, 2ʷ − 1],w = 32 或 64 |
double | [±DBL_MIN, ±DBL_MAX] | <float.h> | [±2−w−2, ±2ʷ],w = 1,024 |
signed | [INT_MIN, INT_MAX] | <limits.h> | [−2ʷ, 2ʷ − 1],w = 31 |
unsigned | [0, UINT_MAX] | <limits.h> | [0, 2ʷ − 1],w = 32 |
bool | [false, true] | <stdbool.h> | [0, 1] |
ptrdiff_t | [PTRDIFF_MIN, PTRDIFF_MAX] | <stdint.h> | [−2ʷ, 2ʷ − 1],w = 31 或 63 |
char | [CHAR_MIN, CHAR_MAX] | <limits.h> | [−128, 127] 或 [0, 255] |
unsigned char | [0, UCHAR_MAX] | <limits.h> | [0, 255] |
5.7.2 位集合与逐位运算符
无符号类型的二进制表示很简单,因此我们还能把它们用于另一种与算术没有直接关系的目的:表示位集合。位集合是对无符号值的另一种解释。我们假定该值表示基本集合 V = {0, …, p − 1} 的一个子集;如果位 bᵢ 存在,就把元素 i 视为集合成员。
有三个二元运算符作用于位集合:|、& 和 ^。它们分别表示集合并 A ∪ B、集合交 A ∩ B 和对称差 A△B。来看一个示例:取 A = 240,表示 {4, 5, 6, 7};再取 B = 287,表示位集合 {0, 1, 2, 3, 4, 8},如表 5.6 所示。计算这些操作的结果时,不需要知道基本集合的总大小,因而也不需要知道精度 p。与算术运算符一样,它们分别还有对应的赋值运算符 &=、|= 和 ^=。
表 5.6 逐位运算符的效果
| 逐位操作 | 值 | 十六进制 | b₁₅ … b₈ b₇ … b₀ | 集合操作 | 集合 |
|---|---|---|---|---|---|
| V | 65,535 | 0xFFFF | 0b11111111’11111111 | ||
| A | 240 | 0x00F0 | 0b00000000’11110000 | ||
~A | 65,295 | 0xFF0F | 0b11111111’00001111 | V \ A | |
-A | 65,296 | 0xFF10 | 0b11111111’00010000 | ||
| B | 287 | 0x011F | 0b00000001’00011111 | ||
A|B | 511 | 0x01FF | 0b00000001’11111111 | A ∪ B | |
A&B | 16 | 0x0010 | 0b00000000’00010000 | A ∩ B | |
A^B | 495 | 0x01EF | 0b00000001’11101111 | A△B |
还有另一个作用于值中各位的运算符:补运算符 ~。补集 ~A 的值为 65,295,对应集合 {0, 1, 2, 3, 8, 9, 10, 11, 12, 13, 14, 15}。这种位补运算始终依赖类型的精度 p。
练习 26
证明 A \ B 可以用 A - (A&B) 计算。
练习 27
证明 V + 1 为 0。
练习 28
证明 A^B 等价于 (A - (A&B)) + (B - (A&B)),也等价于 A + B - 2*(A&B)。
练习 29
证明 A|B 等价于 A + B - (A&B)。
练习 30
证明 ~B 可以用 V - B 计算。
练习 31
证明 -B = ~B + 1。
如果包含头文件 <iso646.h>,所有这些运算符也都可以用标识符写出:bitor、bitand、xor、or_eq、and_eq、xor_eq 和 compl。位集合的典型用途之一是标志,也就是控制程序某些设置的对象:
enum corvid { magpie, raven, jay, chough, corvid_num, };
#define FLOCK_MAGPIE 1U
#define FLOCK_RAVEN 2U
#define FLOCK_JAY 4U
#define FLOCK_CHOUGH 8U
#define FLOCK_EMPTY 0U
#define FLOCK_FULL 15U
int main(void) {
unsigned flock = FLOCK_EMPTY;
...
if (something) flock |= FLOCK_JAY;
...
if (flock&FLOCK_CHOUGH)
do_something_chough_specific(flock);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
这里,每种鸦科鸟对应的常量都是 2 的幂,因此其二进制表示中恰有一位置位。于是,鸟群的成员关系可以用运算符来处理:|= 向 flock 加入一种鸟;& 与某个常量配合,则可检验特定鸟类是否存在。
请注意运算符 & 与 &&、| 与 || 的相似性。如果把无符号值中的每一位 bᵢ 都视为真值,那么 & 会同时对各实参的所有位执行逻辑与。这是一个很好的类比,有助于记住这些运算符的具体拼写。另一方面,请牢记 || 和 && 采用短路求值,因此一定要把它们与逐位运算符明确区分。
自 C23 起,头文件 <stdbit.h> 提供了另一组位操作。其中包括:用 stdc_count_ones 统计值为 1 的位数(集合的大小);用 stdc_has_single_bit 检测是否恰有一位为 1(集合是否为单元素集合);用 stdc_bit_width 给出值为 1 的最高编号位;以及用 stdc_bit_floor 返回包含最高编号元素的单元素集合。由于这个头文件在 C23 中才新增,你的平台可能尚未提供。另一个新特性是预处理器测试 __has_include,可以查询是否能找到某个头文件:
#if !__has_include(<stdbit.h>)
# error "this file needs the <stdbit.h> header"
#endif2
3
5.7.3 移位运算符
下一组运算符在两种无符号值解释之间架起桥梁:一种把无符号值解释为数,另一种把它解释为位集合。左移操作 << 对应于把数值乘以相应的 2 的幂。例如,对于 A = 240,也就是集合 {4, 5, 6, 7},A << 2 等于 240 · 2² = 240 · 4 = 960,表示集合 {6, 7, 8, 9}。结果中无法装入该类型二进制表示的位会被直接丢弃。在这个示例中,A << 9 原本对应集合 {13, 14, 15, 16}(值为 122,880);但由于不存在第 16 位,结果集合为 {13, 14, 15},值为 57,344。
因此,对这种移位操作而言,精度 p 再次成为关键。无法装入的位会丢失,p 还限制了右操作数的可能值。
要点 5.7.3 #1
移位操作的第二个操作数必须小于精度。
还有一个类似的右移操作 >>,它把二进制表示向较低有效位移动。与此相应,它对应于除以 2 的某个幂所得的整数商。位置小于或等于移位值的位会从结果中丢弃。请注意,对这项操作而言,类型精度并不重要。
练习 32
证明:操作 x>>n 中“丢失”的位,对应余数 x % (1ULL << n)。
还有对应的赋值运算符 <<= 和 >>=。
左移运算符 << 的主要用途,是指定 2 的幂。在示例中,现在可以把那些 #define 改写为:
#define FLOCK_MAGPIE (1U << magpie)
#define FLOCK_RAVEN (1U << raven)
#define FLOCK_JAY (1U << jay)
#define FLOCK_CHOUGH (1U << chough)
#define FLOCK_EMPTY 0U
#define FLOCK_FULL ((1U << corvid_num)-1)2
3
4
5
6
这样一来,即使枚举有所改动,示例也能保持稳健。
5.7.4 布尔值
C 中的布尔数据类型也视为无符号类型。请记住,它只有 false 和 true 两个值,分别对应 0 和 1,因此没有负值。C23 以前,名称 bool[12] 以及常量 false 和 true 只能通过包含 <stdbool.h> 获得。如果必须维护旧代码库,或者需要保证对旧系统的向后兼容,仍应包含该头文件;在不需要它的系统上,包含它也不会有什么害处。
把 bool 视为无符号类型,多少有些牵强。向该类型的对象赋值并不遵循要点 4.2.2 #3 的模规则,而是遵循布尔值的特殊规则(要点 3.1 #1)。
你可能很少需要 bool 对象。只有当你想确保赋值时值总会归约为 false 或 true,它们才有用。早期 C 没有布尔类型,遗憾的是,许多经验丰富的 C 程序员至今仍不使用它。
5.7.5 有符号整数
有符号类型比无符号类型略为复杂。C 实现必须决定两件事:
- 算术溢出时会发生什么?
- 如何表示有符号类型的符号?
有符号类型与无符号类型按整数转换等级成对出现,但表 5.1 中有两个显著例外:char 和 bool。有符号类型的二进制表示受到前面所见包含关系图的约束。
要点 5.7.5 #1
正值的表示与符号性无关。
换言之,有符号类型的正值与相应无符号类型中的同一个值具有相同表示。正因如此,任何整数类型的最大值才能如此简单地表示(要点 5.7.1 #1):有符号类型同样具有精度 p,由它确定该类型的最大值。
标准接下来规定,有符号类型还包含一个附加位,即“符号位”。它为 0 时,值为正;它为 1 时,值为负。历史上,人们曾用不同方式借助符号位得到负数;C23 对此作出澄清,如今只允许以“二补码”作为符号表示。
从前还存在“符号与量值”和“反码”表示,但如今它们只剩历史意义,或只见于一些奇特的系统。在符号与量值表示中,量值取正值,符号位只表示前面有一个负号。反码则取相应正值,再把所有位逐位取反。这两种表示都有一个缺点:有两个值都等于 0,也就是正 0 和负 0。由于现役平台已不采用这些表示,它们已从 C 标准中移除;你只会在历史书或不怀好意的招聘笔试中遇到它们。
二补码表示执行的算术与我们在无符号类型中看到的完全相同,只是把无符号值的上半区(最高位为 1 的那些值)解释为负数。下面两个函数基本足以把无符号值解释为有符号值:
bool is_negative(unsigned a) {
constexpr unsigned int_max = UINT_MAX/2;
return a > int_max;
}
bool is_signed_less(unsigned a, unsigned b) {
if (is_negative(a) != is_negative(b)) return a > b;
else return a < b;
}2
3
4
5
6
7
8
表 5.7 16 位无符号整数类型中的取负
| 操作 | 值 | b₁₅ … b₀ |
|---|---|---|
| A | 240 | 0b00000000’11110000 |
~A | 65,295 | 0b11111111’00001111 |
| +1 | 1 | 0b00000000’00000001 |
-A | 65,296 | 0b11111111’00010000 |
表 5.7 展示了怎样构造值 240 的负值。对于无符号类型,-A 可以计算为 ~A + 1。
练习 34
证明:对于无符号算术,A + ~A 是最大值。
练习 35
证明:对于有符号算术,A + ~A 是 −1。(译者注:原书此处作“无符号算术”,与练习 34 重复,且不符合本节主题,已改正。)
练习 36
证明:对于无符号算术,A + (~A + 1) == 0。
二补码表示对有符号类型与无符号类型执行完全相同的位操作,只把最高位置位的表示解释为负数。
这样处理之后,有符号整数算术又能大体正常地工作。遗憾的是,有一个陷阱使有符号算术的结果难以预测:溢出。无符号值必须回绕,而有符号溢出的行为未定义。下面两个循环看起来非常相似:
for (unsigned i = 1; i; ++i) do_something();
for ( signed i = 1; i; ++i) do_something();2
第一个循环会怎样运行,我们十分清楚:计数对象递增到 UINT_MAX,然后回绕为 0。这个过程可能需要一些时间,但在 UINT_MAX-1 次迭代后,循环会停止,因为 i 已经达到 0。
第二个循环看起来也差不多。但由于这里的溢出行为未定义,编译器可以假装它永远不会发生。编译器还知道起始值为正,因此可以假定:只要程序具有已定义行为,i 就永远不会为负或为 0。如同规则(要点 5.1.3 #3)允许它把第二个循环优化为:
while (true) do_something();没错,这是一个无限循环。这段代码要成立,唯一的可能是 do_something 具有副作用,使程序执行能够推进。编译器还可以假定循环后的执行位置永远无法到达:要么循环无限继续,要么循环到达某个使执行终止的内部状态。第 15.4 节会更详细地讨论这种情况。
要点 5.7.5 #2
抽象状态机一旦到达未定义状态,就不能再对后续执行作出任何假设。
编译器不仅可以随意处理操作本身(“未定义?那就由我定义。”),还可以假定程序永远不会到达这种状态,并据此推导结论。
程序到达未定义状态,通常称为程序“具有”或“表现出”未定义行为。这种说法不太理想;在许多情况下,程序根本不会“表现出”任何肉眼可见的怪异迹象。恰恰相反,糟糕的事情可能持续很久,而你甚至毫无察觉。
要点 5.7.5 #3
避免所有操作产生未定义行为,是你的责任。
更糟的是,在某些平台上采用某些标准编译器选项时,执行结果看起来完全正确。由于行为未定义,在这种平台上,有符号整数算术可能碰巧与无符号算术基本相同。但更换平台、编译器或某些选项,就可能改变一切。一个正常运行多年的程序,会突然毫无征兆地崩溃。
后文将尽量避免反复谈论“未定义行为”,而统称为“程序失效”,因为对程序而言,真正要紧的正是失效。这样的失效通常不可靠(常称为“拜占庭式”):执行中的任何组成部分都不再可信。可能产生的影响,从毫无迹象到切实损害平台或数据,无所不包。本书将用整整一章讨论程序失效(第 15 章)。
要点 5.7.5 #4
如果程序状态到达具有未定义行为的操作,执行就已经失效。
在本节之前,我们讨论的内容基本都具有良好定义的行为,因此抽象状态机始终处于良好定义的状态。有符号算术改变了这一点;只要不是非用不可,就应避免它。如果程序在正常结束之前突然终止,我们就称程序“触发陷阱”(或简称“陷入”)。
要点 5.7.5 #5
有符号算术可能以恶劣方式触发陷阱。
对于有符号类型,连取负都可能溢出。我们已经看到,INT_MAX 除符号位外的所有位都置为 1。INT_MIN 具有“下一个”表示:符号位置为 1,其他所有位为 0。相应值并不是 -INT_MAX。
练习 37
证明 INT_MIN+INT_MAX 为 −1。
要点 5.7.5 #6
INT_MIN < -INT_MAX。
换言之,正值 -INT_MIN 超出了范围,因为该操作的值大于 INT_MAX。
要点 5.7.5 #7
有符号算术中的取负可能溢出。
对于有符号类型,位操作作用于二进制表示;移位操作因而变得极为混乱。对负值做这种操作,语义究竟是什么并不清楚。
要点 5.7.5 #8
位操作应使用无符号类型。
5.7.6 固定宽度整数类型
目前所见整数类型的宽度(进而精度),可以用 <limits.h> 中的宏查看,例如 UINT_WIDTH 和 LONG_WIDTH。C 标准只保证它们的最低宽度。对于无符号类型,最低精度如下:
| 类型 | 最低精度 |
|---|---|
bool | 1 |
unsigned char | 8 |
unsigned short | 16 |
unsigned | 16 |
unsigned long | 32 |
unsigned long long | 64 |
通常,这些保证应当已经提供了足够的信息;但在某些技术约束下,它们可能仍然不够,或者你可能想强调某个特定精度。比如,你想用无符号量表示一个已知最大大小的位集合。如果知道 32 位足以容纳该集合,那么视平台而定,可以选择 unsigned(如果恰有 32 位)或 unsigned long(如果 unsigned 太窄)来表示。
C 标准在 <stdint.h> 中提供了精确宽度整数类型的名称。顾名思义,它们具有规定的精确“宽度”;对于所提供的无符号类型,宽度保证与精度相同。
要点 5.7.6 #1
如果提供了类型 uintN_t,它就是宽度和精度都恰为 N 位的无符号整数类型。
要点 5.7.6 #2
如果提供了类型 intN_t,它就是宽度恰为 N 位、精度为 N − 1 的有符号整数类型。
如果存在具有相应性质的类型,就必须提供这些 typedef。[13]
要点 5.7.6 #3
对于值 N,如果存在具备所需性质的类型,就必须提供 intN_t 和 uintN_t。
如今,平台通常提供无符号类型 uint8_t、uint16_t、uint32_t 和 uint64_t,以及有符号类型 int8_t、int16_t、int32_t 和 int64_t;还会陆续加入更多类型,例如 uint128_t 和 int128_t。可以分别用无符号类型宏 UINT8_WIDTH … UINT128_WIDTH,以及有符号类型宏 INT8_WIDTH … INT128_WIDTH,测试它们是否存在及其界限。
练习 39
如果这些宏存在,其值都由类型性质规定。请思考怎样用 N 的闭式公式表达这些值。
要写出所需类型的字面量,可分别使用宏 UINT8_C … UINT64_C 和 INT8_C … INT64_C(平台提供 128 位类型时,还有 UINT128_C 和 INT128_C)。例如,在 uint64_t 是 unsigned long 的平台上,UINT64_C(1) 通常展开成类似 1UL 的形式。(译者注:原书此处作 INT64_C(1),宏列表也写作 INT8_C … INT128_C,疑为笔误,已改正。)
要点 5.7.6 #4
对所提供的任意固定宽度类型,还会提供宽度 _WIDTH、最小值 _MIN(仅有符号类型)、最大值 _MAX 和字面量 _C 宏。
由于我们无法知道固定宽度类型背后的实际类型,很难猜出传给 printf 等函数的正确格式说明符。自 C23 起,可以使用 "wN" 长度说明符完成这项工作[14],其中 N 是类型宽度:
uint32_t n = 78;
int64_t big = (-UINT64_C(1))>>1; // 与 INT64_MAX 的值相同
printf("n is %w32u, and big is %w64d\n", n, big);2
3
C23 有了宽度宏(以及其他一些说明技巧),就能提供连宏预处理器都无法完整处理的类型。尤其是,大多数现代桌面计算机都在硬件上支持 128 位类型,如今可以把它们作为 C 整数类型 int128_t 和 uint128_t 提供出来。需要处理大型位集合时,这一点尤其有用:
#if ULLONG_WIDTH < UINT128_WIDTH // 不要使用 UINT128_MAX
typedef uint128_t wideType;
#else
typedef unsigned long long wideType;
#endif2
3
4
5
宽度保证是一个相对较小的数,因此总能据此写出预处理条件。
5.7.7 位精确整数类型
前面所见的精确宽度整数类型只对某些特定位数存在,通常只对 2 的幂存在。对于必须精确装入一定数量位的量,C23 引入了位精确整数类型。它们使用 _BitInt 关键字指定:
unsigned _BitInt(3) u3 = 7wbu; // 值 0, ..., 3, ... ,7
signed _BitInt(3) s3 = 3wb; // 值 -4, ..., 0, ..., 3
_BitInt(3) s3 = 3wb; // 相同2
3
这里还能看到,这些类型也有字面量:数字字面量的后缀含有 wb 或 WB,还可以与 u 或 U 组合。它们的特殊之处在于,字面量采用能够表示该值的最小宽度类型。例如:
7wbu需要 3 位表示值 7,并且是无符号的,所以类型是unsigned _BitInt(3)。3wb需要 2 位表示值 3,并为符号保留 1 位,所以类型是signed _BitInt(3)。3wbu需要 2 位表示该值,但不需要符号位,所以类型是unsigned _BitInt(2)。(译者注:原书此处作signed,与u后缀不符,已改正。)
这些类型总是在操作数的最大宽度内计算。例如,在:
u3 + 1wbu第一个操作数宽 3 位,第二个宽 1 位。因此,操作结果的类型是 unsigned _BitInt(3);对于这里选取的值,加法的数学结果 8 会回绕,最终得到值 0。
这些类型的一种可能用途,是构造需要具有特定宽度的常量:
constexpr unsigned _BitInt(3) max3u = -1; // 0b111
constexpr unsigned _BitInt(4) max4u = -1; // 0b1111
constexpr unsigned _BitInt(4) high4u= max4u-max3u;// 0b1000
constexpr signed _BitInt(4) max4s = max3u; // 0b0111
constexpr signed _BitInt(4) min4s = ~max4s; // 0b10002
3
4
5
这类类型对所有宽度都存在:无符号类型从 1 开始,有符号类型从 2 开始,直到 <limits.h> 中定义的 BITINT_MAXWIDTH;该最大宽度始终大于或等于 unsigned long long 的宽度 ULLONG_WIDTH。这使我们能够为所有整数值写出具有精确值和符号性的字面量。例如,下面这个庞然大物是一个精度为 127 位、因而类型为 _BitInt(128) 的有符号整数字面量:
0x7FFF’FFFF’FFFF’FFFF’FFFF’FFFF’FFFF’FFFFwb因此,只要该类型存在(BITINT_MAXWIDTH ≥ 128),即使不支持 int128_t 类型自身的字面量,也可以用它初始化 int128_t 对象。
5.7.8 浮点数据
整数分别接近数学中的 ℕ(无符号)或 ℤ(有符号)概念,而浮点类型则接近 ℝ(非复数)或 ℂ(复数)。它们与这些数学概念的差异体现在两个方面。首先,可表示的范围有大小限制,这一点与整数类型类似。例如,头文件 <float.h> 中有常量 DBL_MIN 和 DBL_MAX,给出 double 的最小值和最大值。但要注意,这里的 DBL_MIN 是严格大于 0.0 的最小数;最小的负 double 值是 -DBL_MAX。
不过,当我们想在物理系统中表示实数(ℝ)时,还会遇到另一个困难:实数的展开可能没有尽头。例如,值 1/3 在十进制表示中会无限重复数字 3;π 是“超越数”,在任何表示中都会无限展开,而且不会以任何方式循环。
C 和其他编程语言通过截断展开来处理这些困难。截断位置是“浮动”的(名称由此而来),取决于所讨论数的量级。
稍加简化地看,浮点值由以下几部分计算得出:
| 记号 | 含义 |
|---|---|
| s | 符号(±1) |
| e | 指数,一个整数 |
| f₁, …, fₚ | 尾数位,值为 0 或 1 |
指数满足 emin ≤ e ≤ emax。p 是尾数中的位数,称为“精度”。浮点值由下式给出:
p
s · 2ᵉ · Σ fₖ · 2⁻ᵏ
k=12
3
p、emin 和 emax 的值取决于类型,因此不必在每个数中显式表示。可以通过 DBL_MANT_DIG(p,典型值 53)、DBL_MIN_EXP(emin,−1,021)与 DBL_MAX_EXP(emax,1,024)等宏取得它们。
例如,假设某个数满足 s = −1、e = −2、f₁ = 1、f₂ = 0、f₃ = 1,那么其值为:
−1 · 2⁻² · (f₁·2⁻¹ + f₂·2⁻² + f₃·2⁻³)
= −1 · 1/4 · (1/2 + 1/8)
= −5/322
3
对应十进制值 −0.15625。从这个计算还可以看出,浮点值总能表示成一个以 2 的某次幂为分母的分数。
练习 41
证明:所有满足 e > p 的可表示浮点值,都是 2e−p 的倍数。
对于这种浮点表示,有一点必须牢记:中间计算可能截断值。
要点 5.7.8 #1
浮点操作既不满足结合律,也不满足交换律和分配律。
因此,浮点数基本失去了纯数学中熟悉的所有优美代数性质。当我们操作数量级相差很大的值时,由此产生的问题格外突出。
练习 42
打印下列表达式的结果:1.0E-13 + 1.0E-13,以及 (1.0E-13 + (1.0E-13 + 1.0)) - 1.0。
例如,把一个指数小于 −p 的极小浮点值 x 加到值 y > 1 上,结果仍只是 y。因此,若不深入研究,其实很难断言两次计算的结果是否“相同”。这类研究本身往往就是前沿课题,所以我们不能指望轻易判定相等或不等,只能说结果彼此“接近”。
要点 5.7.8 #2
绝不要比较浮点值是否相等。
复数类型的表示很直接:它与由两个相应实浮点类型元素组成的数组完全相同。要访问复数的实部和虚部,头文件 <tgmath.h> 还提供两个类型泛型宏:creal 和 cimag。对于三种复数类型中任意一种类型的 z,都有 z == creal(z) + cimag(z)*I。[15]
小结
- C 程序运行在一台抽象状态机上,这台机器基本不依赖程序实际运行的计算机。
- C 的所有基本类型都是某种数,但并非所有基本类型都能直接用于算术。
- 值具有类型和二进制表示。
- 必要时,值的类型会隐式转换,以满足具体使用位置的需要。
- 对象在第一次使用之前必须显式初始化。
- 只要不发生溢出,整数计算就会得到精确值。
- 浮点计算只能得到近似结果,并在一定数量的二进制位之后截断。
其他国际标准对这些表示施加了更严格的限制。例如,POSIX 标准强制采用一种特定的符号表示,ISO/IEC/IEEE 60559 [2011] 则规范了浮点表示。 ↩︎
C23 之前没有这个值,只能使用
SIZE_MAX(见前文)。由SIZE_WIDTH可以推导出SIZE_MAX。类似地,如今符号表示已固定为二补码,据此就能推导出所有整数类型的最小值和最大值。 ↩︎这里的“精度”仅采用 C 标准所定义的狭义含义,它不同于浮点计算的准确度。 ↩︎
你可能已经注意到,这份清单没有列出复数。第 5.3.1 节会介绍怎样指定复数。 ↩︎
第 5.6.3 节将说明宏究竟是什么。现在只需把它们看作编译器赋予了某些特定性质的名称。 ↩︎
自 C23 起,即使变长数组(VLA;见第 6.1.3 节)也可以用默认初始化器初始化。 ↩︎
这里用类型为
char const*const的指针引用字符串。稍后会介绍这一技巧的原理。 ↩︎还存在第三类只读对象:临时对象。第 13.2.2 节将介绍它们。 ↩︎
还要注意,现代平台应当支持使用 π 这样的非拉丁字符。如果编译器不支持,请考虑升级到更新的平台。 ↩︎
自 C23 起使用
alignof;此前使用_Alignof。 ↩︎第 12.7 节和第 12.1 节将分别处理后两个概念。 ↩︎
此前,这个基本类型名为
_Bool。为了与旧源代码保持向后兼容,这个名称仍被保留,但新代码不应使用它。 ↩︎C23 以前,只对 8、16、32 和 64 这几个值作出保证。请注意,即使在
CHAR_BITS不等于 8 的平台上,唯一保证存在的类型,也只是 N 等于CHAR_BITS时的uintN_t和intN_t。 ↩︎此前,头文件
<inttypes.h>只提供宏替换。例如,当 N = 64 时,有PRIb64、PRId64、PRIi64、PRIo64、PRIu64、PRIx64和PRIX64,分别对应printf格式"%b"、"%d"、"%i"、"%o"、"%u"、"%x"和"%X"。 ↩︎第 8.1.2 节将介绍这种类函数宏。 ↩︎