C语言篇:语法进阶

371 阅读18分钟

本文介绍一些C语言中高阶的语法知识,比如声明定义作用域生存期名字空间等,这些内容通常难以理解,但是实际开发中又经常用到。大部分教材或其他相关书籍都对这部分内容避而不谈或浅尝辄止,研究多日后,我发现想要把这部分内容讲得既清晰又全面确实是非常困难的。

一如往常,我还是以标准文档为参考尽量详细和严谨地呈现知识。但是标准文档中和这部分相关的内容简直是一团糟,非常晦涩和杂乱,我完全无法保证读者在看完这篇文章之后能消化甚至记住。我尽量用更加便于理解的说法和顺序对标准文档进行总结,文章结尾再谈谈我的一些看法。

声明和定义

在开始之前,需要先弄清楚一些概念:

  • 标识符:标识符就是程序员在代码中自己创建的名字,用来表示某个东西。这个东西可以是类型、函数或者对象等,统称为实体
  • 对象:C语言中的对象指的是一块可以存储值的内存空间。我们俗称的变量实际上指的就是对象,变量名就是跟这个对象关联的标识符。对象的创建(即分配内存)往往是在程序运行时,但编译的时候会计算这个对象需要的内存空间,且称之为预分配内存。

声明和定义都是针对“标识符”的,简单说,就是创造一个名字,并赋予这个名字意义。

C语言的声明和定义是分开的,是两码事。C语言是一种低级的高级语言,很多语言特性都是为了方便编译。声明告诉编译器有这么一个标识符,并且说明这个标识符的一些外部属性,这个时候就可以进行指针等操作了;定义确定了这个标识符的所有属性,并伴随一些其他操作,比如预分配内存。大部分人都分不清楚声明和定义,因为它们经常又是结合在一起的。它们的关系可以用一句话概括:

一些声明伴随着定义。

可以这么理解,一方面,定义的范围包含在声明的范围内,所有的定义都是一种声明,因此我们把函数定义挪到前面就不需要额外声明了。另一方面,存在一些“纯粹”的声明,它们不包含定义,比如函数原型。

对于函数和结构体来说,带定义的声明和纯粹的声明之间的区别是显而易见的。对于变量来说,情况就比较复杂了。变量定义会预创建一个对象,并与声明的标识符关联,经常还可以初始化;变量声明仅仅引入一个标识符,不涉及内存操作,也不能初始化。首先,对于所有块内声明的不带extern限定符的变量,即俗称的局部变量,声明就是定义。块内声明的带有extern限定符的变量,就只是纯粹的声明。在所有函数之外的文件范围声明的变量,即俗称的全局变量,如果带有初始化,则是定义,否则是纯粹的声明。然而,如果某个标识符在文件范围内只有声明没有定义,且该标识符存在不带extern限定符的声明,则在文件末尾为其隐式定义。

下面用一些例子来加深理解,该代码块表示一个完整的源文件。

int a;                  // 纯粹声明
extern int a = 5;       // 定义
static int b;           // 纯粹声明
static int b = 5;       // 定义
extern int c;           // 纯粹声明
int c;                  // 纯粹声明
static int d;           // 纯粹声明
extern int e;           // 纯粹声明

struct S;               // 纯粹声明
struct S {              // 定义
    int a;
};

extern void fn();       // 纯粹声明

void fn(void) {         // 定义
    extern int a;       // 纯粹声明
    extern int a = 5;   // 纯粹声明,不能初始化,编译报错
    static int b;       // 定义
    int c;              // 定义
    void fn();          // 纯粹声明
}

// 隐式定义:int c;
// 隐式定义:static int d;

其他类型的标识符在声明和定义上也有说法,不过基本不需要关心,看看就好。typedef同一个标识符,第一个是定义,之后的是纯粹声明。枚举常量的声明和定义总是一致的。

作用域

作用域是针对“标识符”的,表明一个标识符可用的范围。

标识符的作用域从它声明的位置开始,根据其结束的位置分为4种作用域:

  1. 文件作用域:到文件结束为止
  2. 块作用域:到块结束为止
  3. 函数作用域:到函数定义结束为止
  4. 函数原型作用域:到函数原型的参数列表结束为止

尽管因为声明顺序,没有两个标识符的作用域完全相同,但为了方便表述,把结束位置相同的作用域称为相同作用域。

在所有块以及参数列表外声明的标识符具有文件作用域,从它声明的位置开始,在整个文件范围都可用。这是最大的作用域,其他所有作用域都包含在其中。函数定义中的函数名具有文件作用域。

在块内声明的标识符具有块作用域,离开当前块后就不再可用(但是在子块内依然可用)。特别地,函数定义中的参数具有块作用域,这个块在文件作用域内部,在函数的代码块外部。同样的,在for语句括号内声明的标识符的作用域在外部块之内,但在内部块之外。需要注意,枚举常量的作用域并不在单独的子块内,而是与该声明所在的作用域相同。

唯一具有函数作用域的标识符是标签语句的标签(但不包括switch语句的case标签,其具有块作用域),所以goto语句可以在整个函数范围内跳转,而不局限于块。而且标签的作用域是整个函数块,而不是从申明位置开始,从而可以实现向下跳转。

唯一具有函数原型作用域的标识符是函数原型里的参数名,它唯一的作用是作为变长数组的长度。

C语言的结构就像是盒子模型,层层嵌套,最外面是文件范围,有声明、函数定义等,函数定义包含代码块,块内还能嵌套块,我们可以很直观地区分“盒子”的内部和外部。当在内层作用域声明了一个标识符时,会屏蔽外层作用域的同名标识符,这在一定程度上避免了命名冲突,但实际上带来了更多的编程问题。

名字空间

名字空间是一种避免命名冲突的方法。即使在相同作用域内,不同名字空间内声明的标识符表示不同的实体,互不干扰。C语言有4种名字空间,并且不允许程序员自定义名字空间。这4种名字空间分别是:

  1. 标签语句的标签
  2. 枚举类型、结构体、联合体的名字(注意这里是枚举类型的名字,而不是枚举常量)
  3. 结构体和联合体的成员。每个结构体或联合体内都是单独的名字空间
  4. 其他一切标识符都属于同一个名字空间

C23引入属性,新增了2种名字空间,暂不做讨论。

下面的代码展示了在不同名字空间声明相同标识符的例子,这段代码完全可以通过编译且没有歧义。

struct test {
    int test;
};
struct test test;

int main(void) {
test:
    test.test = 5;

    return 0;
}

C语言的名字空间实际上没有解决任何问题,反而让代码更加混乱。C++的名字空间才真正起到了避免命名冲突的作用,但using namespace会让其失去作用。

链接类型

标识符是用来表示实体的,它表示哪个实体,取决于链接类型。一共有3种链接类型:

  1. 外部链接。整个程序的所有源文件中具有外部链接的同名标识符都表示同一个实体。
  2. 内部链接。单个源文件内具有内部链接的同名标识符都表示同一个实体。
  3. 无链接。相同作用域且相同名字空间内无链接的同名标识符表示同一个实体。

接下来详细说明一个标识符具有哪种链接类型。

  • 具有文件作用域且带有static限定符的标识符具有内部链接
  • 具有文件作用域且不带staticextern限定符的表示对象的标识符具有外部链接
  • 如果一个标识符带有extern限定符,那么首先判断在当前作用域(包括外层作用域)和名字空间内是否已经有同名标识符。如果有且其具有外部链接或内部链接,则二者的链接类型相同;如果没有或者有但其无链接,则该标识符具有外部链接
  • 如果函数声明不带staticextern限定符,等同于带extern限定符。且块内的函数声明不能带static限定符
  • 其他情况都为无链接

分析上面的规则可以发现,函数标识符只能具有外部链接或内部链接,对象标识符三种链接类型都有可能。由于staticextern限定符只能修饰函数或对象声明,所以只有函数或对象的标识符才有可能具有外部链接或内部链接,其他标识符都是无链接的。这意味着无法从其他源文件中引入结构体或联合体的声明,正因如此,通常只保存声明的头文件中包含大量的类型定义。这可以从编译的角度解释,编译是以单个源文件为单位的,对于对象和函数,声明已经包含了大量的信息,足以完成编译中的语法检查等工作;但结构体等必须知道确切的类型定义才能进行语法检查。无链接也意味着可以在子作用域中重新定义类型,从而屏蔽外部作用域的类型定义。

表示同一个实体的标识符可以以相容的方式声明多次,但只可以定义一次,确切来说,定义就是为标识符创造实体。但是,相同作用域且相同名字空间内不允许同名标识符表示不同的实体。一般而言,“相容”要求完全一样,但某些修饰符可以不同,因为存在默认行为。此外,函数原型的参数名可以不同,函数原型和不带参数列表的函数声明也相容(C23不然,参数列表为空默认void)。

我相信大部分人在看完上面几段话后还是一头雾水,这不是我们的问题,而是语言本身的问题。为了便于理解,用实际代码来演示一下,下面每个代码块都表示一个完整的源文件,且不存在代码块之外的源文件,对于表示同一实体的标识符,我会在注释里标记相同的数字。

// 源文件1

extern int a;               // 1,外部链接,找不到同名标识符
int a;                      // 1,外部链接
static int b;               // 2,内部链接
extern int b;               // 2,内部链接,与第5行相同
extern int c;               // 3,外部链接,找不到同名标识符
extern int d(void);         // 4,外部链接,找不到同名标识符
struct e { int a; };        // 5,无链接
struct f { double a; };     // 6,无链接

int main(void) {            // 7,外部链接,找不到同名标识符
    static int a;           // 8,无链接
    extern int b;           // 2,内部链接,与第6行相同
    int c;                  // 9,无链接
    int d();                // 4,外部链接,与第8行相同
    {
        extern int a;       // 1,外部链接,因为第13行无链接
    }
    return 0;
}

// 1,隐式定义:int a;
// 2,隐式定义:static int b;
// 源文件2

extern int a;               // 1,外部链接,找不到同名标识符
static int b;               // 10, 内部链接
int c;                      // 3,外部链接
int d(void) { return 5; }   // 4,外部链接,找不到同名标识符
struct e { int a; };        // 11,无链接
struct f { char a; };       // 12,无链接
static void g(void) { }     // 13,内部链接

// 10,隐式定义:static int b;
// 3,隐式定义:int c;

扶着学不会走路,我们再看几个错误案例,加深理解。

案例1:

extern int a;           // 1,外部链接
static int a;           // 2,内部链接,同一标识符表示不同实体,编译错误
int b;                  // 3,外部链接

int main(void) {        // 4,外部链接
    int b;              // 5,无链接
    extern int b;       // 3,外部链接,同一标识符表示不同实体,编译错误
    return 0;
}

// 2,隐式定义:static int a;
// 3,隐式定义:int b;

案例2:

// 源文件1

extern int a;           // 1,外部链接
extern int b = 0;       // 2,外部链接,通过初始化定义
int c;                  // 3,外部链接
int d();                // 4,外部链接

int main(void) {        // 5,外部链接
    a = 1;              // 1,未定义,链接错误
    b = 1;              // 2
    d();                // 4,未定义,链接错误
}

// 3,隐式定义:int c;
// 源文件2

extern int a;                       // 1,外部链接
static int b;                       // 6,内部链接
int c;                              // 3,外部链接
static int d(void) { return 5; }    // 7,内部链接

// 6,隐式定义:static int b;
// 3,隐式定义:int c; 重复定义,链接错误

生存期

生存期是针对“对象”的,表明一个对象从创建到销毁的持续时间。上面已经提到,对象是一块内存的抽象,但是对象并不与内存地址绑定。当一块内存被分配,就创建了一个对象,在这块内存被回收前,无论它被用来存储什么值,都是同一个对象。内存回收意味着对象被销毁,如果这块内存被重用,即使拥有相同的内存地址,也是不同的对象。

对象的生存期主要分为“静态生存期”和“自动生存期”。“分配生存期”非常简单,就是跟mallocfree这些内存管理函数相关;“线程生存期”不在本篇的讨论范围内。

如果对象的标识符具有外部链接或者内部链接,或者无链接但定义时带有static限定符,则该对象具有静态生存期。具有静态生存期的对象在程序开始运行时创建,直到程序结束才销毁,该对象默认以{0}初始化,一般表现为用0填充内存空间。具有静态生存期的对象只会初始化一次,并且发生在main函数执行之前。

如果对象的标识符无链接且定义时不带static限定符,则该对象具有自动生存期。具有自动生存期的对象的标识符总是在块内定义的,该对象在程序执行进入该块时创建,程序执行退出该块时销毁,进入子块或者执行函数调用不算退出该块。变长数组有点特殊,它具有自动生存期,但不是在程序执行进入该块时创建,而是在程序执行到声明位置时创建,同样在程序执行退出该块时销毁。具有自动生存期的对象不会默认初始化,其初始值是未定义的,一般是该块内存的原始值。

生存期比起前面几节内容还是比较浅显易懂的,来段代码感受一下。

int a = 5;                          // 静态生存期
static int b = 3;                   // 静态生存期
void c(void) {}                     // 不是对象,没有生存期的概念

int main(int argc, char *argv[]) {  // argc, argv自动生存期
    static int a;                   // 静态生存期
    int b;                          // 自动生存期
    return 0;
}

除此之外,还有一个不太常见的“临时生存期”。如果一个结构体或联合体有数组成员,那么这个类型的右值表达式对应一个拥有“临时生存期”的对象。这个对象在程序对这个表达式求值时创建,在对该表达式所在的完整表达式求值结束时销毁。直接上例子:

#include <stdio.h>

struct S {
    int a[3];
};

struct S f1(void) {
    struct S s = { .a = { 1 } };
    return s;
}

int f2(int *a) {
  return a[0];
}

int main(void) {
  printf("%d\n", f2(f1().a));
  return 0;
}

struct S是一个有数组成员的结构,我们通过函数f1来返回一个该类型的右值,这就创建了有临时生存期的对象,对应的表达式为f1()。接着把成员a作为参数传入f2,此时对象的生存期还没有结束,在f2内部访问对象成员是合法的。最终整个printf都执行结束后,完整表达式求值结束,对象被销毁。修改有临时生存期的对象是未定义的。

编译和链接

代码写完之后,编译和链接是生成可执行文件最后的步骤。编译器通常带有链接的功能,所以人们总用“编译”来指代编译和链接,但这实际上是两个完全不同的步骤。

编译是以单个源文件为单位进行的。注意,头文件不是编译单元,它被#include预处理指令导入到源文件中,仅仅是源文件的扩展。编译会进行语法检查,提示一些拼写和语法错误,但它不会关心外部链接的标识符有没有定义。编译会把语句翻译成二进制机器指令、建立符号表,每个源文件生成一个二进制文件,称为目标文件。

编译得到的二进制文件并不能执行,链接以所有目标文件作为输入,生成一个真正可执行的二进制文件。链接拼接及修改目标文件,根据符号表寻找外部链接的标识符的定义,确定相对地址,生成对应操作系统格式的可执行文件。

我的一些想法

随着对C语言的了解不断加深,我对这门语言的热爱也被一点点消磨殆尽。如果让我用一个字评价C语言的现状,那就是“”。同样是extern,既可以表示外部链接也可以表示内部链接,对函数来说既可以加也可以不加;同样是static,既用来表示内部链接,还用来表示静态生存期。同一个标识符在不同位置可以表示不同的实体,同一个特殊字符在不同语境下可以作为不同的运算符。C语言标准用繁杂的术语和概念试图把一切解释清楚,看起来什么都说了,但满篇都是“未定义”,在这片“未定义”的广袤空间内各编译器自由发挥。即使是标准里明确规定的内容,不同编译器的支持程度也相差甚远,MSVC更是臭名昭著。标准本身也并不像我们以为的那样严谨,从C11到C18的7年里,没有任何功能更新,只是不断地勘误和补充,甚至把一大部分内容作为可选支持好为编译器贴牌。C23虽然做了大量的改动,但好坏参半,改动内容基本都是从C++移植过来的,好像更新C语言标准只是为了与新的C++更加兼容,跟C++比起来C语言就是二等公民。

本文涉及的内容过于复杂,如果我们编程时始终琢磨着这些东西无疑是自讨苦吃,我有一些建议或许可以减轻负担:

  • 忘记C语言存在名字空间,永远不要使用相同的标识符表示不同的实体
  • 忘记内层作用域可以屏蔽外层作用域,永远不要在嵌套的作用域内声明相同的标识符
  • 尽量避免在文件作用域定义标识符,如果确实需要,使用static限定符给予内部链接,避免命名冲突
  • 如果需要定义外部链接的标识符,不要加任何externstatic限定符
  • 尽量避免前置声明
  • 引入其他源文件定义的标识符时,使用extern限定符进行声明
  • 不要使用隐晦的语言特性,比如extern对应的链接类型