Menu

C 语言作用域:块作用域、函数作用域与文件作用域详解

C 语言变量在哪里可见、能存在多久:块作用域、函数参数、文件作用域的全局变量、在多次调用之间保留值的 static 局部变量、static 函数、变量遮蔽,以及全局变量为什么会带来麻烦。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

关于每个变量的两个问题

C 语言中的每个变量都有两个不同的属性,把它们混为一谈正是本页大多数意外的根源:

  • 作用域:这个名字可以在哪里使用。这是编译期的问题。
  • 生存期:这块存储存在多久。这是运行期的问题。

两者通常是同步的(普通局部变量在它的块中可见,也恰好存在那么久),但 static 会把它们分开,这就是为什么这个关键字值得在这里弄懂。

块作用域

在花括号内声明的变量,从声明处到右花括号之间可见,其他地方都不可见。

内层块能看到 outer,因为它嵌套在 outer 所在的块中。外层块看不到 inner。这一规则适用于每一对花括号:函数体、if 语句体、循环体,或者像上面那样单独的块。

循环让这一点更具体。在 for 头部声明的计数器属于这个循环:

for (int i = 0; i < 5; i++) {
    /* i lives here */
}
/* i does not exist here */

这正是你想要的:同一个函数中的两个循环可以各自使用 i,互不干扰,循环结束后也不会意外读到这个计数器。如果你确实需要在循环之后用到它的值,比如查找停下来时的下标,就在循环之前声明它。

函数参数的作用域是整个函数体,所以它们的行为就像在函数体开头声明的局部变量。

C99 允许在块中的任何位置声明变量,而不仅限于开头。在第一次需要时再声明变量;作用域越短的变量,出错的方式就越少。

生存期:自动存储

普通局部变量具有自动存储期:控制流进入它所在的块时它被创建,离开时被销毁。它的内存位于栈上。

它会打印三次 1。每次调用都会得到一个全新的 count,初始化为 0,返回时被丢弃。这也是为什么递归函数的每个栈帧都有自己的一份局部变量副本。

有两个后果值得明确指出。未初始化的局部变量包含的是垃圾值,而不是零,也就是栈上原本的那些字节。另外,返回指向局部变量的指针是未定义行为,因为函数一返回,这块存储就消失了:

/* BUG: buf does not exist after the return */
char *broken(void) {
    char buf[64] = "hello";
    return buf;
}

static 局部变量:在多次调用之间保留值

给局部变量加上 static,它的生存期就变成整个程序运行期间,而作用域完全不变。

现在计数器打印 1、2、3。= 0 只在 main 开始之前执行一次,而不是每次调用都执行。

static 局部变量的三条规则:

  • 初始化表达式必须是常量表达式,因为它是在程序启动时应用的,而不是在运行期。
  • 没有初始化表达式时,static 变量会被初始化为零(这和保存垃圾值的自动局部变量不同)。
  • 这个变量仍然只属于该函数。其他代码都无法通过名字访问它。

最后一点正是它的全部魅力:函数可以在多次调用之间记住某些东西,而不必暴露一个谁都能修改的全局变量。用它来做调用计数器、首次使用时构建的缓存查找表,以及只执行一次的初始化标志。

代价是这个函数不再是纯粹的“输入到输出”的映射,相同的参数可能得到不同的结果,这让它更难测试,而且在没有保护的情况下,多个线程同时调用它也不安全。要有意识地使用 static 局部变量,而不是图方便。

文件作用域:全局变量

在所有函数之外声明的变量具有文件作用域。它从声明处到文件末尾都可见,下面的每个函数都能看到它,它的生存期是整个程序运行期间。

全局变量默认初始化为零,所以即使没有 = 0,totalOperations 也从 0 开始。

全局变量默认还具有外部链接:同一程序中的其他 .c 文件可以通过 extern 声明访问它:

/* in stats.c */
int totalOperations = 0;          /* the definition - exactly one in the program */

/* in main.c */
extern int totalOperations;       /* a declaration: "it exists somewhere" */

把 extern 声明放在头文件里,让每个使用者看到的都是同一个声明。注意这种不对称:extern 声明只是说明这个变量存在,而必须有且只有一个 .c 文件真正定义它。

全局变量为什么会坑人

全局变量是共享数据最简单的方式,也是制造无法定位的 bug 最简单的方式。四个具体问题:

任何代码都能修改它们。 当 totalOperations 的值不对时,罪魁祸首可能是任何文件中的任何一行。如果用参数,嫌疑对象就只是你能看到的那些调用处。

函数变得无法测试。 读取全局变量的函数无法单独调用;你得先设置好全局状态,之后还要记得重置它。

名字在整个程序中有效。 叫 count 或 buffer 的全局变量迟早会和别人的撞名。

它们的初始化表达式必须是编译期常量。 在 C 语言中,全局变量只能用常量表达式初始化:int limit = readConfig(); 无法编译,文件作用域中的 int b = a * 2; 也不行。任何需要计算的值都只能在运行时由某个初始化函数赋值,而在那次调用之前访问这个全局变量的每个文件,看到的都是一个悄无声息的 0。

替代方案几乎总是存在的:把值作为参数传入,返回结果,并把相关的状态打包成一个结构体显式地传来传去。确实存在例外,比如程序级的配置对象、日志句柄,但即便是这些,更好的做法也是把它们设为文件作用域的 static,再提供访问函数,这就是下一节的内容。

文件作用域中的 static:私有化

用在全局变量或函数上时,static 的含义和用在局部变量上时完全不同:它表示内部链接。这个名字变为它所在 .c 文件私有,其他任何文件都无法访问。

/* counter.c */
#include "counter.h"

static int count = 0;           /* private to this file - no other file can touch it */

static void validate(void) {    /* a private helper, not part of the interface */
    if (count < 0) count = 0;
}

void increment(void) {          /* public: declared in counter.h */
    count++;
    validate();
}

int getCount(void) {            /* public */
    return count;
}

其他文件可以调用 increment 和 getCount,但看不到 count,也无法调用 validate,链接器不会解析这些名字。这就是 C 语言(简陋的)模块系统:一个声明公开函数的头文件,其余一切都加上 static。

除了整洁之外,还有两个实际好处。两个文件可以各自有一个 static void validate(void) 而不会冲突,如果这些函数是公开的就做不到。另外,编译器知道 static 函数在文件之外没有调用者,所以可以更积极地内联它,或者在它未被使用时发出警告。

所以这个关键字有两种截然不同的含义,由它出现的位置决定:

static on a LOCAL variable   ->  lifetime becomes permanent (scope unchanged)
static at FILE scope         ->  linkage becomes internal    (lifetime unchanged)

变量遮蔽

在内层作用域中声明一个外层作用域已经存在的名字,就会遮蔽外层的名字:在内层作用域中,这个名字指的是新变量,外层的那个无法访问。

这是合法的,偶尔也是有意为之。但更多时候它是个意外,而且代价不小:一个本应更新全局变量的函数,实际更新的却是同名的局部变量,全局变量始终没变。局部变量遮蔽参数时也是一样,你本想赋给输入参数的值落了空。

gcc -Wshadow 会报告每一处遮蔽。它不包含在 -Wall 中,所以要显式打开:

gcc -Wall -Wextra -Wshadow program.c -o program

彻底避免这个问题的习惯是按用途而不是按类型命名:totalScore 和 itemScore 不会互相遮蔽,而两个都叫 n 的变量迟早会。

速查表

declared inside a block        block scope, automatic lifetime, garbage if uninitialised
function parameter             block scope of the function body, a copy of the argument
static inside a function       block scope, PROGRAM lifetime, zero-initialised, kept between calls
declared outside all functions file scope, program lifetime, zero-initialised, visible to other files
static outside all functions   file scope, program lifetime, PRIVATE to this .c file
extern declaration             names a variable defined in another file

常见问题

C 语言中的作用域是什么?

一个名字在代码中可见的区域。在块(一对花括号)内声明的变量只在这个块中可见,这就是块作用域。在所有函数之外声明的变量具有文件作用域,从声明处到文件末尾都可见。

static 对 C 语言局部变量有什么作用?

它改变变量的生存期,而不改变它的作用域。static 局部变量只创建一次、只初始化一次,并在多次调用之间保留它的值,但它仍然只在所在的函数内可见。这是让函数拥有“记忆”而又不使用全局变量的方法。

C 语言中 static 和全局变量有什么区别?

普通全局变量对程序中的每个文件都可见,其他文件可以通过 extern 访问它。文件作用域中的 static 变量或函数只属于那一个 .c 文件,其他文件无法链接到它。文件作用域中的 static 关乎隐藏;局部变量上的 static 关乎生存期。

C 语言中为什么全局变量不好?

任何函数都能修改它们,所以 bug 可能来自程序的任何地方;读取它们的函数无法单独测试;而且这个名字占据整个程序,容易引起冲突。改为把值作为参数传入并返回结果;在确实需要共享状态的地方,把它设为文件作用域的 static,让只有一个文件能访问它。

Coddy 编程语言插图

和 Coddy 一起学编程

开始