第 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_WIDTH 的值,就可以完全确定 size_t 类型上的操作。[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 相同的二进制表示。在采用 32 位 signed 的大多数体系结构上,这一点成立(但并非全部)。然而,没有任何规则保证实际值 +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 节),然后考察 C 在 C23 以前唯一真正的具名常量:枚举常量(第 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 的无符号类型。
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。
练习 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 … INT128_C。例如,在 uint64_t 是 unsigned long 的平台上,INT64_C(1) 通常展开成类似 1UL 的形式。
要点 5.7.6 #4
对所提供的任意固定宽度类型,还会提供宽度 _WIDTH、最小值 _MIN(仅有符号类型)、最大值 _MAX 和字面量 _C 宏。
由于我们无法知道固定宽度类型背后的实际类型,很难猜出传给 printf 等函数的正确格式说明符。自 C23 起,可以使用 "wN" 长度说明符完成这项工作,其中 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 位表示该值,但不需要符号位,所以类型是signed _BitInt(2)。
这些类型总是在操作数的最大宽度内计算。例如,在:
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。[14]
小结
- 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。 ↩︎第 8.1.2 节将介绍这种函数式宏。 ↩︎