学 C 语言的时候,static 大概是最让人困惑的关键字之一。同样是这个词,出现在函数内部是一种意思,出现在函数外部又是一种意思,出现在多文件工程里还牵扯出链接和编译单元这些课上一带而过的概念。不少人的应对办法是把三种用法各自背下来,写代码时照着模板套,一旦被问 "为什么这里加 static",就答不上来了。
这篇文章换个思路:把三种用法放进同一个完整程序里对照着看,再从编译和链接的角度解释它们为什么是这个样子。看完之后你不但记得住,还能给别人讲清楚。
1 先把三种用法摆上桌
static 在 C 语言里一共修饰三类东西,效果各不相同:
| 修饰对象 | 写法位置 | 作用 |
|---|---|---|
| 局部变量 | 函数内部 | 生命周期延长到整个程序运行期间,值在多次调用之间保留 |
| 全局变量 | 所有函数外部 | 作用域限制在本文件(本编译单元)内,其他文件看不到它 |
| 函数 | 函数定义前 | 该函数只在本文件内可见,不允许跨文件调用 |
表格看着规整,但有个现象值得先想一想:同一个关键字,为什么对局部变量改变的是 "活多久",对全局变量改变的是 "谁能看见"?这两个方向看起来毫不相干。这个问题先埋着,第 4 节会给出答案------理解了它,三种用法就不再是三条独立的规则,而是同一个原理的三次落地。
2 三张面孔逐一登场
2.1 第一张面孔:让局部变量 "记住上一次"
普通局部变量的生命周期只有一次函数调用:进入函数时诞生,函数返回时消亡。加了 static 之后,变量只在程序启动时初始化一次,之后一直活在内存的静态存储区,函数返回它也不销毁,下次进函数时它还在原地,值是上次留下的。
一个最经典的应用是统计函数被调用了多少次:
objectivec
int visit_count(void) {
static int count = 0; /* 只在程序启动时执行一次 */
return ++count;
}
这里有个新手最容易犯的错:把 static int count = 0; 理解成 "每次进函数都把 count 清零"。恰恰相反,这行初始化只在程序启动时执行一次,之后的调用中它会直接跳过,从上次的值继续往上加。理解了这一点,"用 static 局部变量做计数器" 这类代码才真正讲得通。
2.2 第二张面孔:把全局变量藏进本文件
普通全局变量在整个工程里默认可见,其他 .c 文件只要写一句 extern int total; 就能访问它。加了 static,这扇门就关上了:
objectivec
/* cache.c */
static int cache_size = 1024; /* 只在本文件内可见 */
int get_cache_size(void) {
return cache_size;
}
cache_size 是这个文件的内部实现细节,外部想获取它,只能通过 get_cache_size 这个公开的函数。如果别的文件试图写 extern int cache_size; 再使用它,链接阶段会直接报错,说找不到这个符号------这不是警告,是编不过。
2.3 第三张面孔:把函数变成 "私有函数"
给函数加 static,效果和给全局变量加 static 一样:只在本文件内可调用。它相当于其他语言的 private:
objectivec
/* utils.c */
static int hash_value(int key) { /* 内部辅助函数 */
return key * 2654435761 % 1000;
}
int insert(int key) { /* 公开接口 */
int slot = hash_value(key);
/* ......后续逻辑...... */
return slot;
}
hash_value 是实现细节,只服务于本文件的 insert。文件外即使有人写了同名函数 hash_value,两者也不会冲突。
3 三张面孔同台:一个完整程序
三种用法散落在不同例子里看,不如放进同一个程序对照着看。下面这个工程模拟一个极简的学生成绩记录模块,三个文件,三种用法各司其职。
record.c 是模块的实现:
objectivec
#include "record.h"
/* 用法二:static 全局变量------本模块的内部状态,外部无法直接访问 */
static int records[MAX_RECORDS];
static int used = 0;
/* 用法三:static 函数------内部辅助函数,不对外暴露 */
static int find_index(int id) {
for (int i = 0; i < used; i++) {
if (records[i] == id) {
return i;
}
}
return -1;
}
int record_add(int id) {
if (used >= MAX_RECORDS || find_index(id) != -1) {
return -1;
}
records[used++] = id;
return 0;
}
int record_count(void) {
/* 用法一:static 局部变量------记录本函数被调用的次数 */
static int query_times = 0;
query_times++;
printf("[record_count] 本函数已被查询 %d 次\n", query_times);
return used;
}
record.h 是对外的头文件:
objectivec
#ifndef RECORD_H
#define RECORD_H
#define MAX_RECORDS 100
int record_add(int id);
int record_count(void);
#endif
main.c 是使用方:
objectivec
#include <stdio.h>
#include "record.h"
int main(void) {
record_add(1001);
record_add(1002);
printf("当前记录数:%d\n", record_count());
printf("当前记录数:%d\n", record_count());
/* 下面的代码如果解开注释,链接时直接报错:
extern int used;
printf("%d\n", used);
error: undefined reference to `used'
*/
return 0;
}
运行输出:
bash
[record_count] 本函数已被查询 1 次
当前记录数:2
[record_count] 本函数已被查询 2 次
当前记录数:2
这个程序值得对照着读一遍,三种用法各自的边界一目了然:
- records 和 used 是模块的内部账本,static 保证外部文件碰不到它们,想读数据只能走 record_count 这条公开通道------这就是 C 语言在没有 class 语法的年代实现 "信息隐藏" 的正统手法;
- find_index 是只给本文件用的工具函数,哪怕工程里其他文件也有个叫 find_index 的函数,两者互不干扰;
- query_times 纯粹是函数自己的记事本,从输出能看到,两次调用之间它的值被保留了下来,而这个计数对外完全不可见。
main.c 里注释掉的那段是留给读者做的实验:解开注释再编译,链接器会亲口告诉你 used 不存在。亲手触发一次这个错误,比看十遍解释都记得牢。
4 为什么一个关键字有三种效果
现在回答第 1 节埋下的问题。表面上看,static 对局部变量管的是生命周期,对全局变量和函数管的是可见性,两件事风马牛不相及。但换个角度看,它们其实是同一句话:把这个东西固定在它 "本来的位置",不给它更大的舞台。
具体来说,C 程序里的对象有三个彼此独立的属性:
- 存储期: 它在内存里活多久。
- 作用域: 代码里哪些位置能直接用这个名字。
- 链接性: 这个符号能否被其他编译单元(可以粗略理解为其他 .c 文件)访问。
局部变量默认的属性是 "自动存储期+块作用域+无链接";static 把存储期改成静态的,让它活过整个程序。全局变量默认 "静态存储期+文件作用域+外部链接";static 把链接性改成内部的,把这个符号关在本文件里。函数同理。三次改动指向的是对象的不同属性,但共同点都是 "收窄"------要么延长生命、收窄可见范围(局部变量本来就不可见,于是只体现生命的延长),要么直接收窄可见范围。
所以 static 的三种用法不是历史巧合堆出来的三条规定,而是同一个关键词在不同上下文里,各自作用于对象某一属性的产物。理解了这层,就不必再靠死记。
5 工程视角:为什么单测框架偏爱 static
写到这里,再讲一个很多学生接触不到但一工作就会撞上的实践问题:单元测试和 static 的关系。
单测的理想状态是 "每个函数都能被单独测试"。麻烦在于,static 函数只有本文件可见,测试代码写在另一个文件里,根本调不到它。单测框架的通行解法是:把测试代码和被测代码放进同一个编译单元。常见做法有三种:
- **条件编译:**在源文件里预留一个由宏控制的 #include,测试构建时把测试文件包含进来,生产构建时这个宏不定义,测试代码一行都不会出现:
objectivec
/* record.c 末尾 */
#ifdef RECORD_UNIT_TEST
#include "test_record.c"
#endif
- 直接把测试函数写进源文件,同样用宏控制是否编译。适合被测函数多、测试逻辑重的模块;
- **通过公开函数间接覆盖:**static 函数本来就是公开接口的实现细节,只测公开接口、让它内部逻辑顺带被跑一遍,是更符合 "测行为不测实现" 原则的思路。
前两种手段在 CppUTest、CMocka 这些框架的示例工程里都能见到。如果你以后在嵌入式或 C 项目里写单测,大概率会和这两种写法打交道------到那时回头看这篇文章,会发现 static 限制可见性的这个 "缺点",恰恰是它能让测试如此干净的原因:内部细节被圈定在文件内,测试要做的只是想办法钻进这个圈里看一眼。
回头再看三种用法,它们其实共同描绘了一件事:C 语言虽然古老,但 "控制可见性、隐藏实现细节、只暴露该暴露的接口" 这些现代工程的关切,它早在 class 出现之前就给出了自己的答案。static 这个不到十个字符的关键字,就是答案的第一笔。把它吃透,你写出来的多文件工程才算真正开始有了 "模块" 的样子。