预处理作为编译的预先准备阶段,其中的宏是一种由预处理器处理的指令或代码片段。宏的基本定义由#define来完成。通常为了区分变量名和函数,宏名通常使用大写字母串来书写。
#define 宏名 宏定义字符串
对于宏用途简单的描述包括以下几点:
1.符号常量,用来增加程序的灵活性
2.简单的函数功能实现,但局限于一行之内完成
3.提供需要多次书写时的方便。可以使用一行宏来简写。
针对宏的特点和用途,接下来详细介绍。
宏定义
一般来说宏可以定义常量也可以定义变量。
#define name( parament-list ) stuff //name和左括号要紧密相邻
#define PI 3.14159 #define SQUARE(x) ((x) * (x))
注意:一般在语句的最后不会加上分号;,因为这也属于语句的一部分,否则会出现以下情况:
#define NUMBER 123; //打印出来为以下: //123;;
而对于某些函数语句例如if、while,更有可能出现语法错误。
if(condition) max = MAX; else max = 0;
宏替换
在预处理阶段,预处理器会将代码中的宏调用替换为宏定义的内容。
#define MAX(x, y) ((x) > (y) ? (x) : (y))
现在我们在代码中使用这个宏来比较两个数的大小,例如:
int a = 10; int b = 20; int max_num = MAX(a, b);
在预处理阶段,预处理器会将宏调用MAX(a, b)
替换为其定义的内容,即((a) > (b) ? (a) : (b))
。因此,上面的代码在编译之前会被替换为:
int a = 10; int b = 20; int max_num = ((a) > (b) ? (a) : (b));
注意:由于运算符优先级的问题,定义宏不要吝啬括号。比如以下的例子
#define SQUARE(x) x * x #define SQUARE(x) (x) * (x)
这两种表达方式得到的结果有可能都不相同。比如我们代入a+1,而当a=1时
得到的两种结果:
3//得到的表达式是:a+1*a+1 4//得到的表达式是:(a+1)*(a+1)
所以我们最保险的方法就是给整个宏都加上括号:
#define SQUARE(x) ((x) * (x))
宏替换的过程可以简单概括为以下几个步骤:
- 宏定义:在代码中使用
#define
指令定义宏,为常量、函数等起一个易记的名字。 - 宏调用:在代码中使用定义好的宏,传入参数(如果有的话)。
- 预处理阶段:在编译之前的预处理阶段,预处理器会扫描代码中的宏调用,并将其替换为宏定义的内容。
- 宏展开:预处理器将宏调用展开为其定义的内容,包括参数的替换。
- 编译阶段:展开后的代码会被编译器处理,生成可执行代码。
宏与函数
经过上述的介绍可以发现,宏和函数实际上有很多相似之处。实际上对于它们的使用也有很大的相似之处,但是它们之间的差异也是显而易见的。
- 宏:
- 预处理阶段替换:宏是在预处理阶段被替换为其定义的内容,只需要直接运算,而不是像函数那样需要先调用再运算再返回。
- 无类型检查:宏没有参数类型检查,因此在宏中使用参数时需要特别小心,确保类型匹配。(这实际上既是优点也是缺点,增加了自由性但是舍弃了严谨性)
- 效率:宏展开时会直接替换文本,没有函数调用的开销,运行所需时间也会大幅减少,因此在一些情况下可能更高效。
- 代码复杂性:宏可以包含更复杂的代码逻辑,如条件判断等。
- 函数:
- 运行时调用:函数是在程序运行时被调用执行的,具有独立的作用域和参数传递机制。
- 类型安全:函数具有参数类型检查,可以避免一些潜在的错误。
- 可读性:函数提供了更结构化和模块化的代码组织方式,增强了代码的可读性和维护性。
- 调试:函数调用可以更方便地进行调试和跟踪。
在选择使用宏还是函数时,可以根据具体情况来决定:
- 如果需要高效的代码替换和更复杂的宏逻辑,可以选择宏。
- 如果需要类型安全、可读性强和更好的代码组织,可以选择函数。
宏的缺陷
- 可能引起宏展开后的代码过长,影响可读性。
- 可能导致宏的滥用,使代码变得难以理解和维护。
- 宏无法调试,不能很好的检索错误
- 宏无法像函数那样递归,不能嵌套宏
- 宏展开可能导致意外的副作用,如参数多次计算等。
x+1;//不带副作⽤ x++;//带有副作⽤,会被多次计算
预处理运算符
#
作用:将宏参数转换为字符串
#include <stdio.h> #define STRINGIFY(x) #x int main() { int num = 10; const char* str = STRINGIFY(num); printf("String representation of num: %s\n", str); return 0; }
STRINGIFY
宏使用了#
运算符,将传入的参数num
转换为字符串。在main
函数中,我们将num
的字符串表示打印出来。
String representation of num: 10
##
作用:连接前后两个符号
#include <stdio.h> #define CONCAT(a, b) a##b int main() { int num1 = 10; int num2 = 20; int result = CONCAT(num, 1) + CONCAT(num, 2); printf("Result: %d\n", result); return 0; }
CONCAT
宏使用了##
运算符,将num
和后面的数字连接在一起,形成新的符号。在main
函数中,我们使用CONCAT
宏将num1
和num2
连接在一起,并将它们相加。
Result: 30
这表明##
运算符成功将num1
和num2
连接在一起,并进行了相加操作。
而如果我们不使用##运算符,宏参数和其他文本会被简单地拼接在一起,而不会进行连接操作。
得到的结果就是
Result: 0
#和##在实际运用中其实很少,所以只作介绍。
条件编译
条件编译允许根据条件来选择性地编译代码。如果我们要将某语句临时放弃或者更改,就可以用到条件编译。
理论上条件编译的功能和条件语句十分相像,只不过一个是在预处理过程中一个是在具体的代码程序中。
在C语言中,条件编译通常使用预处理指令#if
、#ifdef
、#ifndef
、#elif
、#else
和#endif
来实现。
#if define 宏名以及条件
#ifdef-----前者的简写形式
用于条件编译定义
#if !define 宏名以及条件
#ifndef-----前者的简写形式
用于否定的条件编译定义
#elif
#else
两者多用于多个分支的条件编译
#endif
条件编译预处理指令的结束标记,与前面几个指令配对使用,用于结束条件编译的代码块。
#include <stdio.h> #define DEBUG 1 int main() { int num = 10; #if DEBUG printf("Debug mode is enabled\n"); printf("Number: %d\n", num); #else printf("Debug mode is disabled\n"); #endif return 0; }
在上面的示例中,我们定义了一个宏DEBUG
并赋值为1。在main
函数中,使用条件编译指令#if DEBUG
来判断是否启用了调试模式。如果DEBUG
宏被定义且值为非零,则会编译#if DEBUG
和#else
之间的代码;否则,会编译#else
和#endif
之间的代码。
当程序编译时,由于DEBUG
宏被定义为1,所以会编译#if DEBUG
和#else
之间的代码。因此,输出结果为:
Debug mode is enabled Number: 10
头文件包含
头文件包含的方式为以下两种
#include <header.h>
:
- 使用尖括号
<>
包含头文件时,编译器会在系统默认的目录中查找头文件。如果找不到就提示编译错误。 - 这种方式通常用于包含标准库头文件或系统提供的头文件。
#include "header.h"
:
- 使用双引号
""
包含头文件时,编译器会先在当前源文件所在目录中查找头文件,如果找不到再去系统默认目录中查找。如果找不到就提示编译错误。 - 这种方式通常用于包含自定义的头文件。
我们可以发现,""的形式似乎较<>的泛用性更大,那为何不直接全部使用前者来包含头文件呢?
这样做确实可以,但是我们需要时刻注意优秀的代码是需要保持高效性的,这样做会增加查找的时间,并且它并不能用于查找库文件,所以在某些时刻二者区分使用是有好处的。
预处理指令
除了上述已经基本介绍完毕的预处理指令,
常见的预处理指令还包括这些:
#undef
:取消宏定义#error
:生成错误消息#warning
:生成警告消息#pragma
:编译器指令#line
:修改行号和文件名信息#ifdef
、#ifndef
、#else
、#elif
、#endif
:条件编译#pragma once
:头文件只包含一次
这些预处理指令在源代码编译之前起作用,用于对源代码进行预处理、条件编译、头文件包含等操作,可以在一定程度上提高代码的灵活性和可维护性。
而在实际编程中,合理使用预处理指令可以简化代码逻辑、提高代码的可读性和可维护性,从而帮助程序员更好地编写代码。