C Programming Language

Basics #07: Scope and Lifetime

# Basic Tutorial,  # Programming Language,  # C Programming Language

Last Updated: 2020-01-01


前置知识

在阅读本文档前,请确保你已经掌握指针的基础知识,详见 Basics #01: Pointer


作用域

基本概念

通常我们主要讨论变量的作用域,但是事实上,作用域的概念是对应于标识符这一更广泛的概念的。所谓的 标识符 (identifier) 可以表示以下内容:

  • 一个对象(或者说一个变量);

  • 一个函数;

  • 结构体、联合体或枚举的一个 tag 或一个成员;

  • 一个由 typedef 定义的名称;

  • 一个标签(用于 goto 跳转的标签);

  • 一个宏名称或者宏参数。

为使初学者易于理解,本文仅面向变量和函数进行介绍,其它标识符的作用域可类同理解。

标识符的 作用域 (scope) 描述了标识符的 可访问性 (accessability) ,即该标识符在程序的哪些区域内是 可见的 (visible) ,即哪些 语句 (statements) 可以访问该标识符。

例如下面的代码会编译错误,因为变量 a 的作用域局限于 main 函数中,而函数 f 中的语句不在其作用域中,因而无法访问到 a

void f(void) {
    printf("a = %d\n", a);
}
int main(void) {
    int a = 1;
    f(a);
    return 0;
}

C 语言一共有四种作用域。对于其中的三种,标识符的作用域由其声明的位置决定,具体地讲:

  • 如果声明出现在代码块内,或是函数体的形参列表中的一员,则标识符具有 块级作用域 (block scope) 。块级作用域始于声明出现的位置,终止于对应代码块的末尾。

  • 如果声明出现在任何代码块或参数列表之外,则标识符具有 文件作用域 (file scope) 。文件作用域始于声明出现的位置,终止于对应翻译单元的末尾。

  • 如果声明出现在函数原型的声明语句(注意,不是定义函数体时的语句)的形参列表中,则标识符具有 函数原型作用域 (function prototype scope) 。目前我尚不知晓这种作用域有什么实际意义。

在 C 语言中,局部变量 (local variables)全局变量 (global variables) 就分别对应于块级作用域和文件作用域。

块级作用域

通俗地讲,代码块 (block) 指的是由 {} 包裹的一组程序语句,我们所用的函数体、条件分支、循环体等都定义了代码块。注意仅含单个语句的分支和循环体同样定义了代码块,尽管省略了 {} 符号。

#include <stdio.h>
int x = 2;           // in the file scope of the current file
int main(void) {     // block scope of main function
    int a;
    scanf("%d", &a);
    if (a > 5) {     // block scope of if statement
        int b = a * x;
        printf("b = %d\n", b);
    }
    int c = a + b;   // compile error : b is out of scope
    return 0;
}

我们也可以使用不含分支或循环的代码块来圈定一个局部的作用域。这种做法在实践中较少使用,通常习惯实现一些小的函数来隔离作用域,这样代码会有更好的可读性和可扩展性。

int main(void) {
    int a = 1;
    {                // define a block
        int b = a * a;
        a += b;
    }
    int c = a + b;   // compile error : b is out of scope
    return 0;
}

文件作用域

有关 翻译单元 (translation unit) 的概念我们不在本文中展开,初学者可将其简单理解为若干个 .h 头文件和若干个 .c 源文件的组织,这些对应了一个文件作用域。

全局变量的作用域是整个翻译单元。如果希望在翻译单元内的其它源文件中访问一个全局变量,通常需要用 extern 关键字将其声明在需要的位置。

如果希望变量的作用域局限于当前源文件之内,使之无法被其它文件内的程序语句访问,则需要用 static 关键字将其声明为 静态全局变量 (static global variables)

作用域的嵌套和覆盖

作用域是可以嵌套的,例如文件作用域通常包含了若干个块级作用域,而块级作用域也通常会嵌套数层,例如函数内可以有 if-else 结构,if 也可以嵌套多层。

对于嵌套的作用域,外层作用域中的标识符仍将在内层作用域中生效,这是符合常识的,例如全局变量总是能在函数中使用,而函数中直接声明的变量也总是能在 if 内使用。然而,如果出现了重名的标识符,则情况会有所不同。

在同一作用域内不允许声明重名的标识符,就像变量不能重名一样,但是不同作用域内则允许存在重名的标识符。

  • 如果重名的标识符位于互相独立的作用域内(例如两个不同的函数内),则它们互相独立、互不影响。

  • 如果重名的标识符位于嵌套的作用域内,则在内层作用域内,一旦重名的标识符被声明,则声明于外层作用域的标识符会被临时地覆盖。直到内层作用域终止后,外层的标识符会重新变得可见。

当程序执行进入内层后,外部的重名标识符并不会立刻变得不可见,而是直到内层的标识符被声明时才会被覆盖;当程序执行离开内层后,外部的标识符将重新变得可见。以下面的代码为例:

#include <stdio.h>
int a = 1;
int main(void) {
    printf("a = %d\n", a);
    int a = 2;
    printf("a = %d\n", a);
    if (a < 5) {
        printf("a = %d\n", a);
        int a = 3;
        printf("a = %d\n", a);
    }
    printf("a = %d\n", a);
    return 0;
}

上述代码的输出结果如下:

1
2
2
3
2

形式化地讲,我们将内层作用域记为 [Sin, Tin][S_{in},\ T_{in}] ,将外层作用域记为 [Sout, Tout][S_{out},\ T_{out}] ,区间的起始点均为程序语句,其前后顺序为 Sout, Sin, Tin, ToutS_{out},\ S_{in},\ T_{in},\ T_{out} 。同时,我们假设更外层的作用域中不存在其它同名的标识符。

对于两个重名的标识符 Iin[Sin, Tin]I_{in} \in [S_{in},\ T_{in}]Iout[Sout, Sin]I_{out} \in [S_{out},\ S_{in}] ,有:

  • 在区域 [Iout, Iin)[I_{out},\ I_{in}) 内,通过名称 II 将访问到 IoutI_{out}

  • 在区域 [Iin, Tin][I_{in},\ T_{in}] 内,通过名称 II 将访问到 IinI_{in}

  • 在区域 (Tin, Tout](T_{in},\ T_{out}] 内,通过名称 II 将访问到 IoutI_{out}

函数作用域

函数作用域 (function scope) 是专门针对程序中的 标签 (label) 标识符而设置的,也就是专用于 goto 语法。

与块级作用域和文件作用域不同,函数作用域并非从标签的声明开始可见,而是在标签所处的函数中的任意位置可见。这也符合我们对 goto 语法用法的认知。


生命周期

基本概念

变量的 storage duration ,即变量从被创建(被分配内存)到被销毁(内存被回收)的起止时间,决定了变量的生命周期。

变量的 生命周期 (lifetime) 指的是程序执行过程中的一段时间区间,在此期间内,C 标准保证为其保留存储空间。如果变量在其生命周期之外被访问,则会触发未定义行为。

int * f(void) {
    int x = 1;
    return &x;
}
int main(void) {
    int * p = f();
    int x = *p; // undefined behaviour
    return 0;
}

另外,在变量的生命周期结束后,其原先所处的内存地址可能会被分配给其它新的变量,因此如果仍有指针指向这样的变量,对该指针解引用可能会“合法地”访问到其它变量,使程序行为变得难以预测。

根据变量的生命周期,可以将 C 程序中定义的各种变量分为三大类:

  • 静态变量 (static variables) 通常在程序的初始化阶段分配好内存,并在程序的整个生命周期内持续存在,包括全局变量、静态变量和字符串常量等;

  • 自动变量 (automatic variables) 随着函数调用自动地分配,并随着函数返回自动地被回收,包括非静态局部变量等;

  • 动态变量 (dynamic variables) 亦称为 allocated variables ,由动态内存管理函数手动地分配和回收。

有关动态变量的更多内容,详见 Basics #08: Dynamic Memory Allocation

静态变量术语的二义性

值得注意的是,在 C 语言中,静态变量 (static variables) 这一术语有两个容易混淆的不同含义:

  • 与整个程序具有相同生命周期的变量。

  • 使用 static 声明的变量。

这两个含义并不相同,例如声明为 extern 的一般全局变量仅满足第一个定义,而声明在函数体内的 static 变量仅满足第二个定义。因此读者在遇到静态变量这一术语时需要谨慎地辨认。

生命周期与作用域

生命周期与作用域是两个相似但完全不同的概念。一方面,只有具有内存实体的变量才有生命周期的概念,而各类标识符(包括变量、函数声明、标签等)都有作用域的概念;另一方面,即便仅考虑变量,生命周期和作用域也并不等价。

这里即便暂时不能完全理解也没有问题,随着读者的深入学习,在对 C 程序的内存布局等进阶内容有所了解后,就能很轻松地彻底吃透生命周期的概念了。