第12章:存储类别、链接和内存管理
变量的类型说明“怎样解释数据”,存储规则则说明“怎样找到数据,以及数据会存在多久”。本章把单个函数中的变量扩展到多次调用、多个源文件和运行时分配的内存。
理解这些规则时,要把三个问题分开:作用域决定名字在哪里可见,链接决定不同声明是否指向同一个实体,存储期决定对象存在多久。
1. 对象、标识符与访问方式
int value = 10;
int *p = &value;
value 对应一个存放整数的对象;p 对应另一个存放地址的对象。
| 表达式 | 含义 |
|---|---|
value |
用标识符指定整数对象 |
p |
用标识符指定指针对象 |
*p |
通过指针指定那个整数对象 |
&value |
得到整数对象的地址 |
&p |
得到指针对象自身的地址 |
一个对象可以有多种访问方式,匿名的动态分配对象也不一定有自己的变量名。
指定对象的表达式可以是左值,但左值不一定可修改。例如,通过 const int * 解引用得到的表达式是左值,却不能通过它修改整数。
不要把“名字已经不可见”直接等同于“对象已经不存在”,也不要因为一个指针变量还存在,就认为它指向的对象仍然存在。
2. 作用域:在源码的什么位置可以使用名字
2.1 四类作用域
| 类别 | 适用对象与范围 |
|---|---|
| 块作用域 | 块中声明的局部标识符,通常从声明处到所在块结束 |
| 文件作用域 | 函数之外的声明,从声明处到翻译单元结束 |
| 函数原型作用域 | 原型中的形参名,到该原型声明结束 |
| 函数作用域 | goto 使用的标签,范围覆盖整个函数 |
函数定义的形参具有块作用域。不要把“函数里的局部变量”误称为具有函数作用域;标准中的函数作用域主要用于标签。
2.2 内层同名声明会隐藏外层声明
#include <stdio.h>
int main(void)
{
int value = 10;
{
int value = 20;
printf("inner: %d\n", value);
}
printf("outer: %d\n", value);
return 0;
}
输出:
inner: 20
outer: 10
两个 value 对应不同对象。内层声明不会把外层对象改成新类型,也不会因为隐藏它就销毁它。
for (int i = 0; i < 3; ++i) {
/* i 在这里可见。 */
}
// 这个循环声明的 i 在这里不可见。
尽量避免不必要的同名隐藏。就近声明变量能缩小可见范围,但让多层变量故意同名通常只会增加阅读成本。
2.3 文件作用域不是从文件第一行开始
void first(void);
int total = 0;
void second(void)
{
++total;
}
total 的文件作用域从其声明处开始。位于声明之前的代码不会自动获得这个名字,需要事先有适当声明。
2.4 翻译单元不等于一个物理文件
一个 .c 文件经过预处理,连同它包含的头文件内容,构成一个翻译单元。
main.c + 它包含的头文件 → 一个翻译单元
stats.c + 它包含的头文件 → 另一个翻译单元
理解文件作用域和内部链接时,更准确的范围是翻译单元,而不只是编辑器里看到的那个 .c 文件。
3. 链接:不同声明怎样指向同一个实体
3.1 三种链接属性
| 链接属性 | 含义 | 常见例子 |
|---|---|---|
| 无链接 | 这个声明中的名字不通过链接与其他声明共享实体 | 普通局部变量、局部静态变量 |
| 内部链接 | 同一翻译单元中的相关声明指向同一实体 | 文件作用域的 static 对象或函数 |
| 外部链接 | 不同翻译单元中的相关声明也可以指向同一实体 | 通常的文件作用域对象、公开函数 |
链接不是运行时的指针连接,也不等于可见范围。另一个源文件中的对象具有外部链接,不代表当前文件不写声明就能直接使用它。
3.2 局部名字不一定都无链接
普通块内变量通常无链接,但块内也可以出现对已有对象的 extern 声明:
int total = 0;
void add_one(void)
{
extern int total;
++total;
}
块内的这个声明仍然引用文件作用域的 total,没有创建一个新的自动变量。
如果去掉 extern,改成块内的 int total;,才是在创建并隐藏外层名字的局部对象。
4. 存储期:对象存在多久
| 存储期 | 生存期的大致范围 | 典型对象 |
|---|---|---|
| 自动存储期 | 对应块的一次执行期间;VLA 从声明执行时开始 | 普通局部变量、形参、局部 VLA |
| 静态存储期 | 程序整个执行期间 | 文件作用域对象、局部静态对象、字符串字面量 |
| 线程存储期 | 所属线程的执行期间 | C11 的 _Thread_local 对象 |
| 分配存储期 | 成功分配之后,直到相应释放 | malloc、calloc 等取得的内存 |
这些概念描述语言规则,不要求实现一定把自动对象放在硬件栈、把动态对象放在某个固定地址区间。
4.1 自动对象每次调用有各自的实例
函数每次被调用,自动局部对象会重新参与本次执行。递归调用时,各层调用的普通局部变量也是不同对象。
退出块后,不得再通过保存的地址访问已经结束生存期的自动对象:
// 不要从函数返回指向普通局部变量或局部数组的指针。
4.2 静态存储期不表示只读
static int count = 0;
static 不会禁止修改 count。它的作用取决于声明位置;只读访问由 const 等类型规则表达。
4.3 线程对象与全局共享对象不同
_Thread_local int thread_count = 0;
在支持相应 C11 特性的实现中,每个线程拥有自己的实例,而不是所有线程共用同一个计数器。
线程存储期与链接仍是不同维度。_Thread_local 还可以按适用规则和 static、extern 组合;本章只需掌握“每线程各有一份”的区别,不必由此展开完整并发编程。
5. 五种常见存储类别放在一起比较
以下以本章采用的 C99/C11 规则为背景:
| 常见类别 | 典型声明位置与写法 | 作用域 | 链接 | 存储期 |
|---|---|---|---|---|
| 普通自动变量 | 块内 int n; |
块 | 无 | 自动 |
| 寄存器变量 | 块内 register int n; |
块 | 无 | 自动 |
| 局部静态变量 | 块内 static int n; |
块 | 无 | 静态 |
| 文件内私有对象 | 文件作用域 static int n; |
文件 | 内部 | 静态 |
| 可跨文件共享对象 | 文件作用域 int n; 或带初始化的定义 |
文件 | 外部 | 静态 |
extern 常用来补充对已有对象的声明,不能只看到关键字就认定它在创建一种新的独立对象。
C99/C11 把 auto、register、static、extern、_Thread_local 和 typedef 归入存储类别说明符。其中 typedef 用于定义类型别名,本身不分配对象内存。这种语法分类不表示这些关键字都在完成同一种内存操作。
5.1 auto 是普通自动变量的显式说明
void task(void)
{
auto int value = 10;
}
在这里,auto 通常可以省略。本章语境中的 auto 是存储类别说明符,不是 C++ 中常见的类型推导用法。
5.2 register 是请求,不是硬件保证
void task(void)
{
register int index = 0;
// &index 在本章采用的规则下不允许。
}
它请求实现考虑更快的访问方式,但编译器可以忽略请求。即使最终没有放进 CPU 寄存器,仍然不能对该变量使用地址运算符。
程序的正确性和效率不应依赖“写了 register 就一定进入寄存器”。
5.3 自动变量不会默认清零
普通自动对象没有初始化时,值不确定,不能直接读取。它们可以用运行时表达式初始化:
int first = 3;
int second = first * 2;
静态存储期对象没有显式初始化时会被零初始化:整数得到零、算术类型得到相应零值、指针得到空指针。不要把这简单说成“所有类型的所有位都必定清零”;语言的零初始化与字节模式是不同层面的规则。
6. 局部静态变量:函数调用之间保留状态
#include <stdio.h>
void report(void);
int main(void)
{
for (int i = 0; i < 3; ++i) {
report();
}
return 0;
}
void report(void)
{
int fresh = 1;
static int saved = 1;
printf("fresh = %d, saved = %d\n", fresh, saved);
++fresh;
++saved;
}
输出:
fresh = 1, saved = 1
fresh = 1, saved = 2
fresh = 1, saved = 3
fresh 每次调用重新初始化,saved 始终是同一个对象。saved 的初始化不会在每次进入函数时重新执行。
局部静态对象的名字仍然只在相应块内可见。函数返回后对象继续存在,不意味着其他函数自动获得了这个名字。
适合保存调用次数、局部持久状态;但它会引入调用之间的依赖。递归调用不会为静态局部对象额外创建独立副本,多线程访问也需要另外考虑同步。
静态存储期对象的初始化不能随意调用函数获取运行时值:
int initial_value(void);
void task(void)
{
// static int saved = initial_value(); // 不是合法的 C 静态初始化。
}
确实需要运行时初始化时,要另行设计执行逻辑,而不是套用其他语言的局部静态初始化规则。
7. 文件作用域的 static:把实现细节留在模块内
static unsigned count = 0;
static void record_one(void)
{
++count;
}
unsigned current_count(void)
{
record_one();
return count;
}
count 与 record_one 都具有内部链接。其他翻译单元不能通过同名外部声明引用它们,可以定义自己的同名内部实体。
current_count 默认具有外部链接,适合作为模块公开接口。
普通文件作用域对象(未使用 _Thread_local)本来就有静态存储期;在这里添加 static 的关键区别是把链接改为内部链接。不要把它解释成“只有加上 static 才能活到程序结束”。
函数本身不属于普通数据对象,不宜把局部变量的存储期规则直接套到函数上。对函数使用 static,主要是在控制链接。
8. extern、定义与多文件共享
8.1 声明提供信息,定义提供实体
extern int total; // 通常是引用声明,不在这里定义对象。
int total = 0; // 定义对象并初始化。
一个对象可以有多次相容声明,但需要按语言和链接规则提供定义。跨源文件共享变量时,清楚采用“一处定义,其他地方声明”的组织方式。
extern 不是“去其他文件找”的机械指令,它也可以引用同一文件中已有的对象。
文件作用域带初始化的 extern 声明仍然是定义:
extern int total = 7; // 文件作用域下,这仍然定义了对象。
因此,不能在多个 .c 文件中都写这一句,指望它们只是重复声明。
8.2 不带初始化的文件作用域声明有特殊规则
int total;
在文件作用域,这通常是暂定定义。若同一翻译单元中没有其他完整定义,到翻译单元末尾会按规则形成零初始化的定义。
不要把普通的 int total; 放进被多个 .c 包含的头文件,以为它只是一个共享声明。头文件中应使用 extern int total;,实际定义放在一个 .c 中。
8.3 完整的多文件示例
头文件 counter.h:
#ifndef COUNTER_H
#define COUNTER_H
extern unsigned total;
void record_event(void);
#endif
实现文件 counter.c:
#include "counter.h"
unsigned total = 0;
static unsigned batch = 0;
void record_event(void)
{
++batch;
total += batch;
}
调用文件 main.c:
#include <stdio.h>
#include "counter.h"
int main(void)
{
record_event();
record_event();
printf("total: %u\n", total);
return 0;
}
在这些文件所在目录编译:
cc -std=c11 -Wall -Wextra main.c counter.c -o counter_demo
输出:
total: 3
main.c 与 counter.c 使用的是同一个 total。batch 由模块私有,调用者只能通过函数影响它。
这个示例为了说明共享变量而公开 total。实际设计中,也可以把它设为内部对象,再用读取函数公开结果,进一步限制外部修改。
8.4 extern 不会自动改变已有内部链接
static int private_count = 0;
void record(void)
{
extern int private_count;
++private_count;
}
这里块内的声明引用已可见的内部对象,不会使它突然变成可以跨翻译单元共享的外部对象。
8.5 函数的默认链接
int public_task(void);
extern int another_task(void);
static int helper(void);
通常的函数声明默认具有外部链接,extern 可以显式表达这一点;static 用来声明当前翻译单元私有的函数。
头文件放公开函数原型,内部辅助函数留在实现文件中。不要通过包含另一个 .c 文件来代替正常的多文件编译。
9. 随机数:用持续存在的状态生成序列
9.1 rand 与 srand 分工不同
#include <stdlib.h>
srand(123);
int value = rand();
rand 返回 0 到 RAND_MAX 之间的整数,RAND_MAX 定义在 <stdlib.h> 中,标准保证它至少为 32767。
srand 设置种子,影响随后生成的序列。同一实现中使用相同种子,并执行相同的调用过程,会得到相同序列;不同实现不保证产生同一组具体数字。
在没有显式调用 srand 时,序列按种子为 1 的情形开始。伪随机的含义是序列由内部状态和算法决定,不是每次调用都凭空产生不可预测的数字。
9.2 为什么生成器需要静态状态
当前状态 → 算法变换 → 新状态 → 本次输出
↓
留给下一次调用
如果把状态作为每次都重置的自动局部变量,函数就无法自然地延续序列。
可以把状态放在文件作用域的内部对象中,让设置种子的函数和生成数字的函数共享它;其他文件只通过公开接口操作。这是 static 与模块封装的一种典型结合。
也可以把状态交给调用者持有,通过参数传入,从而同时维护多组独立序列。需要什么范围的共享,就选择相应的状态归属。
9.3 完整示例:验证固定种子的可重复性
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int first[5];
srand(123);
for (int i = 0; i < 5; ++i) {
first[i] = rand();
}
srand(123);
for (int i = 0; i < 5; ++i) {
if (rand() != first[i]) {
return EXIT_FAILURE;
}
}
puts("same seed, same sequence");
return EXIT_SUCCESS;
}
固定种子方便复现调试。想让不同运行通常使用不同种子,可以在程序初始化时调用:
#include <stdlib.h>
#include <time.h>
srand((unsigned int)time(NULL));
通常只在初始化时设种子,不要每掷一次骰子就重新设种子。时间变化不够快时,反复用时间设种子可能反复生成相同结果。
9.4 将结果映射到骰子点数
int roll_die(int sides)
{
if (sides < 2 || sides > RAND_MAX) {
return 0;
}
return rand() % sides + 1;
}
先求余得到 0 到 sides - 1,再加 1。这是简单模拟的常见写法,调用者需要识别无效参数时返回的 0。
它不保证严格均匀:即使原始输出均匀,当取值总数不能被面数整除时,取模也会引入偏差。统计质量要求更高时,需要选择适当的生成器与映射算法。rand 也不适合生成密码或安全令牌。
若连续掷多个骰子,还要保证累加结果不会超出目标整数类型范围。验证面数、次数和总和范围,是调用接口的一部分。
10. 动态分配:把对象的生存期交给程序管理
10.1 malloc 接收的是字节数
#include <stdlib.h>
double *data = malloc(count * sizeof *data);
count 是元素个数,sizeof *data 是一个元素的大小,乘积才是所需字节数。
malloc 返回 void *,在 C 中可以隐式转换为相应对象指针,不要求额外强制转换。正确包含 <stdlib.h> 比手写或猜测函数声明更重要。
sizeof *data 不会在这里读取一个 double 值,它取得所指类型的大小。把类型大小写成这种形式,修改指针类型时也不容易漏改分配表达式。
malloc 分配的内容没有被初始化。分配成功后,需要先赋值再读取。
10.2 先验证数量和乘法,再检查分配结果
#include <stdint.h>
#include <stdlib.h>
double *data = NULL;
if (count == 0 || count > SIZE_MAX / sizeof *data) {
/* 拒绝零长度或会使字节数乘法溢出的请求。 */
} else {
data = malloc(count * sizeof *data);
if (data == NULL) {
/* 分配失败,不能解引用 data。 */
}
}
size_t 的乘法可能发生无符号回绕,使一个很大的请求变成很小的分配。只检查 malloc 是否返回空指针,不足以发现之前已经算错的字节数。
对于零长度请求,分配函数具有特殊规则。初学时可以让自己的接口明确拒绝零长度,避免拿返回地址当成存在可访问元素的保证。
若数量来自有符号整数输入,还应先拒绝负数,再转换为 size_t。负数直接参与无符号大小计算,可能变成一个极大的请求。
10.3 指针对象与分配对象有不同生存期
函数调用期间:
局部指针 data ──→ 动态分配的数据块
函数返回后:
局部指针可以消失,数据块不会因此自动释放。
动态数据可以在函数中分配,通过返回值交给调用者,最后在调用者中释放。反过来,局部指针还在,并不意味着它所指的数据没有被别处释放。
sizeof data 只得到指针大小,不是动态分配的字节数。元素个数必须由程序另行保存并传递。
11. 完整示例:创建数组并转交释放责任
下面的函数约定:拒绝零元素请求;失败返回空指针;成功返回包含 count 个元素的数组,由调用者负责释放。
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
int *make_array(size_t count, int initial);
int main(void)
{
size_t count = 5;
int *data = make_array(count, 3);
if (data == NULL) {
fputs("allocation failed\n", stderr);
return EXIT_FAILURE;
}
int total = 0;
for (size_t i = 0; i < count; ++i) {
total += data[i];
}
printf("count: %zu, total: %d\n", count, total);
free(data);
data = NULL;
return EXIT_SUCCESS;
}
int *make_array(size_t count, int initial)
{
int *data;
if (count == 0 || count > SIZE_MAX / sizeof *data) {
return NULL;
}
data = malloc(count * sizeof *data);
if (data == NULL) {
return NULL;
}
for (size_t i = 0; i < count; ++i) {
data[i] = initial;
}
return data;
}
输出:
count: 5, total: 15
返回时消失的是函数的局部指针变量,返回的地址值已经交给调用者。动态分配对象仍存在,直到调用者执行 free。
这与返回普通局部数组的地址完全不同。返回一个地址是否合法,要看被指向对象的生存期,而不只是看返回值类型是否为指针。
12. 释放、别名与内存泄漏
12.1 free 归还的是一整块分配
free(data);
data = NULL;
free 不需要额外传入元素个数。对于非空参数,必须传入尚未释放的、由兼容分配函数返回的分配起始地址。
int *data = malloc(10 * sizeof *data);
// free(data + 1); // 错误:不是分配块的起始地址。
// free(local); // 如果 local 是普通局部数组,也不能这样释放。
free(NULL) 没有效果,可以用于简化清理逻辑。释放一块内存后,不能再次释放同一块,也不能继续访问其中对象。
12.2 设一个指针为空,不会清理所有别名
int *owner = malloc(sizeof *owner);
if (owner != NULL) {
int *alias = owner;
*alias = 7;
free(owner);
owner = NULL;
// alias 没有因此变成 NULL,也不能再用于访问已释放对象。
}
释放后,不应再读取、解引用或拿旧别名继续进行地址计算。把 owner 设为空指针,有助于阻止经由这个变量误用内存,却不能使其他旧别名恢复有效。
清晰的设计应说明:谁拥有释放责任,谁只是临时借用,以及借用何时结束。
12.3 内存泄漏是丢失了释放机会
分配成功 → 保存地址 → 完成使用 → 释放
↓
地址丢失且没有释放
↓
内存泄漏
常见原因包括:
- 函数退出前忘记释放临时分配。
- 把唯一保存地址的指针改指向别处。
- 错误分支直接返回,跳过已成功分配对象的清理。
不使用某块内存,不等于它已经可以被分配器重新使用。释放责任要覆盖成功路径与失败路径。
13. calloc、VLA 与动态数组的区别
13.1 calloc 把整块内存的位清零
int *counts = calloc(count, sizeof *counts);
calloc 接收元素数量与元素大小,分配后把所有位设为零,失败返回空指针,结果同样由 free 释放。
| 项目 | malloc |
calloc |
|---|---|---|
| 主要参数 | 总字节数 | 元素数量、单个元素字节数 |
| 初始内容 | 未初始化 | 所有位清零 |
| 释放方式 | free |
free |
对于整数计数数组,清零通常正好符合需求。但不要把“所有位为零”无条件等同于所有类型的语言零值:浮点数与空指针表示并不在所有实现上都必须是全零位模式。
例如,若需要一个初始化为空指针的指针数组,逐个赋值 NULL 能直接表达语言层面的意图。
13.2 VLA 由块的执行管理,动态内存由程序管理
| 比较项 | 局部 VLA | 分配得到的数组 |
|---|---|---|
| 大小确定时间 | 声明执行时 | 调用分配函数时 |
| 创建后是否自动变长 | 否 | 否 |
| 何时释放 | 退出相应块时自动结束 | 程序调用 free |
| 能否把有效数据交给调用者长期使用 | 不能返回局部 VLA 地址后继续使用 | 可以转交指针和释放责任 |
sizeof 实际对象 |
可得到运行时数组大小 | 指针本身不记录分配大小 |
| 失败处理 | 没有类似 malloc 的空指针返回通道 |
检查返回空指针 |
不能对 VLA 调用 free。两种方式都允许运行时决定元素数量,但它们的存储期与管理责任不同。
13.3 动态分配连续二维数组
固定列数时,可以使用指向一行的指针:
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
enum { COLS = 3 };
size_t rows = 2;
int (*matrix)[COLS];
if (rows == 0 || rows > SIZE_MAX / sizeof *matrix) {
return EXIT_FAILURE;
}
matrix = malloc(rows * sizeof *matrix);
if (matrix == NULL) {
return EXIT_FAILURE;
}
int total = 0;
for (size_t r = 0; r < rows; ++r) {
for (size_t c = 0; c < COLS; ++c) {
matrix[r][c] = (int)(r * COLS + c + 1);
total += matrix[r][c];
}
}
printf("last: %d, total: %d\n", matrix[1][2], total);
free(matrix);
return EXIT_SUCCESS;
}
输出为 last: 6, total: 21。sizeof *matrix 是一整行的大小,所以分配的是 rows 行。
支持变长数组类型时,也可以用运行时列数声明 int (*matrix)[cols]。创建这种类型前,必须先确保列数为正,行大小与总大小计算也都能表示。
这种连续布局使用 int (*)[COLS],不是 int **。若创建的是“指针数组加各行独立分配”的布局,就需要分别管理指针数组和各行对象。
14. 多层分配:每一层都要有对应清理
若每个字符串长度不同,可以先分配一个指针数组,再为每个字符串分配恰好容纳它的空间:
words ──→ [指针0] ──→ "red\0"
[指针1] ──→ "green\0"
[指针2] ──→ "blue\0"
这里总共有四块分配:一个指针数组,加三个字符数组。只调用 free(words) 不会自动释放三个字符串。
下面的程序展示正常释放以及分配到一半失败时的回收方式:
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char **copy_words(const char *const source[], size_t count);
void free_words(char *words[], size_t count);
int main(void)
{
const char *const source[] = {"red", "green", "blue"};
size_t count = sizeof source / sizeof source[0];
char **words = copy_words(source, count);
if (words == NULL) {
return EXIT_FAILURE;
}
words[0][0] = 'R';
for (size_t i = 0; i < count; ++i) {
puts(words[i]);
}
free_words(words, count);
return EXIT_SUCCESS;
}
char **copy_words(const char *const source[], size_t count)
{
char **words;
if (count == 0 || count > SIZE_MAX / sizeof *words) {
return NULL;
}
words = malloc(count * sizeof *words);
if (words == NULL) {
return NULL;
}
size_t made = 0;
for (; made < count; ++made) {
size_t bytes = strlen(source[made]) + 1;
words[made] = malloc(bytes);
if (words[made] == NULL) {
free_words(words, made);
return NULL;
}
strcpy(words[made], source[made]);
}
return words;
}
void free_words(char *words[], size_t count)
{
if (words == NULL) {
return;
}
for (size_t i = 0; i < count; ++i) {
free(words[i]);
}
free(words);
}
输出:
Red
green
blue
source 中每一项都必须指向有效字符串。copy_words 复制字符内容,因此得到的字符数组可以独立修改。
made 只记录已经成功分配并复制的项。失败时,仅释放这些已完成对象,再释放外层指针数组;不读取后面尚未初始化的指针。
成功后,整个返回结果的释放责任交给调用者。free_words 要接收正确的已拥有字符串数量,它不是可以随意用于普通二维字符数组的通用释放函数。
15. 类型限定符:约束访问,表达优化与并发要求
同一层类型上重复使用相同限定符,不会叠加出额外一层限制。例如,类型别名已经包含 const 时,再加同层 const 不会得到一种“更只读”的对象。
15.1 const 限制的是相应访问或对象修改
int value = 1;
const int *reader = &value;
int *const fixed = &value;
| 声明 | 指针能否改变 | 能否通过指针修改整数 |
|---|---|---|
const int *reader |
能 | 不能 |
int *const fixed |
不能 | 能 |
const int *const both |
不能 | 不能 |
如果原对象没有被定义成 const,它仍可以通过其他合法的非 const 访问方式修改。真正定义为 const 的对象,不能通过去掉限定符的指针绕过限制后修改。
函数接收普通整数形参时,本来就得到一个值的副本。给这种形参加 const,限制的是函数内对副本的赋值,不会额外保护调用方原整数。
指针形参则不同:const int *data 可以表达“通过这个形参不修改调用方的整数”。
15.2 跨文件共享只读对象
一处定义,头文件提供引用声明:
// constants.h 中
extern const double conversion_rate;
// constants.c 中
const double conversion_rate = 2.54;
标准 C 中,文件作用域的普通 const 定义不会仅仅因为 const 就自动得到内部链接。这一点与 C++ 的常见规则不同。
也可以在头文件中写:
static const double conversion_rate = 2.54;
这会让各翻译单元分别拥有一个内部对象,通常不是所有文件共用同一个对象。小型常量可能适用,大型只读数组则要考虑重复存储。
指针数组还有两层只读问题:
static const char *const labels[] = {"yes", "no"};
第一个 const 限制所指字符,第二个限制数组中的指针元素。只有 const char *labels[] 时,字符通过这些指针只读,但指针本身仍可改指向。
15.3 volatile 表示访问具有外部可观察要求
volatile unsigned status;
const volatile unsigned *device_status;
volatile 常用于特定环境中的设备寄存器等对象,提醒实现按照相应规则保留访问,不能随意假设两次读取一定得到相同结果。
const volatile 可以表达:程序通过这个访问方式不写入,但对象值仍可能被外部机制改变。
volatile 不会自动使复合操作成为原子操作,也不提供普通线程之间所需的同步。不能仅把共享计数器声明为 volatile,就认为 ++counter 在并发下已经正确。
15.4 restrict 是关于访问别名的承诺
void add_values(double *restrict target,
const double *restrict source, size_t count)
{
for (size_t i = 0; i < count; ++i) {
target[i] += source[i];
}
}
这个示例要求本次处理的源范围与目标范围互不重叠。编译器可以利用这种承诺,减少对别名修改的顾虑。
restrict 不等于“整个程序只能有一个指针保存这个地址”。它约束的是特定执行范围中,相关对象通过哪些访问路径被读取和修改。规则不能简单套用为指针数量限制。
承诺通常不会由运行时自动检查;调用者违反要求,可能导致未定义行为。只有能保证访问关系时才使用它,不能把它当成无条件提升速度的装饰。
memcpy 要求源和目标范围不重叠;memmove 允许重叠。两者的选择体现了接口对访问范围的不同约定。
15.5 _Atomic 用于原子访问
在支持 C11 原子特性的实现中,可以使用 <stdatomic.h>:
#include <stdatomic.h>
#include <stdio.h>
int main(void)
{
_Atomic unsigned counter;
atomic_init(&counter, 0);
unsigned before = atomic_fetch_add(&counter, 1);
unsigned after = atomic_load(&counter);
printf("before: %u, after: %u\n", before, after);
return 0;
}
输出为 before: 0, after: 1。
atomic_fetch_add 将读取旧值和加法更新组成一个原子操作。原子操作不会以部分完成的状态被相应并发访问观察到,但这不表示执行期间其他线程完全不能访问这个对象,也不等于自动给整个函数加锁。
多个原子操作组成的算法,仍需要正确设计操作关系与同步。尤其不能把 atomic_load 后再 atomic_store 的两步更新,直接当作一个不可分割的累加操作。
_Atomic、volatile、const 与 restrict 解决的问题不同,不能相互替代。
16. 数组形参方括号中的限定符与 static
C99 允许在数组形参最外层方括号中写限定符。它们作用于调整后的指针形参:
void process(int data[const], size_t count);
// 对应形参指针为 int *const data。
void update(int data[restrict], size_t count);
// 对应形参指针为 int *restrict data。
这与下面限制元素访问的写法不同:
void show(const int data[], size_t count);
// 对应形参为 const int *data。
方括号里的 static 还有另一种含义:
double sum_first_four(const double data[static 4]);
它要求每次调用时,实参提供至少 4 个可访问元素。这是调用约定,不会在函数内部创建静态数组,不代表返回后仍保存实参数组,也不保证编译器为你插入边界检查。
static 出现位置 |
主要含义 |
|---|---|
| 块内对象声明 | 对象具有静态存储期 |
| 文件作用域对象声明 | 对象具有内部链接,同时本来就有静态存储期 |
| 文件作用域函数声明 | 函数具有内部链接 |
| 数组形参的最外层方括号 | 对本次调用提供的最少元素数量作出要求 |
理解一个关键字时,要结合完整声明和出现位置,而不是只记一个固定中文解释。
17. 选择存储方式时先考虑责任范围
| 需求 | 合适的组织方式 |
|---|---|
| 一次函数调用中的临时数据 | 普通自动变量 |
| 一个块执行期间需要运行时数组大小 | 支持且规模合适时使用 VLA |
| 多次调用之间保留一份局部状态 | 局部静态对象,并明确共享影响 |
| 一个模块内多个函数共同使用状态 | 文件作用域内部对象,通过公开函数操作 |
| 确实需要跨模块共享同一个对象 | 一处定义,相容的外部声明 |
| 数据需要超出创建函数继续存在 | 动态分配,明确把释放责任交给谁 |
| 每个线程分别维护自己的状态 | 适当的线程存储期对象 |
| 多线程共同更新对象 | 使用适当的原子操作或其他同步机制 |
多数临时数据使用普通局部变量最容易推理。扩大可见范围、保留跨调用状态或延长数据生存期时,应能说清楚由谁访问、谁修改、谁最终清理。
看到一个指针时,除了检查类型,还应确认它指向的对象是否仍然存在;看到一个共享变量时,除了确认名字可见,还应确认所有声明是否引用同一个对象,以及修改是否符合接口约定。

