Last Updated: 2020-01-01
在阅读本文档前,请确保你已经掌握指针的基础知识,详见 Basics #01: Pointer 。
通常我们主要讨论变量的作用域,但是事实上,作用域的概念是对应于标识符这一更广泛的概念的。所谓的 标识符 (identifier) 可以表示以下内容:
一个对象(或者说一个变量);
一个函数;
结构体、联合体或枚举的一个 tag 或一个成员;
一个由 typedef
定义的名称;
一个标签(用于 goto
跳转的标签);
一个宏名称或者宏参数。
为使初学者易于理解,本文仅面向变量和函数进行介绍,其它标识符的作用域可类同理解。
标识符的 作用域 (scope) 描述了标识符的 可访问性 (accessability) ,即该标识符在程序的哪些区域内是 可见的 (visible) ,即哪些 语句 (statements) 可以访问该标识符。
例如下面的代码会编译错误,因为变量 a
的作用域局限于 main 函数中,而函数 f
中的语句不在其作用域中,因而无法访问到 a
。
C 语言一共有四种作用域。对于其中的三种,标识符的作用域由其声明的位置决定,具体地讲:
如果声明出现在代码块内,或是函数体的形参列表中的一员,则标识符具有 块级作用域 (block scope) 。块级作用域始于声明出现的位置,终止于对应代码块的末尾。
如果声明出现在任何代码块或参数列表之外,则标识符具有 文件作用域 (file scope) 。文件作用域始于声明出现的位置,终止于对应翻译单元的末尾。
如果声明出现在函数原型的声明语句(注意,不是定义函数体时的语句)的形参列表中,则标识符具有 函数原型作用域 (function prototype scope) 。目前我尚不知晓这种作用域有什么实际意义。
在 C 语言中,局部变量 (local variables) 和 全局变量 (global variables) 就分别对应于块级作用域和文件作用域。
通俗地讲,代码块 (block) 指的是由 {}
包裹的一组程序语句,我们所用的函数体、条件分支、循环体等都定义了代码块。注意仅含单个语句的分支和循环体同样定义了代码块,尽管省略了 {}
符号。
我们也可以使用不含分支或循环的代码块来圈定一个局部的作用域。这种做法在实践中较少使用,通常习惯实现一些小的函数来隔离作用域,这样代码会有更好的可读性和可扩展性。
有关 翻译单元 (translation unit) 的概念我们不在本文中展开,初学者可将其简单理解为若干个 .h
头文件和若干个 .c
源文件的组织,这些对应了一个文件作用域。
全局变量的作用域是整个翻译单元。如果希望在翻译单元内的其它源文件中访问一个全局变量,通常需要用 extern
关键字将其声明在需要的位置。
如果希望变量的作用域局限于当前源文件之内,使之无法被其它文件内的程序语句访问,则需要用 static
关键字将其声明为 静态全局变量 (static global variables) 。
作用域是可以嵌套的,例如文件作用域通常包含了若干个块级作用域,而块级作用域也通常会嵌套数层,例如函数内可以有 if-else
结构,if
也可以嵌套多层。
对于嵌套的作用域,外层作用域中的标识符仍将在内层作用域中生效,这是符合常识的,例如全局变量总是能在函数中使用,而函数中直接声明的变量也总是能在 if
内使用。然而,如果出现了重名的标识符,则情况会有所不同。
在同一作用域内不允许声明重名的标识符,就像变量不能重名一样,但是不同作用域内则允许存在重名的标识符。
如果重名的标识符位于互相独立的作用域内(例如两个不同的函数内),则它们互相独立、互不影响。
如果重名的标识符位于嵌套的作用域内,则在内层作用域内,一旦重名的标识符被声明,则声明于外层作用域的标识符会被临时地覆盖。直到内层作用域终止后,外层的标识符会重新变得可见。
当程序执行进入内层后,外部的重名标识符并不会立刻变得不可见,而是直到内层的标识符被声明时才会被覆盖;当程序执行离开内层后,外部的标识符将重新变得可见。以下面的代码为例:
上述代码的输出结果如下:
形式化地讲,我们将内层作用域记为 ,将外层作用域记为 ,区间的起始点均为程序语句,其前后顺序为 。同时,我们假设更外层的作用域中不存在其它同名的标识符。
对于两个重名的标识符 和 ,有:
在区域 内,通过名称 将访问到 。
在区域 内,通过名称 将访问到 。
在区域 内,通过名称 将访问到 。
函数作用域 (function scope) 是专门针对程序中的 标签 (label) 标识符而设置的,也就是专用于 goto
语法。
与块级作用域和文件作用域不同,函数作用域并非从标签的声明开始可见,而是在标签所处的函数中的任意位置可见。这也符合我们对 goto
语法用法的认知。
变量的 storage duration ,即变量从被创建(被分配内存)到被销毁(内存被回收)的起止时间,决定了变量的生命周期。
变量的 生命周期 (lifetime) 指的是程序执行过程中的一段时间区间,在此期间内,C 标准保证为其保留存储空间。如果变量在其生命周期之外被访问,则会触发未定义行为。
另外,在变量的生命周期结束后,其原先所处的内存地址可能会被分配给其它新的变量,因此如果仍有指针指向这样的变量,对该指针解引用可能会“合法地”访问到其它变量,使程序行为变得难以预测。
根据变量的生命周期,可以将 C 程序中定义的各种变量分为三大类:
静态变量 (static variables) 通常在程序的初始化阶段分配好内存,并在程序的整个生命周期内持续存在,包括全局变量、静态变量和字符串常量等;
自动变量 (automatic variables) 随着函数调用自动地分配,并随着函数返回自动地被回收,包括非静态局部变量等;
动态变量 (dynamic variables) 亦称为 allocated variables ,由动态内存管理函数手动地分配和回收。
有关动态变量的更多内容,详见 Basics #08: Dynamic Memory Allocation 。
值得注意的是,在 C 语言中,静态变量 (static variables) 这一术语有两个容易混淆的不同含义:
与整个程序具有相同生命周期的变量。
使用 static
声明的变量。
这两个含义并不相同,例如声明为 extern
的一般全局变量仅满足第一个定义,而声明在函数体内的 static
变量仅满足第二个定义。因此读者在遇到静态变量这一术语时需要谨慎地辨认。
生命周期与作用域是两个相似但完全不同的概念。一方面,只有具有内存实体的变量才有生命周期的概念,而各类标识符(包括变量、函数声明、标签等)都有作用域的概念;另一方面,即便仅考虑变量,生命周期和作用域也并不等价。
这里即便暂时不能完全理解也没有问题,随着读者的深入学习,在对 C 程序的内存布局等进阶内容有所了解后,就能很轻松地彻底吃透生命周期的概念了。