写在开篇
C/C++的预处理器大家应该再熟悉不过了,但是在刚学习C/C++或者开发经验比较少的时候,宏绝对是一个让人头疼的东西,因为在不熟悉的情况下,无法很直观的理解这是什么意思,大脑也不可能将文本进行替换。尤其是在看源码的时候,会发现源码中的宏定义、宏隔离到处乱飞,非常容易把人看昏头。
但是实际参与开发后,会觉得这东西其实挺简单的。那为什么还要写这一篇文章?
一方面是宏在使用中确实有一些语法细节,自己每次都还需要查一下,作为年轻的老年人,想总结一下,以后直接翻自己的文章;
另一方面是最近发生的一个故事,最近公司在招人,和leader吃饭的时候聊天,他说对最近面试的质量很不满意,举了个例子。他看候选者在简历上写的熟悉C++的,然后就让他写一个类变量的Getter/Setter宏来减少开发重复代码编写量,候选者没写出了,leader就觉得是不是自己出的有点难了,就说让他写一个宏定义代表圆周率,结果还是没写出了,leader只好终止了C/C++相关的问题。
上面这个小插曲反应了一个事实,如果没有很多的项目开发经验,或者源码阅读经验,实际上很多人对宏不是那么了解。
说回宏的本身,宏本质上是在编译器预处理的时候中进行文本替换,但是我觉得宏的最重要作用是,它是一段说明文字,能让人一眼看出这段看出这段代码的作用。下面详细说明下宏的语法细节
预处理指令
主要使用到的关键字如下表所示:
预处理器关键字 | 作用 |
---|---|
#include | 包含头文件,预期逐步被module替换掉 |
#define | 用于定义宏 |
#ifdef #if、 #elif 、 #else 、#endif | 用于条件编译 |
defined(宏名称) | 检查宏是否已经定义 |
#pragma | 用于向编译器发出特定的指示或命令。 |
#include
:这个包含头文件的命令就不用多说,主要注意下#include,本质上是在预编译期间之间将包含的头文件的文本进行复制。因此会出现重复引用的问题;我理解这个在C++20 module引入之后逐步退出历史,但是肯定是一个复杂且漫长的过程。
#pragma
:用于向编译器发出特定的指示或命令,一般也就是用#pragma once
来避免头文件重复包含。有很多不常用的高级用法,这些用的太少了,我也记不住,一般遇到了再查即可
#define
我们通过#define
进行宏的定义,而宏是预处理器提供的特性,用于在预处理过程中进行文本替换。
我们下面举个例子看一下#define
的基本使用:
#define PI 3.1415926;
#define sum_error(x, y) x + y;
#define sum(x, y) (x + y);
int main() {
double area_of_circle = PI * pow(5.0, 2);
//预处理替换: double area_of_circle = 3.1415926 * pow(5.0, 2);
int rect_perimeter = 2 * sum(5, 6);
//预处理替换: int rect_perimeter = 2 * (5 + 6) = 22;
int rect_perimeter2 = 2 * sum_error(5, 6);
//预处理替换: int rect_perimeter = 2 * 5 + 6 = 16;
}
上面举了三个例子:
- 没有输入参数, 会直接将定义的文本原样替换;
- 有输入参数, 在相同位置的参数会按照输入就行替换;
- 编译器不会在乎你定义的宏的含义,只会简单的进行文字替换;就比如
sum_error
和sum
,很明显你在定义的时候不加括号,替换后就出现了不符合预期的错误;
这里面我先对宏进行一个简单的分类:常量宏、函数宏、编译宏。说明这个只是我自己为了方便记忆的区分。
下面举一些例子:
- 常量宏,这个宏就是代表一个常量值,非常常见的例子就是定义一个圆周率。
#define PI 3.1415926
- 函数宏:我对函数宏定义就是有参数的就是函数宏。
#define IF_EXPRESS(condition, express) \
if (condition) { \
express; \
}
#define PROPERTY_GETTER(type, class, name) \
const type& class::get##name() const { \
return m_##name; \
}
#define STRING(str) #str
- 编译宏: 编译器(gcc, clang,mscv等)中已经预定义好的编译宏,通常用于调试、跨平台等
__cplusplus / __APPLE__ / __WIN32__
// cmake
add_definitions(-DDEBUG)
#以及##操作符
#define PROPERTY_GETTER(type, class, name) \
const type& class::get##name() const { \
return m_##name; \
}
#define STRING(str) #str
在上面的两个例子中需要稍微注意的是换行需要通过\就行标明。然后,在C++的宏定义中,#和##是两个不同的操作符,用于宏参数的处理和宏的拼接。
- #操作符(Stringizing Operator):
#操作符用于将宏参数转换为字符串常量。当在宏定义中使用#操作符时,它会将宏参数转换为一个以双引号括起来的字符串。 - ##操作符(Token-Pasting Operator):
##操作符用于将两个标记(tokens)连接成一个标记。标记可以是宏参数、宏定义中的其他标记或任何有效的标记。
需要说明的是:##连接操作符非常常用;#用的其实比较少,因为可以直接将字符串参数作为输入;
变长参数
C/C++的宏还支持变长参数
#define Assert_Log(condition, fmt, ...) \
printf(fmt, __VA_ARGS__) \
Assert(confition)
int main() {
int err_code;
Assert_Log(err_code == 0, "has a error %d", err_code)
}
上面有个例子,我们定义了一个宏,希望在不满足一定条件的情况下,打印出错误信息,并且命中断言。
其中具体的语法细节如下:
- 通过 … 来在宏中表示一个变长参数
__VA_ARGS__
是C/C++中的预处理器宏,用于表示可变数量的参数(Variable Arguments)。__VA_ARGS__
在宏展开时只代表传递给宏的可变参数部分,不包括宏定义中命名的其他参数
条件编译预处理指令
#ifdef、 #if、 #elif 、 #else 、#endif、defined()
#ifdef
用于检查一个宏是否已经被定义。它的语法是 #ifdef macro
,其中 macro
是要检查的宏的名称。
如果指定的宏已经被定义,则 #ifdef
后面的代码块将会被编译;如果指定的宏未定义,则该代码块将被忽略。
一个常见的例子是通过宏定义,来区分release和debug
//CmakeList.txt
add_definitions(-DDEBUG_MODE)
#include <stdio.h>
int main() {
#ifdef DEBUG_MODE
printf("Debug mode is enabled.\n");
#else
printf("Debug mode is disabled.\n");
#endif
#if defined(DEBUG_MODE) && defined(_WIN32_)
printf("Debug mode and OS is windows.\n");
#else
printf("waring...........\n");
#endif
// 其他代码...
return 0;
}
#if
预处理指令用于在编译时对常量表达式进行条件判断。它的条件比较原理如下:
- 预处理器会首先对
#if
后面的表达式进行求值。这个表达式必须是常量表达式,它由预处理器中已定义的宏、整型常量、枚举常量和一些基本运算符组成。 - 求值过程中,预处理器会将宏展开为它们的取值(如果已定义),并执行常量的计算。
- 求值结果为非零(真)时,条件为真,对应的代码块将被编译;求值结果为零(假)时,条件为假,对应的代码块将被忽略。
简单的说,这种#if、#elif可以进行对整数和枚举值进行比较,然后得到bool值,或者整数值不等于零的情况下结果都为真。
enum class color {
red,
blue,
black
}
#define value
#define value_0 0
#define value_1 10
#define value_2 -10
#define value_3 -1.5
#define value_4 "red"
#define value_5 color::red
int main() {
// 正确, 但是value_0对应的常量为0,包含的语句不会编译
#if value_0
int a = 0;
#endif
// 正确, value_1对于的常量为10 > 0,包含的语句会编译
#if value_1
int a = 0;
#endif
// 正确, value_1对于的常量为10 = 10,包含的语句不会编译
#if value_1 > 10
int a = 0;
#endif
// 正确, value_2对于的常量为-10不等于0,包含的语句会编译
#if value_2
int a = 0;
#endif
// 错误, value_3不是整型常量,编译报错
#if value_3
int a = 0;
#endif
// 错误, 宏在条件编译中不支持字符串,以及字符串比较
#if value_4 == "red"
int a = 0;
#endif
// 正确, 但是不会编译,不支持枚举作为常量来就行条件编译
#if value_5
int a = 0;
#endif
// 正确, 支持左边是常量,右边是枚举进行比较
#if value_0 == color::red
int a = 0;
#endif
// 正确, 但是不会编译,不支持该种比较方式
#if value_5 == color::red
int a = 0;
#endif
return 0;
}
简单的总结下,条件编译指令,只能对宏代表整形常量的情况下就行简单的比较,如果比较运算符的右值是枚举,会换算成整型常量来进行比较。
总结
在从语法方向简单的总结一下:
- 对于#define换行用\,#、##操作符以及变长参数的用法需要掌握;
- 对于条件编译,最重要的就一点只有宏定义代表整型常量才可以用于判断;
语法细节都看完之后,宏的优缺点很明显,先说优点:
- 宏的本质是在预处理阶段进行复杂的文本替换, 因此具有高度的灵活性,可以实现代码的通用性和重用性。
- 宏允许进行条件编译。使用条件编译指令,可以根据不同的条件在编译过程中选择性地包含或排除代码,从而实现跨平台开发或实现特定版本的功能。
- 在项目熟悉的情况下,通用宏可以很好的帮助开发,一眼就可以理解代码的意思,可以帮助大家在开发过程中理解其他同事开发的代码。
缺点和限制:
- 宏的调试困难。由于宏展开是在预处理阶段完成的,它们的调试通常比较困难。在调试过程中,无法直接查看宏展开后的代码,这可能增加调试代码的复杂性。
- 重名宏的问题,由于宏没有命名空间,特别是在大型项目中,当多个代码文件中定义了相同名称的宏时,可能会引发错误或不一致的行为。
总结到这里应该也差不多了,下一篇会讲一些自己工作中遇到的常见用法。
要是大家觉得写的还可以有所帮助的话,可以点点赞,收收藏