预处理详解:#define、宏、# 与 ##、条件编译一篇过
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
本文整理自 C 语言「预处理」部分的笔记(鹏哥第 25 讲 + 自己的两遍替换复盘批注)。全部内容只围绕一个事实:预处理阶段发生的一切,本质都是文本替换,而且文本替换的优先级很高。
一、预定义符号
C 语言设置了一些预定义符号 ,可以直接使用,预定义符号也是在预处理期间处理的(直接替换):
c
__FILE__ // 进行编译的源文件
__LINE__ // 文件当前的行号
__DATE__ // 文件被编译的日期
__TIME__ // 文件被编译的时间
__STDC__ // 如果编译器遵循 ANSI C,其值为1,否则未定义
跑一下:
c
printf("%s\n", __FILE__);
printf("%d\n", __LINE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
输出 test.c、6、Nov 22 2025、19:13:25。这些值是编译时写死的,不是运行时算的。
二、#define 定义常量
基本语法:
c
#define name stuff
#define MAX 100
#define STR "hehe"
#define 定义的符号是在预处理阶段就替换掉 的(这就是前面编译笔记里 .i 文件里宏消失的原因)。
思考:define 定义标识符的时候,要不要在最后加上 ;?
不要。因为 #define 是文本替换,如果写成 #define MAX 100;,那么 int a = MAX; 会被替换成 int a = 100;;------多出一个空语句,在 if/else 里甚至会直接报错。
顺带一个相关的小知识:for 循环的判断部分被省略的话,意思是判断恒成立(for(;;) 就是死循环)。
三、#define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
宏的声明方式:
c
#define name( parament-list ) stuff
其中 parament-list 是一个由逗号隔开的符号表,它们可能出现在 stuff 中。
注意:参数列表的左括号必须与 name 紧邻 。如果两者之间有任何空白存在,参数列表就会被解释为 stuff 的一部分------#define SQUARE (x) x*x 定义的就不是一个带参宏,而是把 SQUARE 替换成 (x) x*x。
3.1 最经典的坑:宏不是传参,是替换
c
#define SQUARE(x) x*x
int a = 10;
int r = SQUARE(a);
// 预处理后:int r = a*a; → 100,没问题
但换个用法:
c
int r = SQUARE(a + 10);
// 预处理后:int r = a + 10*a + 10; → 10 + 100 + 10 = 120!
不是 400,是 120 。原因:宏仅仅是文本替换,x 被原样替换成 a + 10,乘法的优先级比加法高,先算了 10*a。
所以,对于要求值的宏定义,要在外面加上括号,防止替换后优先级问题:
c
#define SQUARE(x) ((x)*(x))
括号还不止一层。再看 #define DOUBLE(x) (x)+(x):
c
int r = 5 * DOUBLE(a);
// 预处理后:int r = 5 * (a)+(a); → 50 + 10 = 60!
整个宏体也得包上括号:#define DOUBLE(x) ((x)+(x))。
提示:所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符和邻近操作符之间不可预料的相互作用。
3.2 带有副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。
副作用就是表达式求值的时候出现的永久性效果。
c
x+1; // 不带副作用
x++; // 带有副作用
比如 #define MAX(a, b) ((a)>(b)?(a):(b)),调用 MAX(x++, y++) 时,x++ 会被求值两次,x 最终多加了一次------这在函数里绝不会发生,因为函数参数只算好一次才传参。
我的复盘笔记(最重要的一条) :模拟计算机,先进行两次文本替换,而不是传参:
- 第一次替换 :参数直接替换,不考虑"先使用再自增"这些执行语义 ------
a++就是字面的a++,替换进去出现了几次就写几次; - 第二次替换 :把替换后的内容换回语言处,替换完后,再考虑优先级。
它不是函数传参(不存在"先算好参数再传入"),所以:
- 第一次文本替换时,不应有
++/--这种带有副作用的参数; - 宏体和参数都要加括号,否则替换后符号优先级和想象不符。
另外:第一次替换前,如果参数本身也是 #define 定义的符号,那么先替换它,再走一遍上面的流程(这就是下面替换规则的第 1 步)。
四、宏替换的规则
在程序中扩展 #define 定义符号和宏时,需要涉及几个步骤:
- 在调用宏时,首先对参数进行检查 ,看看是否包含任何由
#define定义的符号。如果是,它们首先被替换; - 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被它们的值所替换;
- 最后,再次对结果文件进行扫描 ,看看它是否包含任何由
#define定义的符号。如果是,就重复上述处理过程。
注意:
- 宏参数和
#define定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归; - 当预处理器搜索
#define定义的符号的时候,字符串常量的内容并不被搜索。
五、宏和函数的对比
宏通常被应用于执行简单的运算。比如在两个数中找出较大的一个,写成宏更有优势:
c
#define MAX(a, b) ((a)>(b)?(a):(b))
那为什么不用函数来完成这个任务?原因有二:
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多,所以宏比函数在程序的规模和速度方面更胜一筹;
- 更为重要的是,函数的参数必须声明为特定的类型 ,所以函数只能在类型合适的表达式上使用。反之这个宏可以适用于整型、长整型、浮点型等可以用于
>来比较的类型------宏的参数是类型无关的。
宏有时候可以做到函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到:
c
#define MALLOC(num, type) (type*)malloc(num * sizeof(type))
// 使用
MALLOC(10, int); // 类型作为参数
// 预处理替换之后:
(int*)malloc(10 * sizeof(int));
宏和函数的完整对比表
| 属性 | #define 定义宏 | 函数 |
|---|---|---|
| 代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码 |
| 执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些 |
| 操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多写括号 | 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测 |
| 带有副作用的参数 | 参数可能被替换到宏体中的多个位置,如果宏的参数被多次计算,带有副作用的参数求值可能会产生不可预料的结果 | 函数参数只在传参的时候求值一次,结果更容易控制 |
| 参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型 | 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是相同的 |
| 调试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
| 递归 | 宏是不能递归的 | 函数是可以递归的 |
命名约定 :一般来讲函数和宏的使用语法很相似,语言本身没法帮我们区分二者。所以平时的习惯是:宏名全部大写,函数名不要全部大写。
六、# 和 ## 运算符
6.1 #:把参数变成字符串(字符串化)
# 运算符将宏的一个参数转换为字符串字面量 。它仅允许出现在带参数的宏的替换列表中。# 运算符所执行的操作可以理解为"字符串化"。
c
#define PRINT(n, format) printf("the value of " #n " is " format "\n", n)
int a = 10;
float f = 3.14f;
PRINT(a, "%d"); // the value of a is 10
PRINT(f, "%f"); // the value of f is 3.140000
拆开看:#n 把参数 a 变成了字符串 "a"。
这里能成立,靠的是 C 语言的一个特性:字符串可以拼接------相邻的两个字符串字面量会自动融合:
"asas" "asas" → "asasasas"
"the value of "、"a"、" is "、"%d"、"\n" 五段字符串直接融合成一条完整的格式串。
还有一个关键细节(自己复盘时标注的):即使发生了替换,# 也使其变为字符串融入字符串;而字符串中的内容不会发生(宏)替换 ------替换规则第 2 条说的"字符串常量的内容并不被搜索"就是这个意思。所以 #define print(x, format) printf("the value of " #x " is " format, x) 里,把要替换的字符用 #x 空出来 ,字符串本体里的 x 不会被换掉;还是两步替换:第二步替换时,字符串中的内容不参与。
6.2 ##:记号粘合,生成"函数模板"
## 可以把位于它两边的符号合成一个符号,它允许宏定义从分离的文本片段创建标识符 。## 被称为记号粘合。
这样的连接必须产生一个合法的标识符,否则其结果就是未定义的。
想想:写一个函数求 2 个数的较大值的时候,不同的数据类型就得写不同的函数。用 ## 可以定义函数的模板:
c
// 定义函数的模板
#define GENERIC_MAX(type) \
type type##_max(type x, type y) \
{ \
return x>y ? x : y; \
}
GENERIC_MAX(int) // 生成 int_max()
GENERIC_MAX(char) // 生成 char_max()
GENERIC_MAX(float) // 生成 float_max()
GENERIC_MAX(int) 展开后,type##type##_max 变成 int_max------一个全新的函数名。不过说实话,## 的用途比较少,知道有这招就行。
七、#undef
这条指令用于移除一个宏定义:
c
#undef NAME
// 如果现存的一个名字需要被重新定义,那么它的旧定义首先要被移除
八、命令行定义
许多 C 的编译器提供了一种能力,允许在命令行中定义符号,用于启动编译过程。
例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大些,我们需要一个数组能够大些):
bash
gcc test.c -D ARRAY_SIZE=100
九、条件编译
在编译一个程序的时候,我们如果要将一条语句(一组语句)编译或者放弃是很方便的,因为我们有条件编译指令。
比如说:调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
c
#include <stdio.h>
#define __DEBUG__
int main()
{
int i = 0;
int arr[10] = {0};
for (i = 0; i < 10; i++)
{
arr[i] = i;
#ifdef __DEBUG__
printf("%d\n", arr[i]); // 为了观察数组是否赋值成功
#endif
}
return 0;
}
常见的条件编译指令:
c
// 1. 单分支
#if 常量表达式
//...
#endif
// 常量表达式由预处理器求值。如:
#if 1>2
printf("hehe"); // 这个为假的话,连编译都没有,直接被丢掉
#endif
// 2. 多分支
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
// 3. 判断是否被定义
#if defined(M)
#ifdef M // 两者等价
#if !defined(M)
#ifndef M // 两者等价
// 4. 嵌套指令(配合多平台/多选项)
#if defined(OS_UNIX)
#ifdef OPTION1
unix_version_option1();
#endif
#ifdef OPTION2
unix_version_option2();
#endif
#elif defined(OS_MSDOS)
#ifdef OPTION2
msdos_version_option2();
#endif
#endif
和 if 语句最本质的区别:条件编译为假的那条分支,连编译都没有参与 ------它在预处理阶段就被删除了,二进制里根本不存在这段代码;而 if 为假只是运行时不执行,代码还在。
十、头文件的包含
10.1 本地文件包含 vs 库文件包含
- 本地文件包含:
#include "test.h" - 库文件包含:
#include <stdio.h>
10.2 防止头文件被重复包含
一个头文件被 include 5 次,它的内容就会被展开 5 次(预处理是递归插入的)。自己的头文件这样写:
c
#ifndef __TEST_H__
#define __TEST_H__
// 函数声明
int Add(int x, int y);
#endif
第一次包含时 __TEST_H__ 未定义,定义它并展开内容;第二次再包含时 #ifndef 不成立,整个头文件被跳过------这就是用条件编译防止重复展开 。(现代编译器也支持 #pragma once。)
十一、其他预处理指令
c
#error
#pragma
#line
...
#pragma pack() // 在结构体部分介绍
不做展开,参考《C 语言深度解剖》学习。
总结
预处理阶段的一切,记住三句话就够了:
- 一切皆文本替换,替换优先级很高------先整体替换,替换完了再谈优先级;
- 宏参要走两遍替换,不是函数传参 ------所以别传
++/--,记得多加括号,宏不能递归、字符串里不搜索; - 条件编译决定代码"参不参与编译" ------为假连编译都没有,这是它和
if的本质区别。
配合上一篇《从 test.c 到 test.exe》,-E 出来的 .i 文件就是这一篇所有规则的现场证据。