C/C++ 预处理器(宏)上篇--原理与基本用法

写在开篇

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;
}

上面举了三个例子:

  1. 没有输入参数, 会直接将定义的文本原样替换;
  2. 有输入参数, 在相同位置的参数会按照输入就行替换;
  3. 编译器不会在乎你定义的宏的含义,只会简单的进行文字替换;就比如sum_errorsum,很明显你在定义的时候不加括号,替换后就出现了不符合预期的错误;

这里面我先对宏进行一个简单的分类:常量宏、函数宏、编译宏。说明这个只是我自己为了方便记忆的区分。

下面举一些例子:

  1. 常量宏,这个宏就是代表一个常量值,非常常见的例子就是定义一个圆周率。
#define PI 3.1415926
  1. 函数宏:我对函数宏定义就是有参数的就是函数宏。
#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
  1. 编译宏: 编译器(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++的宏定义中,#和##是两个不同的操作符,用于宏参数的处理和宏的拼接。

  1. #操作符(Stringizing Operator):
    #操作符用于将宏参数转换为字符串常量。当在宏定义中使用#操作符时,它会将宏参数转换为一个以双引号括起来的字符串。
  2. ##操作符(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)
}

上面有个例子,我们定义了一个宏,希望在不满足一定条件的情况下,打印出错误信息,并且命中断言。

其中具体的语法细节如下:

  1. 通过 来在宏中表示一个变长参数
  2. __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预处理指令用于在编译时对常量表达式进行条件判断。它的条件比较原理如下:

  1. 预处理器会首先对 #if 后面的表达式进行求值。这个表达式必须是常量表达式,它由预处理器中已定义的宏、整型常量、枚举常量和一些基本运算符组成。
  2. 求值过程中,预处理器会将宏展开为它们的取值(如果已定义),并执行常量的计算。
  3. 求值结果为非零(真)时,条件为真,对应的代码块将被编译;求值结果为零(假)时,条件为假,对应的代码块将被忽略。

简单的说,这种#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;
}

简单的总结下,条件编译指令,只能对宏代表整形常量的情况下就行简单的比较,如果比较运算符的右值是枚举,会换算成整型常量来进行比较。

总结

在从语法方向简单的总结一下:

  1. 对于#define换行用\,#、##操作符以及变长参数的用法需要掌握;
  2. 对于条件编译,最重要的就一点只有宏定义代表整型常量才可以用于判断;

语法细节都看完之后,宏的优缺点很明显,先说优点:

  1. 宏的本质是在预处理阶段进行复杂的文本替换, 因此具有高度的灵活性,可以实现代码的通用性和重用性。
  2. 宏允许进行条件编译。使用条件编译指令,可以根据不同的条件在编译过程中选择性地包含或排除代码,从而实现跨平台开发或实现特定版本的功能。
  3. 在项目熟悉的情况下,通用宏可以很好的帮助开发,一眼就可以理解代码的意思,可以帮助大家在开发过程中理解其他同事开发的代码。

缺点和限制:

  1. 宏的调试困难。由于宏展开是在预处理阶段完成的,它们的调试通常比较困难。在调试过程中,无法直接查看宏展开后的代码,这可能增加调试代码的复杂性。
  2. 重名宏的问题,由于宏没有命名空间,特别是在大型项目中,当多个代码文件中定义了相同名称的宏时,可能会引发错误或不一致的行为。

总结到这里应该也差不多了,下一篇会讲一些自己工作中遇到的常见用法。

在这里插入图片描述
要是大家觉得写的还可以有所帮助的话,可以点点赞,收收藏

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值