前言
本章将对预处理知识进行讲解。首先介绍预定义符号,随后着重讲解预处理指令。介绍预处理操作符,最后将宏和函数进行对比。
一、预处理
0x00 什么是预处理
【百度百科】程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
0x01 预定义符号
📚 介绍:在预处理阶段被处理的已经定义好的符号为预定义符号。这些符号是可以直接使用的,是在C语言中已经内置好的。
📌 注意事项:值得注意的是,__ 为两个下划线!
💬 代码演示:
#include <stdio.h> int main(void) { printf("%s\n", __FILE__); // 返回使用行代码所在的源文件名,包括路径 printf("%d\n", __LINE__); // 返回行号 printf("%s\n", __DATE__); // 返回程序被编译的日期 printf("%s\n", __TIME__); // 返回程序被编译的时间 printf("%s\n", __FUNCTION__); // 返回所在函数的函数名 return 0; }
🚩 运行结果如下:
❓ 这些预定义符号有什么用?
💡 如果一个工程特别复杂,这时去调试时可能会无从下手。所以需要代码在运行的过程中记录一些日志信息,通过日志信息分析程序哪里出了问题,再进行排查就如同瓮中捉鳖。
💬 举个例子:
#include <stdio.h> int main(void) { int i = 0; FILE* pf = fopen("log.txt", "a+"); //追加的形式,每运行一次就追加 if (pf == NULL) { perror("fopen"); return 1; } for (i = 0; i < 5; i++) { printf("* 错误日志 "); printf("%d *\n", i+1); printf("发生时间:%s %s\n", __DATE__, __TIME__); printf("具体位置:%s,函数名为%s,第%d行。\n", __FILE__, __FUNCTION__, __LINE__); printf("\n"); } fclose(pf); pf = NULL; return 0; }
🚩 (测试,运行三次代码)
❗ 关于 __STDC__ ,如果编译器完全遵循 ANSI C 标准则返回1,否则未定义。
二、#define
0x00 #define 定义标识符
💬 代码演示:#define 定义标识符的方法
#include <stdio.h> #define TIMES 100 int main(void) { int t = TIMES; printf("%d\n", t); return 0; }
🚩 运行结果:100
🔑 解析:在预处理阶段就会把 TIMES 替换为 100。预处理结束后 int t = TIMES 这里就没有TIMES 了,会变为 int t = 1000。
// 预处理前 int t = TIMES; // 预处理后 int t = 1000;
📌 当然了, #define 定义的符号可不仅仅只有数字,还可以用来做很多事,比如:
#define REG register //给关键字register,创建一个简短的名字 #define DEAD_LOOP for(;;) //用更形象的符号来替换一种实现
① #define REG register,给关键字 register,创建一个简短的名字:
#define REG register int main(void) { register int num = 0; REG int num = 0; // 这里REG就等于register return 0; }
② #define DEAD_LOOP for(;;) ,用更形象的符号来替换一种实现:
#define DEAD_LOOP for(;;) int main(void) { DEAD_LOOP // 预处理后替换为 for(;;); ; // 循环体循环的是一条空语句 DEAD_LOOP; // 那么可以这么写,这个分号就是循环体,循环的是一个空语句 return 0; }
③ 这里假设一个程序里 switch 语句后面都需要加上break,但是某人原来不是写C语言的,他以前用的语言 case 后面是不需要加 break 的,因为他不适应每个 case 后面都要加上 break,所以总是会忘。这时可以妙用 #define 来解决:
#define CASE break;case // 在写case语句的时候自动字上break int main(void) { int n = 0; //switch (n) { // case 1: // break; // case 2: // break; // case 3: // break; //} switch (n) { case 1: // 第一个case不能替换 CASE 2: // 相当于 break; case 2: CASE 3: // 相当于 break; case 3: } return 0; }
④ 如果定义的 stuff 过长,可以分行来写,除了最后一行外,每行的后面都加一个续行符即可 \ :
#include <stdio.h> #define DEBUG_PRINT printf("file:%s\nline:%d\n \ date:%s\ntime:%s\n" , \ __FILE__,__LINE__ , \ __DATE__,__TIME__ ) int main(void) { DEBUG_PRINT; return 0; }
❓ #define 定义标识符时,为什么末尾没有加上分号?
#define TIMES 100; #define TIMES 100
💬 举个例子:加上分号后,预处理替换的内容也会带分号 100;
#include <stdio.h> #define TIMES 100; int main(void) { int t = TIMES; // int t = 100;; // 等于两个语句 // int t = 100; // ; return 0; }
❌ 举个例子:加上分号,代码会出错的情况
#define _CRT_SECURE_NO_WARNINGS 1 #include <stdio.h> #define TIMES 100; int main(void) { int a, b; if (a > 10) b = TIMES; // b = 100;; else // else不知道如何匹配了 b = -TIMES; // b = 100;; return 0; }
🔺 结论:在 #define 定义标识符时,尽量不要在末尾加分号!(必须加的情况除外)
0x01 #define 定义宏
📚 介绍:#define 机制允许把参数替换到文本中,这种实现通常被称为宏(macro)或 定义宏(define macro),parament-list 是一个由逗号隔开的符号表,他们可能出现在 stuff 中。
📌 注意事项:
① 参数列表的左括号必须与 name 紧邻。
② 如果两者之间由任何空白存在,参数列表就会将其解释为 stuff 的一部分。
💬 代码演示:3×3=9
#include <stdio.h> #define SQUARE(X) X*X int main(void) { printf("%d\n", SQUARE(3)); // printf("%d\n", 3 * 3); return 0; }
❓ SQUARE (3+1) 的结果是什么?
#include <stdio.h> #define SQUARE(X) X*X int main(void) { printf("%d\n", SQUARE(3+1)); return 0; }
💡 答案:7 。这里将 3+1 替换成 X,那么 X 就是3+1, 3+1 * 3+1, 根据优先级结果为 7。要看作为一个整体,完全替换。宏的参数是完成替换的,他不会提前完成计算,而是替换进去后再计算。替换是在预处理阶段时替换,表达式真正计算出结果是在运行时计算。
💬 如果想获得 3+1 相乘(也就是得到 4×4 = 16) 的结果,我们需要给他们添加括号:
#include <stdio.h> // 整体再括一个括号,严谨 #define SQUARE(X) ((X)*(X)) int main(void) { printf("%d\n", SQUARE(3+1)); return 0; }
💬 另外,整体再套一个括号!让代码更加严谨,防止产生不必要的错误。举个例子,我们DOUBLE实现两数相加,我希望得到 10* DOUBLE,也就是 "10*表达式相加" 的情况:
#include <stdio.h> #define DOUBLE(X) (X)+(X) int main(void) { printf("%d\n", 10 * DOUBLE(3+1)); // printf("%d\n", 10 * (4) + (4)); // 我们本意是想得到80,但是结果为44,因为整体没带括号 return 0; }
🚩 运行结果:44(不是预期想得到的结果)
🔑 解决方案:整体再加上一个括号!
#define DOUBLE(X) ((X)+(X)) int main(void) { printf("%d\n", 10 * DOUBLE(3+1)); return 0; }
🚩 运行结果:80(达到预期想得到的结果)
🔺 结论:所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,可以有效避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料地相互作用。
0x02 #define 替换规则
📚 在程序中扩展 #define 定义符号或宏时,需要涉及的步骤如下:
1️⃣ 检查:在调用宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果包含,它们首先被替换。
2️⃣ 替换:替换文本随后被插入到程序中原来的文本位置。对于宏,函数名被它们的值替换。
3️⃣ 再次扫描:最后,再次对结果文件进行扫描,看看是否包含任何由 #define 定义的符号。如果包含,就重复上述处理过程。
💬 举个例子:
#include <stdio.h> #define M 100 #define MAX(X, Y) ((X)>(Y) ? (X):(Y)); int main(void) { int max = MAX(101, M); return 0; }
📌 注意事项:
① 宏参数 和 #define 定义中可以出现 #define 定义的变量。但是对于宏绝对不能出现递归!
② 当预处理器搜索 #define 定义的符号的时候,字符串常量的内容并不被搜索。
0x03 # 和 ##
❓ 我们知道,宏是把参数替换到文本中。那么如何把参数插入到字符串中呢?
❌ 比如这种情况,使用函数是根本做不到的:
void print(int x) { printf("变量?的值是%d\n", ?) 函数根本做不到 } int main(void) { int a = 10; // 打印内容:变量a的值是10 print(a); int b = 20; // 打印内容:变量b的值是20 print(b); int c = 30; // 打印内容:变量c的值是30 print(c); return 0; }
💡 这种情况,就可以用 宏 来实现。
📚 介绍:# 把一个宏参数变成对应的字符串。
💬 使用 # 解决上面的问题:
#include <stdio.h> #define PRINT(X) printf("变量"#X"的值是%d\n", X); // #X 就会变成 X内容所定义的字符串 int main(void) { // 打印内容:变量a的值是10 int a = 10; PRINT(a); // printf("变量""a""的值是%d\n", a); // 打印内容:变量b的值是20 int b = 20; PRINT(b); // printf("变量""b"的值是%d\n", b); // 打印内容:变量c的值是30 int c = 30; PRINT(c); // printf("变量""c""的值是%d\n", c); return 0; }
🚩 运行结果如下:
⚡ 改进:让程序不仅仅支持打印整数,还可以打印其他类型的数(比如浮点数):
#include <stdio.h> #define PRINT(X, FORMAT) printf("变量"#X"的值是 "FORMAT"\n", X); int main(void) { // 打印内容:变量a的值是10 int a = 10; PRINT(a, "%d"); // 打印内容:变量f的值是5.5 float f = 5.5f; PRINT(f, "%.1f"); //printf("变量""f""的值是 ""%.1f""\n", f); return 0; }
✨ 这操作是不是很奇葩?还有更奇葩的呢,这位更是重量级:
📚 介绍:## 可以把位于它两边的符号融合成一个符号。它允许宏定义从分离的文本片段创建标识符。
💬 使用 ## 将两边的符号缝合成一个符号:
#include <stdio.h> #define CAT(X,Y) X##Y int main(void) { int vs2003 = 100; printf("%d\n", CAT(vs, 2003)); // printf("%d\n", vs2003); return 0; }
🚩 运行结果如下:
📌 注意事项:## 也可以将多个符号合成一个符号,比如 X##Y##Z
0x04 #undef
📚 用于移除一个宏定义。
💬 代码演示:用完 M 之后移除该定义
#include <stdio.h> #define M 100 int main(void) { int a = M; printf("%d\n", M); #undef M // 移除宏定义 return 0; }
0x05 带 "副作用" 的宏参数
❓ 什么是副作用?
💡 后遗症就是表达式求值的时候出现的永久性效果,例如:
// 不带有副作用 x + 1; // 带有副作用 x++; int a = 1; // 不带有副作用 int b = a + 1; // b=2, a=1 // 带有副作用 int b = ++a; // b=2, a=2
📚 介绍:当宏参数在宏的定义中出现超过一次的情况下,如果参数带有副作用(后遗症),那么你在使用这个宏的时候就可能出现危险,导致不可预料的后果。这种带有副作用的宏参数如果传到宏体内,这种副作用一直会延续到宏体内。
💬 举个例子:
#include <stdio.h> #define MAX(X,Y) ((X)>(Y)?(X):(Y)) int main(void) { int a = 5; int b = 8; int m = MAX(a++, b++); printf("m = %d\n", m); printf("a=%d, b=%d\n", a, b); return 0; }
🚩 运行结果如下:
🔺 结论:写宏的时候尽量避免使用这种带副作用的参数。
0x06 宏和函数对比
💬 举个例子:在两数中找较大值
① 用宏:
#include <stdio.h> #define MAX(X,Y) ((X)>(Y)?(X):(Y)) int main(void) { int a = 10; int b = 20; int m = MAX(a, b); // int m = ((a)>(b) ? (a):(b)) printf("%d\n", m); return 0; }
② 用函数:
#include <stdio.h> int Max(int x, int y) { return x > y ? x : y; } int main(void) { int a = 10; int b = 20; int m = Max(a, b); printf("%d\n", m); return 0; }
❓ 那么问题来了,宏和函数那种更好呢?
💡 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多,所以宏比函数在程序的规模和速度方面更胜一筹。 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之,宏可以适用于整型、长整型、浮点型等可以用于比较的类型。因为宏是类型无关的。
📚 当然,宏也有劣势的地方:
① 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
② 宏不能调试。
③ 宏由于类型无关,因为没有类型检查,所以不够严谨。
④ 宏可能会带来运算符优先级的问题,导致程容易出现错。
💬 宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到:
#include <stdio.h> #include <stdlib.h> #define MALLOC(num, type) (type*)malloc(num*sizeof(type)) int main(void) { // 原本的写法:malloc(10*sizeof(int)); // 但我想这么写:malloc(10, int); int* p = MALLOC(10, int); // (int*)malloc(10*sizeof(int)) ... return 0; }
📌 宏和函数的对比表:
属性 | #define 定义宏 | 函数 |
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非 常小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每 次使用这个函数时,都调用那个地方的同一份代码 |
执行速度 |
更快 | 存在函数的调用和返回的额外开 销,所以相对慢一些 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境 里,除非加上括号,否则邻近操作符的优先级可能 会产生不可预料的后果,所以建议宏在书写的时候多些括号 | 函数参数只在函数调用的时候求 值一次,它的结果值传递给函数。表达式的求值结果更容易预测。 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副 作用的参数求值可能会产生不可预料的结果 | 函数参数只在传参的时候求值一 次,结果更容易控制 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法 的,它就可以使用于任何参数类型 | 函数的参数是与类型有关的,如 果参数的类型不同,就需要不同 的函数,即使他们执行的任务是 不同的 |
递归 | 宏是不能递归的 | 函数是可以递归的 |
🔺 总结:如果一个运算的逻辑足够简单,建议使用宏。反之,如果一个运算的逻辑足够复杂,建议使用函数。
💭 内联函数(C99)简要介绍:
0x07 命名约定
📚 命名约定,一般来讲函数的宏的使用语法很相似,所以语言本身没法帮我们区分二者。约定俗成的一个习惯是: 宏名全部大写,函数名不要全部大写。
不过这也不是绝对的,比如我有时候就是想把一个宏伪装成函数来使用,那么我就全小写给宏取名。并不强制,但是这个约定是每个C/C++程序员大家的一种 "约定" 。