找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5114

积分

0

好友

716

主题
发表于 2 小时前 | 查看: 3| 回复: 0

根据 C 语言最新的 ISO 9899:2024 (C23) 标准,我们来系统地梳理程序中 标识符 (identifier)作用域 (scope)链接 (linkage)名字空间 (name space),以及 变量 (object or variable)存储期 (storage duration) 这几个核心概念。

虽然 C 程序最终必须被翻译成机器指令才能执行,但在理解语言本身时,我们有必要想象存在一台能直接“执行”C 程序全部文本(包括各种声明和预处理指令语义)的 抽象机器 (abstract machine)C 语言标准正是从抽象机的角度定义了程序的语义。作为程序员,自觉运用这个视角是正确编程的思想基础。

1. 标识符的作用域

在 C 程序中,标识符 用来标识各种实体 (entity),例如:

  • 变量、函数
  • 结构体/共用体/枚举的标签 (tag) 和成员
  • typedef 定义的类型
  • 语句标号

可以说,标识符就是实体的名字。标识符与实体的关联通过 声明 (declaration) 来确立。变量和函数需要占用内存来存储数值和指令代码,这是它们与其他实体的主要区别。

枚举类型的成员称为 枚举常量 (enumeration constant),它类似于宏定义的宏名,但必须是整型常量表达式。一个枚举类型定义了一个整型枚举常量的集合,枚举常量标识符本质上就是一个整型常量,主要用于提高代码可读性。

同一个标识符在程序的不同位置可以标识不同的实体。对于标识符所标识的每一个实体,该标识符只在程序文本的一个特定区域内是 可见的 (visible)可引用的 (can be used),这个文本区域就称为该标识符的 作用域

同一标识符标识的不同实体,必须具有不同的作用域或属于不同的名字空间,以此来消除引用歧义,否则会导致语义错误。

作用域分为四类:函数作用域 (function scope)文件作用域 (file scope)块作用域 (block scope)函数原型作用域 (function prototype scope)。在讨论作用域时,“函数原型”特指不带函数体的函数声明。

  • 函数作用域:只有 语句标号名 (label name) 具有函数作用域。一个标号可以在其所属函数的任何地方被 goto 语句引用。标号通过“标号名后接冒号和一条语句”这种独特的语法格式来隐式声明。
  • 文件作用域:如果一个标识符(准确地说是其声明子元或类型指定子元)出现在所有块结构(用 { } 包裹的代码块)和参数列表之外,那么它就具有文件作用域。其作用域始于声明点,结束于其所在的 翻译单元(可以简单理解为预处理后的源文件)末尾。
  • 块作用域:如果一个标识符出现在任何一个块结构或函数定义的参数列表之内,那么它就具有块作用域。其作用域始于声明点,结束于其直属块的末尾。
  • 函数原型作用域:如果一个标识符位于函数原型(非函数定义的一部分)的参数列表之内,那么它就具有函数原型作用域。其作用域始于声明点,结束于函数声明子元之尾,即声明末尾的分号之前。

如果同一标识符标识同一名字空间的两个不同实体,它们的作用域可以 交叠 (overlap)(例如文件作用域包含块作用域)。但如果交叠,内层作用域必须严格结束于外层作用域之前。这样,在内层作用域中,该标识符引用的是在内层声明的实体,而外层声明的实体则被暂时 隐藏 (hidden)

两个标识符的作用域当且仅当在同一点结束时,才被视为具有相同的作用域。

2. 标识符的链接

在不同的作用域或同一作用域内多次声明的同一标识符,可以通过一个链接过程来引用同一个变量或函数。链接的本质就是地址解析和确定的过程。不同标识符之间不存在链接

链接有三种:外链 (external linkage)内链 (internal linkage)无链 (no linkage)。一个标识符声明只能附带一种链接。

  • 外链:在构成整个程序的所有翻译单元和函数库范围内,每次声明该标识符都会引用同一个变量或函数(即全局唯一)。
  • 内链:在一个翻译单元范围内,每次声明该标识符都会引用同一个变量或函数(即文件内唯一)。
  • 无链:每次声明该标识符都会引用一个独一无二的实体(不限于变量或函数)。

链接的确定规则:

  1. 在具有文件作用域的变量或函数标识符的声明中,如果包含关键字 static,则该标识符具有内链。
  2. 对于用关键字 extern 声明的标识符,如果在其作用域内存在一个可见的(未被隐藏的)先前声明,则其链接属性与先前声明相同(若先前声明为内链或外链)。如果不存在可见的先前声明,或先前声明指定为无链,则该标识符具有外链。
  3. 如果函数标识符的声明不含任何存储类指定子元(如 staticextern),则其链接属性的认定方式与声明包含 extern 时相同。
  4. 如果变量标识符的声明具有文件作用域且不含关键字 static,则该标识符具有外链。

下列标识符无链接:

  • 既不是变量也不是函数的标识符(如标签、标号)。
  • 函数参数标识符。
  • 具有块作用域且其声明不含关键字 extern 的变量标识符。

所有库函数的标识符均具有外链。

重要提示: 在一个翻译单元内,如果同一个标识符同时以内链和外链的形式出现,其行为是未定义的 (undefined),编程时必须避免这种情况。

3. 标识符的名字空间

如果在翻译单元内的某一点,同一个标识符有多个针对不同实体的声明同时可见,那么这些引用会由上下文语法结构特征自动区分开来。这就自然地形成了不同的名字空间:

  1. 语句标号名:由其“后接冒号和语句”的语法特征区分,构成一个独立的名字空间。
  2. 结构体、共用体和枚举的标签:分别由关键字 structunionenum 区分出来,它们共同形成一个名字空间。
  3. 结构体或共用体的成员:每一个结构体或共用体的全体成员构成一个独立的名字空间,由包含操作符 .-> 的成员访问表达式的独特语法区分。
  4. 普通标识符:除上述以外的所有其他标识符(包括枚举常量标识符)归为一类,构成一个名字空间。

标识符的名字空间是消除引用歧义的另一个重要机制。

4. 变量的存储期

存储期决定了变量的 生命期 (lifetime)。这里我们主要讨论最基础的两种:静态存储期 (static storage duration)自动存储期 (automatic storage duration)

变量的生命期是程序运行过程中的一个时间段,在此期间确保变量拥有内存空间。在生命期内,变量存在、拥有恒定的内存地址、并持有最近一次存储的值。在变量生命期之外引用它是未定义行为。

存储期的确定规则:

  • 如果一个变量标识符的声明具有外链或内链,或者其声明包含关键字 static,则该变量具有静态存储期(可称为静态变量)。静态变量的生命期为整个程序运行全程,且在程序开始执行前只初始化一次。
  • 如果一个变量标识符被声明为无链,且其声明不包含关键字 static,则该变量具有自动存储期(可称为自动变量)。
    • 对于固定长度数组或非数组型的自动变量,其生命期从进入其直属块开始,到该块以任何方式执行结束为止。如果因递归进入同一块,每次都会生成新的变量实例。自动变量的初值是不确定的 (indeterminate)。如果指定了初始化,则每当执行流到达其声明时才会进行初始化(有可能被 goto 跳过)。
    • 对于可变长数组型的自动变量,其生命期从变量声明点开始,到程序执行离开所声明的作用域为止。每次递归进入都会生成新的实例,且其初值总是不确定的。

5. 外部定义

标识符的 定义 (definition) 是一种特殊的 声明 (declaration)

  • 变量定义:是导致变量内存得到分配的变量标识符声明。
  • 函数定义:是包含函数体 { ... } 的函数标识符声明。

因此,定义一定是声明,但声明不一定是定义。编译器根据函数定义生成指令代码,根据变量定义分配内存。

一个翻译单元是由一组 外部声明 (external declaration) 的序列构成的。“外部”是指这些声明出现在所有函数之外(因此具有文件作用域)。外部定义 (external definition) 就是充当函数或变量定义的外部声明。

定义的唯一性规则:

  • 一个内链标识符在一个翻译单元内只能有一个外部定义。
  • 一个外链标识符在整个程序范围内只能有一个外部定义。
  • 一个无链变量标识符的每一次声明都会引用一个独一无二的变量,因此该声明必定是定义。

外部变量定义的特殊情况:

  • 如果一个具有文件作用域的变量声明带有初始值 (initializer),那么它是一个正式的外部定义。
  • 如果一个具有文件作用域的变量声明未带初始值,也未指定 extern 存储类,那么它只是一个 临时定义 (tentative definition)
  • 如果一个翻译单元包含一个标识符的一个或多个临时定义,但没有其正式的外部定义,那么这些临时定义将共同被视为一个初始值为“空”的正式外部定义(数值变量初始化为0,指针变量初始化为 NULL)。

6. 综合程序示例

下面这个由多个文件组成的程序,将帮助我们具体理解上述所有概念。

文件:stdio.h (片段)

int printf(const char *format, ...); // 库函数原型声明

文件:header.h

extern int i1;
void f1(int p);
int f3(int p);

文件:source1.c

#include <stdio.h>
#include "header.h"

int main(void) {
    int i;
    struct i1 {
        int i;
        int i1;
    };
    typedef struct i1 Struct1;
    Struct1 i2 = { 8, 9 };
    enum i { X = 10, Y, Z };
    enum i y = Y;

    i = 5;
    {
        int i = 6;
        int i1 = 7;

        f1(i + i1);
        goto i1;
        i = i1;
    }
i1:
    printf("%d\n", i + i1 + i2.i + y);
    return 0;
}

文件:source2.c

#include "header.h"

int i1 = 1;
static int i2 = 2;

static int f2(int p) {
    int i1 = 3;
    return p + i1 + i2;
}

void f1(int p) {
    int i2 = 4;
    i1 += p + i2 + f2(i2) + f3(p);
}

文件:source3.c

#include "header.h"

int f3(int p) {
    return p + i1++;
}

程序执行流程与概念分析:

  1. 预备与链接:在程序开始执行前,静态变量被初始化。source2.c 中的 int i1 = 1;i1 的唯一定义(外链,静态存储期)。source1.csource3.c 中的 extern int i1; 是对此外部变量的声明。f1f3 的定义与声明同理。static int i2 = 2; 是内链变量,仅在 source2.c 内可见。static int f2(...) 是内链函数,也仅在 source2.c 内可调用。

  2. 作用域与隐藏

    • main 函数中,int i;(外层)和子块内的 int i = 6;(内层)都是普通标识符。内层 i 的作用域严格嵌套在外层 i 的作用域内,因此在子块中,外层 i 被隐藏。
    • 外部变量 i1(来自 header.h)和子块内的 int i1 = 7; 同理,在子块内,全局的 i1 被隐藏。
    • f1 函数中,局部变量 int i2 = 4; 隐藏了同文件内的静态变量 static int i2 = 2;
    • f2 函数中,局部变量 int i1 = 3; 隐藏了外部变量 i1
  3. 名字空间

    • main 函数中的 struct i1 { ... };(结构体标签)、int i1 = 7;(普通标识符)、i1:(语句标号)以及结构体成员 int i1; 分属四个不同的名字空间,因此即使同名也不会冲突。
    • 同样,int i;(变量)、int i;(结构体成员)、enum i { ... };(枚举标签)也分属不同名字空间。
  4. 程序执行与结果

    • main 函数调用 f1(13)
    • f1 执行 i1 += 13 + 4 + f2(4) + f3(13);
    • f2(4) 返回 4 + 3 + 2 = 9(注意:f2 内的 i1 是局部变量 3,i2 是静态变量 2)。
    • f3(13) 返回 13 + i1++。此时 i1 是全局变量,初值为1,所以返回 14,并随后将 i1 递增为2。
    • f1 中的计算变为:i1 = 2 + (13 + 4 + 9 + 14) = 42
    • 控制流通过 goto 跳转到 i1 标号处,继续执行 main 函数。
    • 此时打印 i + i1 + i2.i + y。这里的 i 是外层的 5i1 是全局变量 42i2.i 是结构体成员 8y 是枚举变量 11。总和为 5 + 42 + 8 + 11 = 66

运行程序,在命令行中得到如下输出:

程序运行结果命令行输出

7. 常见用法总结

掌握以下习惯用法,足以应对大多数编程场景:

// 以下各行示例语义彼此无关
int i1 = 0;          // 外部定义,所有翻译单元共享,外链,静态变量,全程生命期
static int i2 = 0;   // 外部定义,单个翻译单元共享,内链,静态变量,全程生命期
extern int i3;       // 外部声明(非定义),链接取决于已有声明,变量实体不在本文件

int f1(int p);       // 函数原型声明,外链

int f2(int p)        // 函数定义,外链
{
    static int i4 = 0; // 局部静态变量,本函数独享,无链接,静态存储期,全程生命期
    int i5 = 0;        // 局部自动变量,初值确定,无链接,自动存储期,块生命期
    int i6;            // 局部自动变量,初值不确定,无链接,自动存储期,块生命期
    // ......
}
static int f3(int p); // 函数原型声明,内链
static int f4(int p)  // 函数定义,内链
{
    // ......
}

8. 核心要点与最佳实践

  1. 声明唯一:任何实体应尽量只有一份声明,避免不必要的重复和潜在的混乱。
  2. 慎用同名:尽量不使用同名标识符,以免造成引用歧义。
  3. 解决冲突:如果必须使用同名标识符,应确保它们属于不同名字空间,或作用域无交叠,或作用域严格嵌套(外层被内层隐藏),以实现无歧义引用。
  4. 显式初始化:为外部变量定义显式指定初值,即使是 0NULL,尽量避免依赖临时定义。
  5. 合理选择链接
    • 变量:需要整个程序共享用外链;仅在单个源文件内共享用内链;仅在单个函数内使用用无链+static(静态局部变量)。
    • 函数:需要整个程序共享用外链;仅在单个源文件内使用用内链
  6. 理解递归:函数递归调用时,每次都会生成新的自动变量(包括形参)实例。
  7. 关注声明属性:对于每个声明,要清楚其标识符、实体、作用域、链接、名字空间。如果是变量,还要清楚其存储期和生命期。
  8. 区分初值:静态变量的初值是确定的,且在程序执行前只初始化一次;自动变量的初值是不确定的,除非显式初始化。
  9. 区分声明与定义:对于变量和函数,要清晰区分声明和定义。
  10. 理解声明本质:声明的语义是为标识符指定解释(标识何种实体)和属性(存储类、类型、初值等)。

透彻理解作用域、链接、名字空间和存储期,是编写正确、清晰、可维护的C程序,尤其是多文件项目的基石。希望本文的梳理和示例能帮助你更好地掌握这些核心概念。对于更深入的编译和链接过程细节,可以继续在云栈社区探索相关专题。




上一篇:AI基础设施安全预警:企业部署中权限滥用导致安全事件激增
下一篇:内存与磁盘加载机制详解:虚拟内存、磁盘缓存与程序瘦身实践
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-10 05:02 , Processed in 1.261924 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表