作用域、链接与存储期
本节目标
查询 C23 标识符名字空间、作用域、链接、存储期、生存期以及 static 与 extern 的位置语义。
看到一个名字时,不要笼统地问“这个变量活多久”,而要依次拆开四个问题:名字在哪里可见、不同声明是否指向同一实体、对象的存储至少持续多久,以及当前访问是否仍发生在对象生存期内。本章以 ISO C23 为基线,把名字的属性与对象的属性分开查询。
标识符的名字空间
C 的“标识符名字空间”不是 C++ 的 namespace 语法,而是同一作用域中查找名字时采用的分类。先确定名字出现的语法位置,再判断它会与哪些声明冲突。
| 名字空间 | 包含的名字 | 查找线索 |
|---|---|---|
| 标号 | goto 的目标标号 | 标号后跟冒号,goto 后使用同类名字 |
| 标签名 | struct、union、enum 后的名字 | 必须结合对应的标签关键字查找 |
| 成员 | 每个结构体或联合体各自拥有一组成员名 | 通过 . 或 -> 和具体聚合类型确定 |
| 标准属性与属性前缀 | 标准属性名和属性前缀 | 由属性说明符语法和属性记号确定 |
| 前缀后的属性标识符 | prefix::name 中末尾的 name | 每个属性前缀分别拥有一组实现定义属性名 |
| 普通标识符 | 对象、函数、typedef 名、枚举常量等其余标识符 | 普通声明符、枚举常量和表达式中的名字 |
标签、结构体成员、标签名与普通标识符可能使用相同拼写,因为它们属于不同名字空间。这里的“标签”指 goto 使用的标号,“标签名”指 struct、union 或 enum 的 tag;成员名字空间还按每个结构体或联合体分别建立。C23 为标准属性与属性前缀建立一个共享名字空间,并为每个属性前缀引入的实现定义属性标识符分别建立独立名字空间。
struct record {
int record;
};
static void inspect(void) {
struct record record = {.record = 1};
record:
if (record.record == 0) {
goto record;
}
}
这个片段只展示名字查找,不调用 inspect,因此不声明运行结果。record 分别作为标签名、成员、普通标识符和标号出现;相同拼写不等于同一声明。
文件、块、函数与函数原型作用域
作用域回答“从源码的哪个位置到哪里,可以用这个标识符找到该声明”。四类作用域不能仅靠对象存储位置来猜:
| 作用域 | 典型声明 | 可见边界 |
|---|---|---|
| 文件作用域 | 所有块和参数列表之外的声明 | 从声明点延续到当前翻译单元末尾 |
| 块作用域 | 块内声明、函数定义的参数 | 从声明点延续到包含它的块末尾 |
| 函数作用域 | 标号 | 整个函数定义,包括标号出现前后的各层块 |
| 函数原型作用域 | 非函数定义的原型参数名 | 只到该函数声明符结束 |
标签具有函数作用域,不因块结束而消失,因此函数内不同块不能各自声明同名标号。普通局部对象仍是块作用域;函数定义中的形参也进入函数体对应的块作用域。函数原型中的参数名只帮助表达声明,调用方不依赖它与定义中的参数名相同。
int compare(int count, const int values[static count]);
static void choose(int ready) {
if (ready) {
goto selected;
}
selected:
;
}
原型中的 count 和 values 只有函数原型作用域;selected 则在 choose 的整个函数定义内属于同一个标号。
声明点与可见范围
声明所在的语句不是一个不可分割的瞬间。普通声明符声明的名字通常在声明符完成后、初始化器开始前就到达声明点,所以内层同名声明可能在自己的初始化器中遮蔽外层名字。
typedef unsigned long count;
static void measure(void) {
long count = sizeof(count);
(void)count;
}
局部对象 count 的声明点先于 sizeof(count),因此初始化器中的 count 已表示新对象,而不是外层 typedef 名。C23 的推断类型声明中,名字的作用域要到初始化器结束后才开始,所以不能把上面显式写出类型的规则直接套给 auto 推断声明。审查声明时应分别标出声明点和作用域终点;参数声明、枚举常量、标签名等类别还有各自的声明点规则,不能一概写成“从这一行开头可见”。
若内层自动对象在初始化器中读取自己的未确定值,执行会越过程序行为边界;本节不通过运行这种代码来猜结果。有关对象声明和初始化的基础可回查基本类型、对象、声明与初始化。
无链接、内部链接与外部链接
作用域描述声明标识符在源码中的可见范围,链接描述不同作用域或翻译单元中的声明是否指向同一实体。名字当前不可见,不代表实体已经消失;两个声明拼写相同,也不自动代表同一实体。
| 链接种类 | 常见来源 | 多个声明的关系 |
|---|---|---|
| 无链接 | 普通块作用域对象、形参、typedef 名 | 每个声明只在自身语义范围内命名实体,不靠链接跨声明合并 |
| 内部链接 | 文件作用域的 static 对象或函数 | 同一翻译单元内的相容声明可以指向同一实体 |
| 外部链接 | 具有外部链接的文件作用域对象或函数,以及相应 extern 声明 | 不同翻译单元中的相容声明可以指向程序中的同一实体 |
链接是标识符的属性,不是“链接器能否找到文件”的口语缩写。标号、成员和标签名也不靠普通标识符的内部或外部链接规则跨位置合并。代表程序中,counter_value、counter_next 和 call_sequence 具有外部链接;counter.c 的 step 具有内部链接。
同一实体的多次声明
同一实体可以有多次声明,但这些声明必须满足适用的链接与类型兼容规则。相容声明提供的信息不完全相同时,后续使用处可能得到复合类型;“名字一样”不能替代兼容性检查。
extern int samples[];
extern int samples[8];
int transform(int);
int transform(int value);
两条 samples 声明可以共同描述一个具有外部链接的数组实体,并形成包含长度信息的复合类型;两条 transform 原型的参数名是否出现不改变函数类型。若把同一实体分别声明为不兼容类型,或在同一声明链中制造不一致的链接,就不能依据某次实现“恰好接受”而视为可移植程序。
声明是否同时构成定义是另一个判断维度。本章只建立同一实体与兼容声明的边界,不提前链接尚未发布的后续章节。
自动、静态、线程与已分配存储期
存储期描述对象存储的最小持续时间,生存期描述对象值和访问语义何时有效。存储期由声明位置、存储类说明符或分配接口共同决定,不由名字长短或是否跨文件使用决定。
| 存储期 | 典型对象 | 存储持续边界 |
|---|---|---|
| 自动存储期 | 未使用 static、thread_local 或 extern 的普通块作用域对象 | 非 VLA 对象从进入关联块到该块执行结束;VLA 对象从执行声明到程序离开该声明的作用域 |
| 静态存储期 | 文件作用域对象、块内 static 对象 | 存储覆盖整个程序执行,对象只建立一个实例 |
| 线程存储期 | 以 thread_local 声明的对象 | 每个线程拥有独立实例,存储覆盖该线程执行期 |
| 已分配存储期 | 由分配函数取得的对象 | 从成功分配延续到对应释放或重新分配操作结束旧对象 |
块作用域的 thread_local 声明必须同时带有 static 或 extern;无论采用哪种组合,线程存储期都不等于自动获得跨线程同步。已分配存储期也不随保存地址的局部指针离开块而结束。动态分配的完整释放责任可查阅动态内存、生命周期与所有权。
作用域、链接、存储期与生存期
把四个维度放在一起时,先问它描述的是“名字”还是“对象”:
| 维度 | 作用对象 | 核心问题 | 典型误判 |
|---|---|---|---|
| 作用域 | 声明的标识符 | 这段源码能否通过该名字找到声明? | 名字离开作用域就认为所指对象一定销毁 |
| 链接 | 标识符 | 另一处声明是否命名同一实体? | 同名就认为同一实体,或跨文件就认为必有外部链接 |
| 存储期 | 对象 | 为对象保留存储的最小持续时间是什么? | 块内 static 被误认为文件作用域名字 |
| 生存期 | 对象 | 对象值和访问语义当前是否有效? | 仍保存一个地址值就认为对象仍可访问 |
例如,块内 static 对象的名字只有块作用域、没有链接,对象却具有静态存储期;由局部指针指向的分配对象可以在该指针离开作用域后继续存活;反过来,指针对象仍有存储也不能延长它曾指向对象的生存期。
static 与 extern
static 与 extern 必须结合声明位置、声明对象和此前可见声明解释,不能只背一个中文翻译。
| 写法位置 | 对作用域的影响 | 对链接或存储期的主要影响 |
|---|---|---|
文件作用域 static 对象 | 名字仍是文件作用域 | 通常给予内部链接;对象具有静态存储期 |
文件作用域 static 函数 | 名字仍是文件作用域 | 函数名具有内部链接,只在当前翻译单元内关联 |
块作用域 static 对象 | 名字仍限于所在块 | 名字没有链接;对象改为静态存储期并由各次调用共享 |
文件作用域 extern 对象声明 | 名字是文件作用域 | 结合此前声明与初始化器判断链接和定义责任 |
块作用域 extern 声明 | 名字只在所在块可见 | 可以引用具有链接的实体;不会把局部名字扩展成文件作用域 |
数组形参方括号中的 static | 形参仍遵循参数作用域 | 表达调用时最小可访问元素数的契约,不指定对象存储期或链接 |
文件作用域 static 通常给予内部链接,块作用域 static 改变对象存储期但不会让局部名字获得文件作用域。extern 不等于“定义在别的文件”,必须结合声明位置、初始化器和既有声明判断。
下面的三个文件组成一个程序。头文件公开三个外部链接函数;counter.c 把对象 counter 和辅助函数 step 限制在当前翻译单元,并用块内静态对象记录共享调用状态。
#ifndef XSONNET_C_SCOPE_LINKAGE_COUNTER_H
#define XSONNET_C_SCOPE_LINKAGE_COUNTER_H
int counter_value(void);
int counter_next(void);
int call_sequence(void);
#endif
#include "counter.h"
static int counter = 2;
static int step(void) {
return 1;
}
int counter_value(void) {
return counter;
}
int counter_next(void) {
counter += step();
return counter;
}
int call_sequence(void) {
static int calls = 0;
int current_call = calls + 1;
calls = current_call;
return current_call;
}
#include <stdio.h>
#include "counter.h"
int main(void) {
int first_call;
int second_call;
printf("counter-start=%d\n", counter_value());
printf("counter-next=%d\n", counter_next());
printf("counter-next=%d\n", counter_next());
first_call = call_sequence();
second_call = call_sequence();
printf("call-sequence=%d,%d\n", first_call, second_call);
return 0;
}
cc -std=c23 -Wall -Wextra -Wpedantic -Werror main.c counter.c -o scope_linkage_report
输出:
counter-start=2
counter-next=3
counter-next=4
call-sequence=1,2
递归、自动状态与静态状态
递归调用各自拥有自动对象,块内静态对象由所有调用共享。每次函数调用都会为普通自动局部对象建立对应实例,因此递归层可以各自保存参数和中间结果;块内静态对象则只有一个实例,后一层调用会观察并修改同一状态。
static int depth_sum(int depth) {
int current = depth;
if (depth == 0) {
return current;
}
return current + depth_sum(depth - 1);
}
current 在每个活动调用中都是不同的自动对象。代表程序的 call_sequence 则使用块内静态对象 calls,所以连续两次调用共享状态并得到 1,2。把这类共享状态放入递归函数会让结果依赖整个调用历史,还会引入重入与并发边界;只有接口明确需要共享时才这样设计。
遮蔽、链接冲突与悬空对象
下面的片段集中展示审查线索。它们包含生存期错误或不兼容声明,只用于审查,不执行,也没有固定输出:
static int total = 1;
static int *bad_address(void) {
int local = 42;
return &local; // 返回后 local 的生存期结束。
}
extern int mismatched;
extern double mismatched; // 同一实体的不兼容类型声明。
static void shadow(void) {
int total = 2; // 遮蔽文件作用域的 total。
(void)total;
}
从函数返回指向普通自动对象的指针会形成生存期错误。离开 bad_address 后,local 的生存期已经结束;在求值中使用指向它(或刚越过它)的指针值就是未定义行为,保存该地址的指针对象表示会变为不确定。指针对象本身仍在生存期内也不改变这条规则;不要运行这类代码来观察某次调用“看起来还在”的值。
| 症状 | 判断错误 | 修正方向 |
|---|---|---|
| 内层同名声明意外改变含义 | 没有从声明点重新做名字查找 | 缩短作用域、改名,或显式标出被遮蔽的外层声明 |
| 多文件中声明类型不一致 | 只看拼写,没有核对兼容类型与链接 | 让共同接口声明来自同一个自包含头文件 |
把块内 extern 当成本地对象定义 | 把作用域、链接和定义责任混为一谈 | 根据此前声明、初始化器和完整程序逐项判断 |
| 返回局部对象地址 | 把指针对象仍有存储误当成所指对象仍存活 | 返回值副本、使用调用方提供的存储,或采用明确的分配责任 |
最终审查顺序是:先确定名字空间与声明点,再画出作用域;随后判断声明是否通过链接指向同一实体;最后单独追踪对象的存储期和生存期。四步中任何一步都不能由其他一步代替。