第 13 章:存储
本章介绍:
- 通过动态分配创建对象;
- 存储与初始化规则;
- 理解对象的生存期;
- 处理自动存储。
到目前为止,程序中处理的大多数对象都是通过常规声明引入的具名对象——拥有特定类型和引用该对象的标识符。有时定义与声明位于代码中的不同位置,但定义依然通过类型和标识符引用对象。另一类较少见的对象拥有类型却没有标识符:第 5.6.4 节介绍的复合字面量。
所有这些具名对象和复合字面量都具有生存期,它取决于程序的语法结构。对象的生存期和标识符可见性,可能横跨整次程序执行——全局对象、全局字面量以及用 static 声明的对象即是如此——也可能绑定到函数内部的某个语句块。[1]
此前还看到,对某些对象而言,在递归函数中声明它们时,区分不同实例十分重要。递归调用层级中的每次调用,都拥有该对象的独立实例。因此,有必要区分另一种与对象并不完全相同的实体:存储实例。
本章将介绍另一种创建对象的机制——动态分配(第 13.1 节)。严格地说,这套机制创建的存储实例最初只被视为字节数组,不会被解释成对象;只有在其中存入内容后,它们才获得类型。
至此,各种可能性已经接近完整,可以讨论存储期、对象生存期和标识符可见性的不同规则(第 13.2 节)。本章还会深入初始化规则(第 13.4 节),因为采用不同方式创建的对象,其初始化存在显著差异。
此外还有两段插叙。第一段更细致地考察对象生存期,使我们能够在 C 代码中一些出人意料的位置访问对象(第 13.3 节)。第二段则窥视一种具体体系结构如何实现内存模型(第 13.5 节),特别说明特定机器可能怎样处理自动存储。
13.1 malloc 及其同伴
如果程序需要处理不断增长的数据集合,目前见过的对象类型就过于受限。面对规模不定的用户输入、Web 查询、大型交互图等不规则数据、大矩阵和音频流,一种方便办法是:在运行过程中随时为对象取得存储实例,并在不再需要时释放。这套方案称为动态分配,有时简称为分配。
头文件 <stdlib.h> 提供以下函数,为分配得到的存储建立接口:
#include <stdlib.h>
void* malloc(size_t size);
void free(void* ptr);
void* calloc(size_t nmemb, size_t size);
void* realloc(void* ptr, size_t size);
void* aligned_alloc(size_t alignment, size_t size);2
3
4
5
6
7
前两个函数 malloc(memory allocate,内存分配)和 free 最为常用。顾名思义,malloc 在运行过程中为我们创建存储实例,free 随后将其消灭。另外三个函数是 malloc 的专用版本:calloc(clear allocate,清零分配)把新存储的所有位设为 0;realloc 扩大或缩小存储;aligned_alloc 确保采用非默认对齐。
要点 13.1-1
调用分配函数时,只能请求严格大于 0 的大小。
详细讨论这些函数前,先来看 C23 从 POSIX 引入、前文已简要提到的两个函数:
#include <string.h>
char* strdup(char const s[static 1]);
char* strndup(char const s[static 1], size_t n);2
3
4
它们专门处理字符串,把分配和复制操作方便地结合起来。strdup 可以理解为具有如下定义:
char* strdup(char const s[static 1]) {
/* 为终止字符 0 多留一个位置。 */
char* ret = malloc(strlen(s) + 1);
return ret ? strcpy(ret, s) : nullptr;
}2
3
4
5
这项实现假定 s 非空,而且它提供的输入确实是字符串;否则,strlen 所作扫描就是错误的。
这里,malloc 要么分配所请求的存储空间并返回指针,要么在没有可用存储时返回空指针。代码考虑到了这一性质,只有在分配成功后才复制内容。
要点 13.1-2
分配失败会产生空指针。
strndup 的约束比 strdup 稍宽。它只假定 s 所指缓冲区的前 n 个字节中存在值 0,或者缓冲区至少具有 n 字节:
char* strndup(char const s[static 1], size_t n) {
char const* pos = memchr(s, 0, n);
n = pos ? (pos - s) + 1 : n;
char* ret = malloc(n);
if (ret) {
memcpy(ret, s, n - 1);
ret[n - 1] = 0;
}
return ret;
}2
3
4
5
6
7
8
9
10
若函数返回了缓冲区,它一定是字符串,因为最后一个字节保证写入值 0。因此,只要知道字符串大小的上界,就应优先采用这个接口。
要点 13.1-3
优先使用 strndup,而不是 strdup。
<stdlib.h> 中这些函数操作 void*,也就是不带已知类型信息的指针。能够为这组函数指定这样的“非类型”,很可能正是整套 void* 指针机制的存在理由。借此,它们可以普遍用于所有类型。以下示例为 double 向量分配一大块存储,地球上每个活人对应一个元素:
size_t length = living_people();
double* large_vec = malloc(length * sizeof *large_vec);
for (size_t i = 0; i < length; ++i) {
large_vec[i] = 0.0;
}
/* ... */
free(large_vec);2
3
4
5
6
7
8
9
练习 37
不要实际执行这次分配;请改为计算当前平台需要多少存储。你的平台有可能分配这样一个向量吗?
malloc 对存储之后的用途或对象类型一无所知,所以存储大小按字节指定。上述惯用写法只在一个位置给出类型信息,也就是 large_vec 的指针类型。在 malloc 实参中使用 sizeof *large_vec,可以保证分配正确的字节数;即使后来把 large_vec 改成 size_t*,分配也会自动适应。
另一种常见惯用写法,直接取得想创建的对象类型大小——这里是由 length 个 double 元素组成的数组:
double* large_vec = malloc(sizeof(double[length]));我们已经被强制转换这种显式转换折腾过。务必注意,malloc 调用应当保持原样:从其返回类型 void* 到目标类型的转换会自动发生,无须干预。
要点 13.1-4
不要强制转换 malloc 及其同伴的返回值。
这种强制转换不仅多余;若忘记包含 <stdlib.h>,显式转换甚至会适得其反。旧式 C 编译器可能假定:
/* 若忘记包含 stdlib.h,许多编译器仍会假定: */
int malloc(); // 错误的函数接口!
double* large_vec = (void*)malloc(sizeof(double[length]));2
3
4
编译器会假定返回 int,从而触发错误的 int 到指针类型转换。作者见过许多由此造成的崩溃和隐蔽缺陷,尤其常见于听从了糟糕建议的初学者代码。
在前面的代码中,下一步是通过赋值初始化刚分配的存储——这里把所有元素设为 0.0。只有执行这些赋值后,large_vec 的各个元素才成为“对象”。这样的赋值会赋予它们有效类型和值。
要点 13.1-5
通过 malloc 分配的存储没有初始化,也没有类型。
13.1.1 数组大小可变的完整示例
现在来看一个示例:与简单数组对象相比,通过 malloc 分配动态数组可以带来更大灵活性。以下接口描述一种名为 circular 的 double 值环形缓冲区。
circular:double 值环形缓冲区
这种数据结构可以从尾部加入 double 值,也可以从头部取出值。每个结构都有可存储元素数量的上限。
typedef struct circular circular;从 0 个元素开始,只要已存元素数量没有超过限制,就能向缓冲区追加新元素,或从头部移除元素。
/* 把值 value 作为新元素追加到缓冲区 c。
成功时返回 c,否则返回空指针。 */
circular* circular_append(circular* c, double value);
/* 从 c 中移除最旧元素并返回其值。
元素存在时返回该元素,否则返回 0.0。 */
double circular_pop(circular* c);
/* 返回缓冲区 c 中位置 pos 的指针。
成功时返回相应元素指针,否则返回空指针。 */
double* circular_element(circular const* c, size_t pos);2
3
4
5
6
7
8
9
10
11
circular 需要为环形缓冲区分配和释放空间,所以必须为该类型实例提供一致的初始化与销毁函数。两对函数提供这套能力。
第一对函数作用于已有存储。它们接收结构体指针,并保证为数据成员分配或释放空间:
/* 用最多 cap 个元素初始化环形缓冲区 c。
只能用于未初始化的缓冲区。
用本函数初始化的每个缓冲区,都必须调用 circular_destroy 销毁。 */
circular* circular_init(circular* c, size_t cap);
/* 销毁环形缓冲区 c。
c 必须已经通过 circular_init 初始化。 */
void circular_destroy(circular* c);2
3
4
5
6
7
8
第二对函数还会分配或释放结构体本身的存储。由于它们不直接访问结构体成员,可以定义为 inline:
/* 分配并初始化最多容纳 len 个元素的环形缓冲区。
用本函数分配的每个缓冲区,都必须调用 circular_delete 删除。 */
[[nodiscard("pointer to allocated data dropped")]]
[[__gnu__::__malloc__, __gnu_free__(circular_delete)]]
inline circular* circular_new(size_t len) {
return circular_init(malloc(sizeof(circular)), len);
}
/* 删除环形缓冲区 c。
c 必须已经通过 circular_new 分配。 */
inline void circular_delete(circular* c) {
circular_destroy(c);
free(c);
}2
3
4
5
6
7
8
9
10
11
12
13
14
如果采用普通数组对象,创建 circular 时就会固定它能存储的最大元素数量。为了更加灵活,circular_resize 可以提高或降低限制,circular_getlength 则能查询元素数量:
/* 把容量调整为 cap。
返回的指针将取代函数实参。 */
[[nodiscard("returned pointer replaces function argument")]]
circular* circular_resize(circular* c, size_t cap);
/* 返回已经存储的元素数量。 */
size_t circular_getlength(circular const* c);2
3
4
5
6
7
circular_new 和 circular_resize 使用了此前未见的属性 [[nodiscard]]。它表示函数返回值不应忽略,还可以附带消息;如果忽略,编译器就会发出警告。这对当前用法格外重要:一般而言,传给函数调用的指针在函数返回时会失效,必须改用返回的、可能已经改变的指针值。
此外,circular_new 使用 GNU 专用属性 [[__gnu__::__malloc__]],表示函数返回的指针指向此前未出现过的数据;第二个形式还说明应使用 circular_delete 删除数据。
通过 circular_element,该类型的行为近似 double 数组。传入当前长度范围内的位置,就会获得存储在该位置的元素地址。
本书上一版及 C23 以前的实现把结构体定义隐藏在 .c 文件中,使用者只能通过所提供的函数访问。如今,可以用 [[deprecated]] 把所有成员标为弃用,于是连结构体定义也能放进头文件。下文将展示这种设计怎样工作。
struct circular {
size_t start [[deprecated("privat")]]; /* 第一个元素。 */
size_t len [[deprecated("privat")]]; /* 元素数量。 */
size_t cap [[deprecated("privat")]]; /* 最大容量。 */
double* tab [[deprecated("privat")]]; /* 数据数组。 */
};2
3
4
5
6
其思想是:指针成员 tab 始终指向长度为 cap 的数组对象。在某一时刻,缓冲元素从 start 开始,成员 len 记录缓冲区中存储的元素数量。数组 tab 内的位置对 cap 取模计算。
以下表格表示一个 cap = 10、start = 2、len = 4 的 circular 实例:
| 数组索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 缓冲内容 | 未定 | 未定 | 6.0 | 7.7 | 81.0 | 99.0 | 未定 | 未定 | 未定 | 未定 |
| 缓冲位置 | 0 | 1 | 2 | 3 |
四个数 6.0、7.7、81.0 和 99.0 连续放置在 tab 所指数组对象中。
下表仍包含相同的四个数,但元素存储跨越了数组末端:
| 数组索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 缓冲内容 | 81.0 | 99.0 | 未定 | 未定 | 未定 | 未定 | 未定 | 未定 | 6.0 | 7.7 |
| 缓冲位置 | 2 | 3 | 0 | 1 |
初始化这种数据结构时,需要调用 malloc 为成员 tab 提供内存:
[[deprecated("implementation")]]
circular* circular_init(circular* c, size_t cap) {
if (c) {
if (cap) {
*c = (circular){
.cap = cap,
.tab = malloc(sizeof(double[cap])),
};
/* 分配失败。 */
if (!c->tab) {
c->cap = 0;
}
} else {
*c = (circular){};
}
}
return c;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
该函数始终检查指针形参 c 是否有效;条件语句的两个分支都会赋予复合字面量,从而保证其他所有成员初始化为 0。
库函数 malloc 可能因多种原因失败。例如,内存系统可能被此前的调用耗尽,或者所请求的分配大小实在太大。在一般用途系统上——你用于学习的系统很可能属于这一类——除非刻意触发,这种失败并不常见;但检查失败依然是良好习惯。
要点 13.1.1-1
malloc 通过返回空指针值表示失败。
销毁这种对象更加简单:只需检查指针,然后无条件释放成员 tab。
[[deprecated("implementation")]]
void circular_destroy(circular* c) {
if (c) {
free(c->tab);
circular_init(c, 0);
}
}2
3
4
5
6
7
库函数 free 有一项友好性质:它接受空形参,并在这种情况下什么也不做。
其他一些函数使用内部函数计算缓冲区的“环形”位置。它声明为 static,所以只对这些函数可见,不会污染标识符命名空间(要点 9.2-3):
[[deprecated("implementation")]]
static size_t circular_getpos(circular const c[static 1], size_t pos) {
if (c->cap) {
pos += c->start;
pos %= c->cap;
}
return pos;
}2
3
4
5
6
7
8
取得缓冲区元素指针就很简单:
[[deprecated("implementation")]]
double* circular_element(circular const* c, size_t pos) {
double* ret = nullptr;
if (c && pos < c->cap) {
pos = circular_getpos(c, pos);
ret = &c->tab[pos];
}
return ret;
}2
3
4
5
6
7
8
9
利用这些信息,现在你应该可以妥善实现除其中一个接口之外的所有函数。[2] 比较困难的是 circular_resize 函数。它首先计算几个相关的长度,然后根据请求是扩大还是缩小数组,分别进行处理。这里我们采用了一种命名约定:以 o(代表 old,旧的)开头的变量名表示修改前的属性,以 n(代表 new,新的)开头的变量名表示修改后的值。在函数的末尾,我们会利用在各个分支中计算出的值,通过复合字面量来构造一个新的结构体:
[[nodiscard("returned pointer replaces function argument")]]
[[deprecated("implementation")]]
circular* circular_resize(circular* c, size_t nlen) {
if (c) {
size_t len = c->len;
if (len > nlen) {
return nullptr;
}
size_t olen = c->cap;
if (nlen != olen) {
size_t ostart = circular_getpos(c, 0);
size_t nstart = ostart;
double* otab = c->tab;
double* ntab;
if (nlen > olen) {
/* 扩大分支见下文。 */
}
/* 缩小分支留作练习。 */
*c = (circular){
.cap = nlen,
.start = nstart,
.len = len,
.tab = ntab,
};
}
}
return c;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
先填补前面代码中的空缺,处理扩大对象的第一种情况。关键部分是调用 realloc:
ntab = realloc(c->tab, sizeof(double[nlen]));
if (!ntab) {
return nullptr;
}2
3
4
realloc 接收现有对象的指针以及重定位后的新大小。它要么返回具有所需大小的新对象指针,要么返回空指针。紧随其后的代码检查后一种情况;若无法重定位对象,就终止函数。
realloc 具有一些值得注意的性质:
- 若原指针为空,它会像
malloc一样分配缓冲区。 - 返回的指针可能与实参相同,也可能不同。运行时系统自行判断调整能否原地完成——例如对象之后是否还有空间——还是必须提供新对象。无论返回的指针是否相同,这都被视为一个带有相同数据的新对象;特别是,从原对象派生的所有指针都会失效。
- 如果实参指针与返回指针不同,也就是对象已经复制,那么无须、甚至不应对旧指针做任何事情;旧对象已经得到处理。
- 在可能范围内保留对象现有内容:
- 扩大对象时,与旧大小对应的起始部分保持不变;
- 缩小对象时,重定位后的对象包含调用前的起始部分。
- 若返回空指针,也就是运行时系统无法满足重定位请求,旧对象保持不变,因而不会丢失任何内容。
确定新对象已经具有所需大小后,还必须保证 tab 继续表示环形缓冲区。如果此前像第一张表那样,缓冲元素对应的部分连续,就无须处理,所有数据都已经妥善保留。
若环形缓冲区跨越末端,则需要调整:
/* 缓冲区非空,而且原本已经分成两段。 */
if (ostart + len > olen) {
size_t ulen = olen - ostart;
size_t llen = len - ulen;
if (llen <= (nlen - olen)) {
/* 把下半段复制到旧末端之后。 */
memcpy(ntab + olen, ntab, llen * sizeof(double));
} else {
/* 把上半段移到新数组末端。 */
nstart = nlen - ulen;
memmove(ntab + nstart,
ntab + ostart,
ulen * sizeof(double));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
第一个子情况中,原来位于低索引处的部分可以完整装入新增加的空间:
| 阶段 | 数组布局 |
|---|---|
| 旧布局(索引 0—9) | 81.0, 99.0, 未定, 未定, 未定, 未定, 未定, 未定, 6.0, 7.7 |
| 新布局(索引 0—12) | 81.0, 99.0, 未定, 未定, 未定, 未定, 未定, 未定, 6.0, 7.7, 81.0, 99.0, 未定 |
| 新缓冲位置 | 索引 8、9、10、11 分别是位置 0、1、2、3 |
另一种情况下,低索引处的部分装不进新增空间,于是把缓冲区的上半段移向新数组末端。
两种处理方式揭示了一项细微差异。第一种情况使用 memcpy:复制操作的源元素与目标元素不可能重叠,所以这样是安全的。另一种情况下,源元素和目标元素可能重叠,必须使用限制较少的 memmove。[3]
你可能已经注意到,前面的函数定义(但不是声明)也带有 [[deprecated]] 属性。这样,这些函数内部使用 circular 结构体成员时,不会因成员已弃用而给出诊断;否则,编译 circular.c 会产生大量无用诊断。只包含头文件的代码看不到函数定义上的 [[deprecated]],所以使用任何函数都不会得到诊断,只有直接访问结构体成员时才会。
因此,使用者代码仍可拥有 circular 类型的局部对象:
#include "circular.h"
circular circ;
circular_init(&circ, 100);
/* ... */
circular_destroy(&circ);2
3
4
5
6
7
8
若把结构体定义完全隐藏在 .c 源文件中,这种写法便不可能成立。
写作本书时(2024 年 1 月),并非所有编译器都完全遵循 C 标准对该属性的意图。因此,源文件开头带有一项编译器专用的 #pragma;相关编译器修复并更加严格地遵循 C 标准后,就可以删除它:
#if __GNUC__ > 4 && __GNUC__ <= 14
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#endif2
3
13.1.2 保证动态分配一致
前面的两个代码示例都体现了一项原则:malloc、realloc 和 free 等分配函数调用应当成对出现。它们不必位于同一个函数中,但在大多数情况下,简单统计二者的出现次数,应当得到相同结果。
要点 13.1.2-1
每次分配都必须对应一次释放。
否则可能存在内存泄漏,也就是分配对象丢失。这可能耗尽平台资源,表现为性能低下或随机崩溃。
要点 13.1.2-2
每次调用 free,都必须对应一次 malloc、calloc、aligned_alloc 或 realloc。
但要注意,realloc 很容易干扰简单计数:若使用现有对象调用它,它会同时释放旧对象并分配新对象。
内存分配系统追求简单,所以 free 只允许接收由分配函数返回的指针或者空指针。
要点 13.1.2-3
只能把 malloc、calloc、aligned_alloc 或 realloc 原样返回的指针传给 free。
传入的指针不得:
- 指向通过其他方式分配的对象,例如具名对象或复合字面量;
- 指向已经释放的对象;
- 只指向已分配对象内部较小的一部分。
否则程序会崩溃。绝非夸张:这会彻底破坏本次程序执行的内存,属于最糟糕的崩溃类型之一。
13.1.3 柔性数组成员
前面的 circular 展示了怎样把动态分配的数组(成员 tab)与元数据(例如成员 len)结合起来。C 还有另一种方式,可以把数组信息更直接地与数组本身耦合,称为柔性数组成员(flexible array member,FAM)。这种数组必须是结构体的最后一个成员,并定义为不完整数组:
typedef struct ua32 ua32;
struct ua32 {
size_t length;
uint32_t data[]; // 柔性数组成员。
};2
3
4
5
6
其思想是让成员 data 和 length 始终保持一致。对于指向该类型对象的指针值 ap,ap->data 始终表示 uint32_t[ap->length] 类型数组:
size_t len = 32;
size_t size = offsetof(ua32, data) + sizeof(uint32_t[len]);
/* 大小过小时作出调整。 */
if (size < sizeof(ua32)) {
size = sizeof(ua32);
}
ua32* ap = calloc(size, 1);
/* 保证长度成员与对象大小一致。 */
ap->length = len;
/* ap->data 主要作为数组使用。 */
for (size_t i = 0; i < ap->length; ++i) {
printf("ap->data[%zu] is %w32u\n", i, ap->data[i]);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
请注意,柔性数组成员 data 不一定紧接在结构体本身之后,它的偏移可能使其起点位于通常结构体大小之内。因此,必须始终保证分配的存储足以访问整个结构体。
要点 13.1.3-1
带有柔性数组成员的结构体对象,必须拥有足以访问整个结构体的存储。
遗憾的是,标准没有提供能够分配这种结构体并自动初始化 length 等成员值的工具。
要点 13.1.3-2
长度成员与柔性数组成员之间的一致性必须手工维护。
13.2 存储期、生存期与可见性
我们已经从多处看到,标识符的可见性与它所引用对象的可访问性并不是同一件事。清单 13.1 中名为 x 的对象就是一个简单示例。
清单 13.1:局部标识符遮蔽示例
#include "c23-fallback.h"
void square_it(double* p) [[__unsequenced__]] {
*p *= *p;
}
int main(void) {
double x = 35.0;
double* xp = &x;
{
square_it(&x); // 引用 double x。
/* ... */
[[maybe_unused]] int x = 0; // 遮蔽 double x。
/* ... */
square_it(xp); // 仍然有效地使用 double x。
/* ... */
}
/* ... */
square_it(&x); // 引用 double x。
/* ... */
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
第 7 行声明的标识符 x,其可见作用域从该行开始,一直延伸到函数 main 末尾;但中间有一段显著中断:从第 12 行到第 16 行,其可见性被另一个同名标识符 x 遮蔽。
要点 13.2-1
标识符只在其作用域内可见,而且可见性从声明处开始。
要点 13.2-2
标识符的可见性可能被从属作用域中的同名标识符遮蔽。
标识符的可见性与它所表示对象的可用性并不相同。首先,所有 square_it 调用都会使用 double x 对象,尽管定义该函数的位置看不到标识符 x。其次,在第 14 行,即便标识符受到遮蔽,我们仍把 double x 的地址传给 square_it。
另一个示例涉及带有存储类 extern 的声明。它们始终指代具有静态存储期、预期在文件作用域中定义的对象;[4] 见清单 13.2。
清单 13.2:extern 标识符遮蔽示例
#include <stdio.h>
unsigned i = 1;
int main(void) {
unsigned i = 2; // 一个新对象。
if (i) {
extern unsigned i; // 一个已有对象。
printf("%u\n", i); // 打印 1。
} else {
printf("%u\n", i); // 打印 2。
}
}2
3
4
5
6
7
8
9
10
11
12
13
该程序有三项名为 i 的声明,却只有两项定义:第 6 行的声明兼定义遮蔽第 3 行;随后,第 8 行的声明又遮蔽第 6 行,但它引用的对象与第 3 行定义的对象相同。
练习 41
该程序会打印哪个值?
要点 13.2-3
具名对象的每一项定义都会创建一个彼此不同的新对象。
因此,下例中的字符数组 A 和 B 标识地址不同的对象,表达式 A == B 必定为假:
char const A[] = {'e', 'n', 'd', '\0'};
char const B[] = {'e', 'n', 'd', '\0'};
char const* c = "end";
char const* d = "end";
char const* e = "friend";
char const* f = (char const[]){'e', 'n', 'd', '\0'};
char const* g = (char const[]){'e', 'n', 'd', '\0'};2
3
4
5
6
7
但这里总共有多少个彼此不同的数组对象?答案并不固定,编译器拥有许多选择。
要点 13.2-4
只读对象字面量可以相互重叠。
前例包含三个字符串字面量和两个复合字面量。它们都是只读的对象字面量:字符串字面量按定义只读,两个复合字面量则受 const 限定。其中四个具有完全相同的基础类型和内容 {'e', 'n', 'd', '\0'},所以四个指针 c、d、f 和 g 完全可能都初始化成同一个字符数组的地址。编译器甚至可以进一步节省内存:由于 end 位于 friend 末尾,这个地址可能恰好就是 &e[3]。
这些示例表明,对象是否可用,不仅取决于标识符的词法性质或字面量定义的位置,也取决于程序执行状态。对象的生存期具有起点和终点。
要点 13.2-5
对象具有生存期;在生存期之外不能访问该对象。
要点 13.2-6
如果程序执行在对象生存期之外引用该对象,则执行失败。
对象生存期起点和终点的定义,取决于创建对象所用的工具。C 区分四种对象存储期:
- 静态存储期: 在编译时确定;
- 自动存储期: 在运行时自动确定;
- 分配存储期: 由
malloc及其同伴的函数调用显式确定; - 线程存储期: 绑定到特定执行线程。
下表概括声明及其存储类、初始化、链接、存储期和生存期之间的复杂关系。“暂定”表示只有在不存在其他带初始化器的定义时,才隐含形成定义;“诱导”表示:若此前已经遇到内部链接声明,则采用内部链接,否则采用外部链接。复合字面量依据自己所在的块(如果有)遵循类似规则。
| 存储类或形式 | 作用域 | 定义 | 链接 | 存储期 | 生存期 |
|---|---|---|---|---|---|
| 带初始化器 | 文件 | 是 | 外部 | 静态 | 整次执行 |
extern,带初始化器 | 文件 | 是 | 外部 | 静态 | 整次执行 |
| 字符串字面量 | 任意 | 是 | 不适用 | 静态 | 整次执行 |
static,带初始化器 | 任意 | 是 | 内部 | 静态 | 整次执行 |
constexpr | 任意 | 是 | 内部 | 静态 | 整次执行 |
| 未初始化 | 文件 | 暂定 | 外部 | 静态 | 整次执行 |
extern,未初始化 | 任意 | 否 | 诱导 | 静态 | 整次执行 |
static,未初始化 | 任意 | 暂定 | 内部 | 静态 | 整次执行 |
thread_local | 文件 | 是 | 外部 | 线程 | 整个线程 |
extern thread_local | 任意 | 否 | 外部 | 线程 | 整个线程 |
static thread_local | 任意 | 是 | 内部 | 线程 | 整个线程 |
非 VLA,无存储类、auto 或 register | 块 | 是 | 无 | 自动 | 定义所在块 |
| VLA | 块 | 是 | 无 | 自动 | 从定义到块末尾 |
| 带数组的函数返回表达式 | 块 | 是 | 无 | 自动 | 到完整表达式末尾 |
这张表尚未深入全部细节,却已经说明:关键字用法与底层术语相当容易混淆。
首先,与名称暗示的含义不同,存储类 extern 可能对应拥有外部链接或内部链接的标识符。[5] 除编译器外,具有链接的标识符通常还由另一个外部程序——链接器——管理。这样的标识符会在程序启动时、甚至进入 main 前完成初始化,而链接器负责保证这一点。来自不同目标文件的标识符需要外部链接,才能全部访问同一个对象或函数,并由链接器建立对应关系。
此前见过的重要外部链接标识符包括 C 库函数。它们驻留在通常名为 libc.so 之类的系统库中,而不在你创建的目标文件里。与其他目标文件没有联系的全局文件作用域对象或函数则应当具有内部链接。其余所有标识符均无链接。[6]
其次,静态存储期并不等同于以存储类 static 声明具名对象。后者只是强制具名对象或函数具有内部链接。这样的对象可以在文件作用域(全局)声明,也可以在块作用域(局部)声明。
前三种存储期已经有许多示例。线程存储期从 C23 开始写作 thread_local,此前写作 _Thread_local;它与第 20 章将介绍的 C 线程 API 有关。
分配存储期较为直接:这种对象的生存期始于创建它的 malloc、calloc、realloc 或 aligned_alloc 调用,终于销毁它的 free 或 realloc 调用;若没有发出这种调用,则终于程序执行结束。
另外两种存储期需要更多解释,下面分别讨论。从 C23 开始,复合字面量的类型中也可以带有存储类;相应规则较为简单。
要点 13.2-7
复合字面量的生存期,与在同一语境中按相同存储类声明的具名对象相同。
唯一可能的例外,是出现在块作用域中、受 const 限定但没有任何存储类说明的复合字面量。它们甚至不必对应唯一对象,而可以引用整次程序执行期间始终可用的存储。
13.2.1 静态存储期
具有静态存储期的对象可以通过不同方式定义:
- 在文件作用域中定义,而且未声明为
thread_local的对象;具名对象和复合字面量都可以具有这项性质; - 在块内定义、带存储类说明符
static且没有附加thread_local的具名对象和复合字面量; - 字符串字面量;它们是
char或宽字符类型数组,始终具有静态存储期。
这些对象的生存期横跨整个程序执行。由于在任何应用代码执行前就被视为存活,它们只能采用编译时已知的表达式,或者系统进程启动过程能够解析的表达式初始化。例如:
double A = 37;
double* p = &(static double){1.0};
int main(void) {
static double B;
}2
3
4
5
6
这里定义了四个具有静态存储期的对象:由 A、p 和 B 标识的三个对象,以及第 2 行定义的复合字面量。其中三个对象具有 double 类型,一个具有 double* 类型。
四个对象从一开始就正确初始化:其中三个显式初始化,B 隐式初始化为 0。
要点 13.2.1-1
具有静态存储期的对象始终会初始化。
p 的初始化需要一点超出编译器自身能力的魔法,因为它使用了另一对象的地址。这样的地址通常只能在执行启动时计算;这正是大多数 C 实现需要链接器概念的原因。
B 的示例说明,即使对象生存期横跨整次程序执行,其名称也不一定在整个程序中可见。前面的 extern 示例还表明,在别处定义的静态存储期对象,可以在狭窄作用域内变得可见。
13.2.2 自动存储期
这是最复杂的情况。自动存储期规则大多是隐式的,因此最需要解释。以下几类对象可能显式或隐式定义,并归入这一类别:
- 块作用域中未声明为
static的具名对象和复合字面量;它们可能没有存储类,也可能带有传统存储类auto、register或constexpr;[7] - 函数调用返回的某些临时对象。
自动对象不是变长数组(VLA)时,生存期规则最简单也最常见。
要点 13.2.2-1
除 VLA 或临时对象外,自动对象的生存期与定义所在块的执行相对应。
也就是说,大多数局部对象会在程序执行进入其定义所在作用域时创建,在离开该作用域时销毁。不过,由于递归,同一个对象的多个实例可能同时存在。
要点 13.2.2-2
每次递归调用都会为自动对象创建一个新的局部实例。
具有自动存储期的对象对优化大有好处:编译器通常能看到具名对象的全部用法,因而能够凭借这些信息判断它是否可能形成别名。传统 auto、constexpr 和 register 所声明对象的差异,会在这里发挥作用。
要点 13.2.2-3
& 运算符不能用于以 register 声明的对象。
这样便无法无意间取得 register 对象的地址(要点 12.3-2),由此直接得到下一项规则。
要点 13.2.2-4
以 register 声明的对象不能形成别名。
通过 register 声明,可以迫使编译器指出我们在何处取得了地址,从而找出一些具有优化潜力的位置。对并非数组、也不包含数组的对象而言,这种方法效果很好。
要点 13.2.2-5
在性能关键代码中,把非数组的局部对象声明为 register。
数组在这里具有特殊地位,因为几乎在所有语境中,它们都会退化成首元素地址,所以数组必须能够取得地址。
要点 13.2.2-6
存储类为 register 的数组没有用处。
数组的存在还会使另一种情况需要特殊处理。某些函数返回值是真正的奇美拉:具有临时生存期的对象。函数通常返回值,而值本身无法寻址;但若返回类型包含数组类型,就必须能够隐式取得地址,才能让 [] 运算符具有明确定义。因此,以下函数返回结果是临时对象,可以通过成员指示符 .ory[0] 隐式取得地址:
struct demo {
unsigned ory[1];
};
struct demo mem(void);
printf("mem().ory[0] is %u\n", mem().ory[0]);2
3
4
5
6
7
C 中存在临时生存期对象的唯一原因,就是访问这种函数返回值的成员。不要把它们用于其他用途。
要点 13.2.2-7
具有临时生存期的对象只读。
要点 13.2.2-8
临时生存期终于外围完整表达式结束之时。
也就是说,包含该临时对象的表达式一结束求值,它的生命便随之结束。在前例中,构造完 printf 的实参后,临时对象就不复存在。与复合字面量的定义相比:复合字面量会一直存活到外围作用域中的 printf 执行结束。
13.3 插叙:在定义前使用对象
本节更深入地讨论自动对象怎样获得生命——或者没有获得生命。内容略有难度,若现在没有精力,可以暂时跳过,稍后再回来。本节知识是理解第 13.5 节具体机器模型所必需的,而后者同样是一段插叙。本节还会引入 goto 和标签,第 15.6 节处理错误时还要用到它们。
回到普通自动对象的生存期规则(要点 13.2.2-1)。仔细思考就会发现,它相当特别:这种对象的生存期始于进入其定义的作用域,而不是更符合直觉的“执行首次遇到定义时”。
为了观察差异,来看清单 13.3,它改编自 C 标准文档中的一个示例。
清单 13.3:有意构造的复合字面量示例
void fgoto(unsigned n) {
unsigned j = 0;
unsigned* p = nullptr;
unsigned* q;
AGAIN:
if (p) {
printf("%u: p and q are %s, *p is %u\n",
j,
(q == p) ? "equal" : "unequal",
*p);
}
q = p;
p = &((unsigned){j});
++j;
if (j <= n) {
goto AGAIN;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
我们尤其关心调用 fgoto(2) 时打印的行。作者的计算机输出:
1: p and q are unequal, *p is 0
2: p and q are equal, *p is 12
这段代码无疑是刻意拼凑出来的。它使用一种尚未实际见过的新构造 goto。顾名思义,这是一项跳转语句,在本例中指示计算机从标签 AGAIN 处继续执行。后文会看到更适合使用 goto 的语境;这里仅为演示怎样跳过第 14 行的复合字面量定义。
对于 n == 2,程序执行三次遇到 printf 所在行;但 p 初始为空,所以第一次会跳过调用。相应状态如下:
j | p | q | printf |
|---|---|---|---|
| 0 | 空 | 不确定 | 跳过 |
| 1 | 值为 0 的字面量地址 | 空 | 打印 |
| 2 | 值为 1 的字面量地址 | 值为 0 的字面量地址 | 打印 |
当 j == 2 时,p 和 q 保存的地址来自不同迭代。为什么输出却说二者地址相等?这只是巧合吗?还是因为在词法位置上先于复合字面量定义之处使用它,触发了未定义行为?
C 标准规定必须产生这里展示的输出。特别是,当 j == 2 时,p 和 q 的值相等且有效,它们所指对象的值是 1。换一种说法,本例中的 *p 使用具有明确定义,尽管从词法位置看,对 *p 求值发生在对象定义之前。此外,这里只有一个复合字面量,所以 j == 2 时两个地址相等。
要点 13.3-1
对于非 VLA 对象,生存期始于进入定义所在作用域,终于离开该作用域。
要点 13.3-2
每次执行遇到定义时,都会对自动对象和复合字面量的初始化器求值。
本例三次经过复合字面量,依次把它设成 0、1 和 2。
VLA 的生存期遵循不同规则。
要点 13.3-3
VLA 的生存期始于执行遇到定义,终于离开可见作用域。
因此,对 VLA 而言,前面通过 goto 实现的奇怪技巧无效:即使仍处于同一块中,也不能在定义前面的代码中使用指向 VLA 的指针。之所以特殊处理 VLA,是因为其大小是运行时性质,进入声明所在块时根本无法为它分配空间。
13.4 初始化
第 5.5 节讨论了初始化的重要性。要保证程序从明确定义的状态开始,并在整个执行期间保持这种状态,初始化至关重要。对象的存储期决定它怎样初始化。
要点 13.4-1
具有静态或线程存储期的对象会默认初始化。
你可能还记得,默认初始化相当于把对象所有成员初始化为 0(算术类型)或 nullptr(指针类型)。特别是,对于 {} 值可能具有非平凡表示的基础类型——也就是指针和浮点类型——默认初始化仍能正确工作。
对于自动对象和分配对象,则必须主动处理。
要点 13.4-2
具有自动或分配存储期的对象必须显式初始化。
最简单的办法是使用初始化器,让具名对象与复合字面量一进入可见范围便处于明确定义状态。从 C23 开始,即使分配的是 VLA,只要对整个数组采用默认初始化,也能这样做。
对于动态分配的数组,如果已经知道把所有位设成零是一种有效初始化——基本上所有整数类型都是如此——就应通过 calloc 而不是 malloc 分配。如果无法这样做,例如类型包含浮点数据或指针,就必须通过赋值初始化。原则上,每次分配此类对象时都可以手工完成;但初始化部分可能在视觉上把定义与使用隔开,使代码难以阅读和维护。避免这项问题最简单的方式,是把初始化封装进函数。
要点 13.4-3
系统地为每一种数据类型提供初始化函数。
这里强调的是“系统地”:应当采用一致约定,决定初始化函数怎样工作、怎样命名。回到 rat 数据类型的初始化函数 rat_init,它为此类函数实现了一套具体 API:
- 对于类型
toto,初始化函数命名为toto_init; _init函数的第一个实参,是待初始化对象的指针;- 若该对象指针为空,函数什么也不做;
- 可以提供其他实参,为特定成员传入初始值;
- 函数返回收到的对象指针;若发生错误,则返回空指针。
凭借这些性质,这种函数很容易用于指针的初始化器:
rat const* my_rat = rat_init(malloc(sizeof(rat)), 13, 7);这样做有多项优点:
- 若
malloc调用失败并返回空指针,唯一效果是把my_rat初始化为空,所以my_rat始终处于明确定义状态; - 如果不希望之后改变对象,可以从一开始就用
const限定指针目标;对新对象的全部修改都发生在右侧初始化表达式内部。
这种初始化会出现在许多位置,因此还可以封装进另一个函数:
rat* rat_new(long long numerator,
unsigned long long denominator) {
return rat_init(malloc(sizeof(rat)),
numerator,
denominator);
}2
3
4
5
6
通过该函数初始化就变成:
rat const* my_rat = rat_new(13, 7);像作者这样痴迷宏的人,甚至可以轻松定义一个类型泛型宏,一劳永逸地完成这层封装:
#define P99_NEW(T, ...) T##_init(malloc(sizeof(T)), __VA_ARGS__)于是,前面的初始化可以写成:
rat const* my_rat = P99_NEW(rat, 13, 7);它至少与 rat_new 版本同样易读,而且无须为定义的每种类型再声明一个函数。
许多人不赞成这类宏定义,所以部分项目不会把它作为普遍策略接受;但至少应当知道这种可能性存在。它使用了两项此前尚未遇到的宏特性:
##运算符拼接记号。这里的T##_init把实参T和_init融合成一个记号;T为rat时得到rat_init,为toto时得到toto_init;- 构造
...提供长度可变的实参列表。第一个实参之后传入的所有实参,都能在宏展开内部通过__VA_ARGS__访问。因此,可以按照相应_init函数的需要,把任意数量的实参传给P99_NEW。
如果必须通过 for 循环初始化数组,情况会更加难看;同样可以很容易地封装成函数:
rat* rat_vinit(size_t n, rat p[n]) {
if (p) {
for (size_t i = 0; i < n; ++i) {
rat_init(p + i, 0, 1);
}
}
return p;
}2
3
4
5
6
7
8
借助该函数,初始化再次变得直接:
rat* my_rat_vec = rat_vinit(44, malloc(sizeof(rat[44])));这里封装成函数更好,因为重复填写大小很容易引入错误:
rat* rat_vnew(size_t size) {
return rat_vinit(size, malloc(sizeof(rat[size])));
}2
3
13.5 插叙:机器模型
到目前为止,我们主要从 C 代码内部展开论证,用语言自身的内在逻辑描述发生的事情。本节是一段偏离这种方式的可选插叙,将窥视一种具体体系结构的机器模型。我们会更细致地观察一个简单函数怎样翻译到该模型中,尤其关注自动存储期怎样实现。如果现在实在无法承受这些内容,可以暂时跳过;否则请记住不要慌张,大胆潜入其中。
传统上,计算机体系结构通过冯·诺依曼模型描述。[8] 在这套模型中,处理单元拥有有限数量、可以保存整数值的硬件寄存器;主存储器同时保存程序和数据,并且可以线性寻址;有限指令集则描述这些组成部分能够完成的操作。
通常用来按 CPU 能理解的方式描述机器指令的中间程序设计语言,统称为汇编程序或汇编语言,至今仍大体建立在冯·诺依曼模型上。汇编语言并不像 C 那样存在一套适用于所有平台的统一语言,而是由整套方言组成,分别考虑 CPU、编译器和操作系统等方面的具体特征。这里采用 GCC 编译器为 x86_64 处理器体系结构使用的方言。如果不清楚这意味着什么,也不必担心;它只是一种体系结构的示例。
练习 48
找出让编译器为你的平台生成汇编输出所需的实参。
清单 13.4 给出了清单 13.3 中 fgoto 函数的汇编输出。这种汇编代码通过指令操作硬件寄存器和内存位置。例如,指令 movl $0, -16(%rbp) 把值 0 存入(移动到)寄存器 %rbp 所指位置之前 16 字节的内存位置。
汇编程序还包含标签,用于标识程序中的特定位置。例如,fgoto 是函数的入口点,.L_AGAIN 则对应 C 中的 goto 标签 AGAIN。你可能已经猜到,右侧字符 # 后面的文字是注释,尝试把各条汇编指令与相应 C 代码联系起来。
这个汇编函数使用以下硬件寄存器:
%eax %ecx %edi %edx %esi %rax %rbp %rcx %rdx %rsp这份列表远比最初冯·诺依曼机器拥有的寄存器丰富,但核心思想依然存在:一些通用寄存器表示程序执行状态中的值,另有两个寄存器承担非常特殊的角色:%rbp(基指针)和 %rsp(栈指针)。
函数在内存中拥有一块保留区域,通常称为栈,用于保存局部对象和复合字面量。该区域的“上”端由寄存器 %rbp 指定,各对象通过相对于它的负偏移访问。例如,形参 n 位于 %rbp 之前 36 字节,编码成 -36(%rbp)。
下表表示为函数 fgoto 保留的内存块布局,以及函数执行到三个不同位置时存储的值。
| 位置 | -48 | -36 | -28 | -24 | -16 | -8 | -4 | %rbp |
|---|---|---|---|---|---|---|---|---|
| 含义 | n | 复合字面量 | q | p | j | |||
| 初始化后 | 未定 | 2 | 未定 | 未定 | 0 | 未定 | 0 | |
| 第 0 次迭代后 | 未定 | 2 | 0 | 0 | %rbp-28 | 未定 | 1 | |
| 第 1 次迭代后 | 未定 | 2 | 1 | %rbp-28 | %rbp-28 | 未定 | 2 |
这个示例特别适合用来了解自动对象,以及执行进入函数时怎样建立它们。在这台特定机器上,进入 fgoto 时有三个寄存器保存本次调用的信息:%edi 保存函数实参 n;%rbp 指向调用函数的基地址;%rsp 指向本次 fgoto 调用可以用来存储数据的内存顶部地址。
现在考察清单 13.4 中的汇编代码怎样布置这些内容。fgoto 一开始便执行三条指令,正确建立自己的“世界”:先保存 %rbp,因为需要把该寄存器挪作己用;再把 %rsp 的值移入 %rbp;最后让 %rsp 减少 48。这里的 48 是编译器为 fgoto 所需全部自动对象计算出的字节数。
由于建立过程如此简单,该步骤保留的空间不会初始化,里面填充着未定内容。接下来的三条指令会初始化三个自动对象 n、j 和 p,其他对象则一直保持未初始化,直到稍后处理。
清单 13.4:fgoto 函数的汇编版本
.type fgoto, @function
fgoto:
pushq %rbp # 保存基指针。
movq %rsp, %rbp # 装入栈指针。
subq $48, %rsp # 调整栈指针。
movl %edi, -36(%rbp) # fgoto 实参 0 -> n。
movl $0, -4(%rbp) # 初始化 j。
movq $0, -16(%rbp) # 初始化 p。
.L_AGAIN:
cmpq $0, -16(%rbp) # if (p)
je .L_ELSE
movq -16(%rbp), %rax # p -> rax。
movl (%rax), %edx # *p -> edx。
movq -24(%rbp), %rax # q 是否等于 p?
cmpq -16(%rbp), %rax
jne .L_YES
movl $.L_STR_EQ, %eax # 是。
jmp .L_NO
.L_YES:
movl $.L_STR_NE, %eax # 否。
.L_NO:
movl -4(%rbp), %esi # j -> printf 实参 1。
movl %edx, %ecx # *p -> printf 实参 3。
movq %rax, %rdx # equal/unequal -> printf 实参 2。
movl $.L_STR_FRMT, %edi # 格式 -> printf 实参 0。
movl $0, %eax # 清零 eax。
call printf
.L_ELSE:
movq -16(%rbp), %rax # p
movq %rax, -24(%rbp) # -> q。
movl -4(%rbp), %eax # j
movl %eax, -28(%rbp) # -> 复合字面量。
leaq -28(%rbp), %rax # &复合字面量
movq %rax, -16(%rbp) # -> p。
addl $1, -4(%rbp) # ++j。
movl -4(%rbp), %eax # if (j
cmpl -36(%rbp), %eax # <= n)
jbe .L_AGAIN # goto AGAIN。
leave # 恢复栈。
ret # return 语句。2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
完成这些布置后,函数便已准备就绪。特别是,它可以轻松调用另一个函数:%rsp 现在指向一块新内存区域的顶部,被调用函数可以使用该区域。标签 .L_NO 之前的中间部分就体现了这一点,它实现对 printf 的调用:先依次把函数应当接收的四个实参放入寄存器 %edi、%esi、%ecx 和 %rdx,然后清零 %eax 并调用函数。
总而言之,无论函数实际用了多少个自动对象,为非 VLA 自动对象建立内存区域都只需要几条指令。若函数拥有更多对象,只需把魔数 48 改成区域的新大小。
这种实现方式直接带来两项结果:
- 自动对象通常从函数或作用域开始处就已经可用;
- 不会强制初始化自动对象。
这与 C 中自动对象的生存期及初始化规则十分吻合。
前面的汇编输出至多只讲了一半故事。为了展示这种代码生成所依据的原则性假定,生成时没有启用优化。启用优化后,“如同”规则(要点 5.1.3-3)允许编译器大幅重组代码。作者的编译器在完整优化下会生成近似清单 13.5 的内容。
清单 13.5:经过优化的 fgoto 汇编版本
.type fgoto, @function
fgoto:
pushq %rbp # 保存基指针。
pushq %rbx # 保存 rbx 寄存器。
subq $8, %rsp # 调整栈指针。
movl %edi, %ebp # fgoto 实参 0 -> n。
movl $1, %ebx # 初始化 j,从 1 开始。
xorl %ecx, %ecx # 0 -> printf 实参 3。
movl $.L_STR_NE, %edx # "unequal" -> printf 实参 2。
testl %edi, %edi # if (n > 0)
jne .L_N_GT_0
jmp .L_END
.L_AGAIN:
movl %eax, %ebx # j + 1 -> j。
.L_N_GT_0:
movl %ebx, %esi # j -> printf 实参 1。
movl $.L_STR_FRMT, %edi # 格式 -> printf 实参 0。
xorl %eax, %eax # 清零 eax。
call printf
leal 1(%rbx), %eax # j + 1 -> eax。
movl $.L_STR_EQ, %edx # "equal" -> printf 实参 2。
movl %ebx, %ecx # j -> printf 实参 3。
cmpl %ebp, %eax # if (j <= n)
jbe .L_AGAIN # goto AGAIN。
.L_END:
addq $8, %rsp # 回卷栈。
popq %rbx # 恢复 rbx。
popq %rbp # 恢复 rbp。
ret # return 语句。2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
编译器已经彻底重组代码。新代码复现原代码的效果,输出保持相同;但它不使用内存中的对象,不比较指针是否相等,也完全不见复合字面量的踪迹。例如,它根本没有实现 j = 0 的迭代,因为该迭代没有任何效果,直接省略即可。对于其他迭代,它先单独处理 j = 1 的版本,此时已知 C 程序中的指针 p 和 q 不同;一般情况再递增 j,并相应设置 printf 的实参。
练习 49
利用 p 会反复得到同一个值这一事实,编写一段更接近优化汇编版本的 C 程序。
练习 50
即使是优化版本仍有改进余地,循环内部还可以缩短。编写一段 C 程序,启用完整优化编译后探索这项潜力。
以上内容均针对不使用 VLA 的代码。VLA 会改变局面:如果所需内存不是常量大小,简单地用常数修改 %rsp 就不再可行。程序必须根据 VLA 各项边界的实际值,在执行期间计算大小;随后立即相应调整 %rsp;执行离开 VLA 定义所在作用域时,还要撤销对 %rsp 的修改。因此,这里的 %rsp 调整量无法在编译时计算,必须在程序执行期间确定。
本章小结
- 大量对象或大型对象所需的存储可以动态分配和释放,但必须谨慎追踪这些存储。
- 标识符可见性与存储期是不同事物。
- 初始化必须针对每种类型,采用连贯策略系统完成。
- C 对局部对象的分配策略与函数栈的底层处理方式吻合良好。
这里略有简化,后文很快会揭示繁复的细节。 ↩︎
练习 38:补全缺失函数的实现。 ↩︎
练习 39:实现数组缩小。务必在调用
realloc前重新组织数组内容。 ↩︎严格地说,这种对象也可以在另一翻译单元的文件作用域中定义。 ↩︎
链接是标识符的性质,而不是标识符所表示对象的性质。对
extern而言,更好的关键字或许是linkage。 ↩︎你很可能从未显式调用平台链接器。它通常隐藏在所调用的编译器前端背后;动态链接器甚至可能到程序启动时才开始工作,而你不会察觉。这里的
static若改称internal或许更恰当,不过需要理解任何形式的链接都意味着静态存储期。 ↩︎关键字
auto还参与类型推导,第 18 章会完整介绍。 ↩︎约在 1945 年,由 J. Presper Eckert 和 John William Mauchly 为 ENIAC 项目发明;现代科学先驱之一 John von Neumann(1903—1957,亦称 Neumann János Lajos 和 Johann Neumann von Margitta)首次在 von Neumann [1945] 中描述。 ↩︎