上期回顾: 【C语言基础】:预处理详解(一)
一、宏和函数的对比
宏通常被应有于执行简单的运算。
比如在两个数中找出较大的⼀个时,写成下面的宏,更有优势⼀些。
#define MAX(x, y) ((x) > (y) ? (x) : (y))
用函数来完成:
- 调用函数
- 执行运算
- 函数返回
使用函数来完成任务就要经历这三个步骤,而这三个步骤都需要一定的时间开销,对于一些简单的运算,这无疑是不太好的。
用宏来完成:
对于简单的运算,宏只有执行运算的时间开销,这个效率明显比函数要高得多。
小结:
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
- 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于 > 来比较的类型。宏的参数是与类型无关的。
利用宏的执行速度短,那是不是以后就只用宏了呢?这明显是不明智的,函数也有着宏所没有的优点:
3. 每次使用宏的时候,⼀份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
4. 宏是没法调试的。
5. 宏由于类型无关,也就不够严谨。(双刃剑)
6. 宏可能会带来运算符优先级的问题,导致程容易出现错。
宏也有函数做不到的功能,例如:宏的参数可以出现类型,函数就不可以
【示例】:利用宏来实现malloc函数
#include<stdio.h> #define Malloc(n, type) (type*)malloc(n*sizeof(type)) int main() { // int* p = (int*)malloc(5 * sizeof(int)); int* prt = Malloc(5, int); return 0; }
当我们将5和int传入到Malloc是,那么n就是5,type就是int,也就是有一个参数是类型,宏是可以实现的,但函数可以实现,预处理之后替换的结果就是(int*)malloc(5 * sizeof(int))。
宏和函数的对比:
二、#和##运算符
2.1 #运算符
#运算符是一个预处理器运算符,用于字符串化(Stringification)。当你在宏定义中使用 # 运算符时,它会将宏的参数转换为一个字符串字面量。这意味着,当宏被展开时,参数的值会被放在双引号中,成为字符串的一部分。
【示例铺垫】:
#include<stdio.h> int main() { printf("hello" "world\n"); printf("helloworld\n"); return 0; }
C语言会将两个字符串看成一个字符串。
#include<stdio.h> int main() { int a = 1; printf("the value of a is %d\n", a); int b = 20; printf("the value of b is %d\n", b); float f = 5.6f; printf("the value of f is %f\n", f); return 0; }
可以看到,这几个打印的只有这两个地方有所差异,那我们可以利用宏来实现这个功能。
【示例】:
#define Print(n, format) printf("the value of " #n " is " format "\n", n) int main() { int a = 1; Print(a, "%d"); int b = 20; Print(b, "%d"); float f = 5.6f; Print(f, "%f"); return 0; }
可以发现,结果其实是一样的,这里的#运算符的作用就是将n转化成"n",例如:#a就是将a转换成"a"。
利用前面的那个铺垫,两个字符串可以看成一个字符串。
注意:使用 # 运算符时,应确保宏参数两侧有空格或其他非字母数字字符,否则可能会导致字符串化不正确。例如,#define NUM 42 和 #define NUM_ 42 会产生不同的结果,因为第一个定义会将 NUM 字符串化,而第二个定义会将 NUM_ 字符串化,并且由于 42 紧跟在 NUM_ 后面,它可能会成为字符串的一部分,导致预处理错误。
2.2 ##运算符
在C语言中,## 是预处理器的标记粘贴运算符。这个运算符可以将两个标识符拼接成一个更长的标识符。当预处理器遇到使用 ## 的宏定义时,它会将 ## 符号左边和右边的任何合法标识符或宏名称拼接在一起,创建一个新的标识符。
【示例铺垫】:求较大值
// 求整数较大值 int int_max(int x, int y) { return x > y ? x : y; } // 求浮点数较大值 float float_max(float a, float b) { return a > b ? a : b; }
这样写显得有点繁琐,因为求不同的数据类型就要写不同的函数,这时候就可以动态创建宏名称:
#include<stdio.h> // \为续航符 #define GENERIC_MAX(type) \ type type##_max(type x, type y)\ {\ return (x>y?x:y);\ } // 使用宏定义不同的函数 GENERIC_MAX(int) GENERIC_MAX(float) int main() { int m1 = int_max(5, 6); printf("%d\n", m1); float m2 = float_max(5.6f, 3.4f); printf("%f\n", m2); return 0; }
预处理之后可以更加明显的看到这之间的变化:
注意:
- 由于 ## 运算符是在预处理阶段进行的,因此它不能用于运行时的代码拼接。
- 确保在使用 ## 运算符时,左右两边的标识符是明确的,否则可能会导致编译错误或者不可预期的行为。
- ##运算符可以与 # 字符串化运算符结合使用,创建更加复杂的宏定义。
命名约定
一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分⼆者。
那我们平时的⼀个习惯是:
- 把宏名全部大写
- 函数名不要全部大写
三、#undef
#undef是一个预处理器指令,用于取消已经定义的宏。当预处理器遇到 #undef指令时,它会移除指定宏的定义,使得宏名不再代表之前定义的文本。
#undef 指令通常用于以下情况:
- 防止宏名冲突:如果在不同的头文件中定义了相同的宏名,或者在修改代码时需要改变宏的定义,可以使用 #undef 来确保宏的最新定义是有效的。
- 条件编译:在条件编译块中,可能需要根据某些条件取消宏的定义,这时可以使用 #undef。
- 清理宏定义:在某些复杂的宏定义中,可能需要在宏展开后清理宏定义,以防止宏名被错误地使用。
使用方法:
// 只需要提供要取消定义的宏名即可 #undef macro_name
【示例】:
#define MAX 100 #undef MAX printf("%d\n", MAX); // 这里会引发错误,因为MAX已不再定义
四、命令行定义
在C语言编程中,命令行定义指的是通过编译器的命令行参数来定义宏或者设置编译时的选项。这种方法允许开发者在不修改源代码的情况下,动态地改变编译过程和生成的程序的行为。
定义宏
大多数C语言编译器允许使用命令行参数来定义宏。在GCC和Clang等编译器中,可以使用 -D 选项来定义宏。
【示例】:命令行定义
#include<stdio.h> int main() { int arr[SZ];// SZ未定义 for (int i = 0; i < SZ; i++) { arr[i] = i + 1; } for (int i = 0; i < SZ; i++) { printf("%d ", arr[i]); } return 0; }
五、条件编译
条件编译是C语言预处理器提供的一项功能,它允许根据预处理器指令的特定条件来包含或排除代码块。这意味着在编译时,只有满足特定条件的代码才会被编译器处理,其他不满足条件的代码将被忽略。这对于根据不同的平台、操作系统或编译时的配置来编译不同的代码非常有用。
条件编译主要使用以下预处理器指令:
- #ifdef:如果定义了某个宏,则编译#ifdef和#endif之间的代码块。
- #ifndef:如果未定义某个宏,则编译#ifndef和#endif之间的代码块。
- #if:如果给定的表达式为真(非零),则编译#if和#endif之间的代码块。
- #elif:如果前面的#if或#elif条件不满足,并且当前#elif表达式为真,则编译#elif和#endif之间的代码块。
- #else:如果前面的所有#if和#elif条件都不满足,则编译#else和#endif之间的代码块。
- #endif:结束条件编译块。
【示例1】:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
#include<stdio.h> #define __DEBUG__ int main() { int arr[10] = { 0 }; for (int i = 0; i < 10; i++) { arr[i] = i + 1; #ifdef DEBUG printf("%d ", arr[i]); //为了观察数组是否赋值成功。 #endif // DEBUG } return 0; }
【示例2】:#if 常量表达式
#include<stdio.h> int main() { #if 0 printf("hello world"); #endif return 0; }
预处理后可以发现,当不满足条件时,这里是不参与编译的
【示例3】:多分支的条件编译
#include<stdio.h> #define M 1 int main() { #if M == 0 printf("hehe\n"); #elif M == 1 printf("haha\n"); #elif M == 2 printf("heihei\n"); #endif return 0; }
注意:最后都要以 #endif 结束。
【示例4】:判断是否被定义
#include<stdio.h> int main() { #if defined(MAX)// 定义了执行,没定义不执行 printf("NO"); #endif #if !defined(MAX)// 没定义执行,定义了不执行 printf("YES"); #endif return 0; }
其实条件编译是非常常见的,比如在头文件里面就会经常使用条件编译,以下是头文件stdio.h的部分条件编译:
六、头文件的包含
1. 头文件包含的方式
在C语言中,头文件的包含方式主要有两种:直接包含和间接包含。这两种方式都是为了在当前文件中引入其他文件中定义的函数、变量、类型声明等,以便在当前文件中使用它们。
- 直接包含
直接包含是指在源文件或头文件中使用预处理器指令 #include 直接引入另一个文件。这是最常见的包含方式,可以确保所需的声明和定义在当前编译单元中可用。
#include <stdio.h>
编译器会在标准库的路径中搜索这些文件。这些路径通常是编译器安装时预设的,包括了所有标准库文件的位置。尖括号通常用于包含C标准库的头文件。
查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件⼀样在标准位置查找头文件。如果找不到就提示编译错误。
2. 嵌套文件包含
我们已经知道, #include 指令可以使另外⼀个文件被编译。就像它实际出现于 #include 指令的地方⼀样。
这种替换的方式很简单:预处理器先删除这条指令,并用包含文件的内容替换。
⼀个头文件被包含10次,那就实际被编译10次,如果重复包含,对编译的压力就比较大。
test.h头文件
void test(); struct Stu { int id; char name[20]; };
如果直接这样写,test.c文件中将test.h包含5次,那么test.h文件的内容将会被拷贝5份在test.c中。
如果test.h 文件比较大,这样预处理后代码量会剧增。如果工程比较大,有公共使用的头文件,被大家都能使用,又不做任何的处理,那么后果真的不堪设想。
解决办法:
每个头文件的开头写:
test.h头文件
#ifndef __TEST_H__ #define __TEST_H__ void test(); struct Stu { int id; char name[20]; }; #endif
或者#pragma once
#pragma once void test(); struct Stu { int id; char name[20];
就可以避免头文件的重复引入。