预处理命令

罗大富 BigRich大约 9 分钟C/C++

现在,我们已经把 C 语言中最基础的内容讲完了,我们回过头来看看 C 语言文件中最前面的代码 - 预处理命令,在 C 与 C++ 中,凡是以 # 号开头的均为 预处理命令

严格来说预编译指令并不是 C 和 C++ 语言的组成部分,不能直接对他们进行编译。必须在对写好的代码进行编译之前,先对程序中这些特殊的命令进行 预处理。经过预处理之后,得到的就是可供计算机直接执行的目标代码。

预编译指令的结尾都没有分号,例如:

#ifndef _STDBOOL_H
#define _STDBOOL_H

#ifndef __cplusplus

#if defined __STDC_VERSION__ && __STDC_VERSION__ > 201710L
/* bool, true and false are keywords.  */
#else
#define bool	_Bool
#define true	1
#define false	0
#endif

#else /* __cplusplus */

/* Supporting _Bool in C++ is a GCC extension.  */
#define _Bool	bool

#endif /* __cplusplus */

/* Signal that all the definitions are present.  */
#define __bool_true_false_are_defined	1

#endif	/* stdbool.h */

以上代码是 <stdbool.h> 头文件的源代码,它就是完全使用预处理命令写的,一般预处理命令包括以下 3 个功能:

  1. 文件包含 - #include
  2. 宏定义 - #define
  3. 条件编译 - #ifdef#ifndef

#define - 宏定义

#define 叫做宏定义,作用是在预处理阶段进行文本替换,语法格式为:

#define 名字  值

#define、名字与值之间需要用空格隔开,其中最后的 可以是数字、表达式、代码语句等。为了防止与字符串变量混淆,习惯上,我们通常把宏名全部大写

例如,圆周率为 3.14159,凡是代码中需要用到圆周率的地方,都可以使用宏定义来代替,代码如下:

#define PI  3.14159

再比如,我们在嵌入式开发 UI 界面的时候,会需要用到各种颜色,颜色一般会使用 6 位十六进制数表示,肉眼观察的话很难理解,为了避免出现这样的数字,我们就可以使用宏定义来代替:

#define WHITE   0xFFFFFF
#define BLACK   0x000000
#define PINK    0xFFC0CB
#define BLUE    0x0000FF
#define CYAN    0x00FFFF
#define GREEN   0x008000
#define GOLD    0xFFD700

宏定义对所定义的类型没有限制,也可以替换数据类型,代码语句等等:

#include <stdio.h>

#define D       double       // 宏定义 double 数据类型

#define HELLO   printf("Hello, world!");

int main() {
    D num = 11.11;
    printf("num: %.2f\n", num);

    HELLO
    return 0;
}

#include - 文件包含

#include 叫做文件包含命令,用来引入对应的头文件(.h 文件)。#include 的处理过程很简单,就是将头文件的内容直接插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件,这与复制粘贴的效果相同。换句话说,#include 其实就是一种纯文本的替换操作。

在学习文件包含命令之前,我们还需要了解一下 源文件头文件 的概念:

  • 源文件(Source File) 是源代码所在文件,其中定义了各种函数、变量和数据结构等,后缀名为 .c

  • 头文件(Header File) 是包含 C 语言函数原型、宏定义、变量、结构体等声明的文件,通常以 .h 为扩展名。头文件用于在源文件中引用和共享函数和变量的声明,以便在编译时能够正确地识别和使用这些函数和变量。头文件中通常不包含实际的函数实现,而只包含函数的声明和必要的宏定义。

二者的根本区别在于:

  • 源文件 包含了实际的代码实现,而 头文件 只包含了声明和定义;
  • 源文件用于编译和链接生成可执行文件,而 头文件 用于在编译时进行函数和变量的声明和共享。

源文件和头文件之间的关系是通过预处理器指令 #include 来建立的。在源文件中使用 #include 指令引用头文件,编译器在编译源文件时会将头文件的内容插入到 #include 指令所在的位置,使得源文件中的函数和变量能够正确地识别和使用。

#include 的用法有两种,如下所示:

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

使用尖括号 <> 和双引号 "" 的区别在于头文件的搜索路径不同:

  • 使用尖括号 <>,编译器会到系统路径下查找头文件,而不是在当前目录中查找;
  • 而使用双引号 "",编译器首先在当前目录下查找头文件,如果没有找到,再到系统路径下查找。

简单来说就是,系统定义的头文件使用尖括号,用户定义的头文件使用双引号。

前面我们一直使用尖括号来引入标准头文件,现在我们也可以使用双引号了,如下所示:

#include "stdio.h"
#include "string.h"

stdio.hstring.h 都是标准头文件,它们存放于系统路径下,所以使用尖括号和双引号都能够成功引入;而我们自己编写的头文件,一般存放于当前项目的路径下,所以不能使用尖括号,只能使用双引号。

当然,你也可以把当前项目所在的目录添加到系统路径,这样就可以使用尖括号了,但是一般没人这么做,纯粹多此一举,费力不讨好。

关于 #include 用法的注意事项:

  • 文件包含允许嵌套,也就是说在一个被包含的文件中又可以包含另一个文件。
  • 同一个头文件可以被多次引入,多次引入的效果和一次引入的效果相同,因为头文件在代码层面有防止重复引入的机制。

什么情况下,我们会需要用到自己编写头文件呢?

一个大型的项目可以分解成多个文件进行编写、调试和管理,每个文件对应于程序中的一个模块或功能,不同的文件之间可以相互调用和使用,最终组合在一起形成完整的程序,这样的编程方式也被称为 多文件编程

多文件编程 的好处在于:

  1. 结构清晰:将程序分割成多个文件可以使代码结构更加清晰。不同的功能模块可以放在不同的文件中,使得代码逻辑更加明确,易于理解和维护。
  2. 代码复用:多文件编程可以促进代码的复用。将可复用的代码块抽象成一个独立的文件,其他文件可以引用这个文件中的代码,避免了重复编写相同的代码。
  3. 编译效率提高:当程序变得越来越庞大时,使用多文件编程可以提高编译的效率。因为每个文件的修改只会导致被修改的文件重新编译,而不需要重新编译整个程序。
  4. 团队合作:在大型项目中,多文件编程使得团队成员可以同时工作在不同的文件中,而不会相互影响。这样可以提高团队的开发效率,并且方便代码的合并和管理。
  5. 便于调试:将程序分割成多个文件,可以更方便地进行模块化调试。当出现问题时,只需要关注与问题相关的文件,而不需要对整个程序进行调试。
  6. 在多文件编程中,通常有一个主文件(也称为主函数所在的文件),它作为整个程序的入口点。主文件调用其他文件中的函数或者包含其他文件中的代码,以实现程序的各个功能。

在多文件编程中,一个程序通常包含多个源文件(.c文件)和一个头文件(.h文件)。头文件中通常包含函数声明、宏定义和结构体定义等信息。

常见的文件组织结构如下:

├── main.c              // 程序入口文件
├── module_1.c          // 模块1源文件
├── module_1.h          // 模块1头文件
├── module_2.c          // 模块2源文件
├── module_2.h          // 模块2头文件
├── test.c              // test 源文件
└── test.h              // test 头文件

头文件的主要作用是包含全局变量、函数声明、宏定义和结构体定义等信息。

例如,test.h 可能包含以下内容:

#include "stdio.h"

void hello();

头文件的作用在于提供了对应模块的接口,其他文件可以通过包含该头文件,以访问其中的函数和变量。源文件分别对应不同的功能模块,通常实现了头文件中声明的函数。

例如,test.c 源文件可能包含如下内容:

#include "test.h"


void hello() {
    printf("hello, world!");
}

main.c 文件是程序的入口,负责调用其他模块的函数,协调各个模块的功能,代码如下:

#include "test.h"

int main() {
    // 调用 test.h 的函数
    hello();
    return 0;
}

条件编译

预处理的最后一个功能就是条件编译,在一般情况下,源文件的所有代码都会参加编译,以生成目标代码。但在某些特殊情况下,我们可能只想对部分满足条件的代码语句进行编译,这就需要用到 条件编译

在编译预处理阶段,会把 test.h 文件的内容,插入到 include 的位置,如果重复加载头文件,那么代码就会重复插入。例如:

// main.c 文件
#include "test.h"
#include "test.h"

int main() {
    // 调用 test.h 的函数
    hello();
    return 0;
}

虽然没有报错,但是,如果我们在头文件中定义了一个结构体、枚举类型,那么就会报错。代码如下:

// test.h 文件
#include "stdio.h"

typedef struct student_info {
    char name[10];      // 名字
    int age;            // 年龄
    double height;      // 身高
    char gender;        // 性别
} student;

enum weekday
{
    MON = 1, TUE, WED, THU, FRI, SAT, SUN
};

void hello();

报错如下:

重复定义报错提示
重复定义报错提示

为什么我们再重复调用 stdio.h 时没有出现任何报错呢?这是因为 stdio.h 中使用了条件编译。

条件编译的关键字与 if 语句类似:

  • #if
  • #elif
  • #else
  • #endif
  • #ifdef
  • #ifndef

#ifndef#endif 经常用于头文件的包含保护,以防止同一个头文件被多次包含。#ifndefif not defined 的缩写,也就是 是否未定义 的意思。它们会根据 某个宏是否被定义 来控制代码的编译过程。而 #endif 就是结束 结束条件编译块,不可以省略,并需要 #if、#ifdef、#ifndef 搭配使用

以下是使用条件编译后 test.h 头文件中的代码:

#ifndef TEST_H
#define TEST_H

#include "stdio.h"


struct student_info {
    char name[100];
    int age;
    char gender;
};


void hello();

#endif // test.h

当第一次包含 test.h 头文件时,TEST_H 宏没有定义,所以预处理器会定义这个宏,并编译头文件的内容。如果在同一个源文件中再次包含这个头文件,由于 TEST_H 已经被定义,预处理器会跳过整个头文件的内容,从而避免重复定义和编译错误。

上次编辑于:
贡献者: 罗大富BigRich