玩转宏定义——从入门到进阶
宏定义是什么
宏定义(macro definition)是 C/C++ 中的一种预处理指令,可以在编译之前替换源代码中的一些文本。简单来说就是用宏自定义了一些其它符号,这些符号在使用时全等于被替换的内容。
#define DATE "2023_01_20"
#define FILE_NUM 250
上面两个例子中表现的就是宏定义的基本格式 #define+若干空格+自定义符号+若干空格+被替换内容,DATE在代码的任何部分都可以直接当做"2023_01_20"这段字符串使用,同理FILE_NUM也可以直接用来当做250。不过这种替换是简单粗暴,不带任何修饰的,这种特性也带来一定的问题,在下面用好宏定义板块会提到这些问题,并教给你如何避免这种问题。
#define WORKING_DIE “/home/lcc/linux/nfs/rootfs/lib/modules/4.1.15/all_flile/c_text/for_text/”
我们有时候会宏定义一些比较长的数据,像上面这样,这样会显得代码看起来特别的臃肿,可以使用\
(续行符) 将宏定义的内容分割开,当然分割前后的宏替换内容是一致的。
#define WORKING_DIE “/home/lcc/linux/nfs/rootfs/lib/\
modules/4.1.15/all_flile/c_text/for_text/”
通过使用 \,可以让代码看起来更加的整洁,提高了代码的可读性,但是在使用时,一定不能让 \
右侧出现除了换行符以外的任何字符,空格也不可以,否则会导致错误出现。
用好宏定义
虽然说宏定义看起来很简单,不过合理的使用会给编程带来极大的便利,能提高程序的可读性和可维护性,而且通过与函数等的结合也具有很大的灵活性。接下来主要从常量替换、整体、集合体几个方面来谈谈宏定义的应用,以及这些用法可能带来的问题及解决方法,当然宏的很多应用在C语言中都有替代的方案,这些方案在不同情况下使用会有优劣之分,明确了这些,在某一场景下做出正确的选择,我们才能算真正意义上掌握了宏。讲完这些,希望各位保持好奇心,坐稳了,开始发车!
常量替换
int a = 2;
float b = 3;
if(4 > 5)
#define MAX_LEN 22
代码中能被我们直接观察到的数据就是常量,所以常量又被称为字面量,而且常量是在程序执行期间都不会发生改变的值。以上代码块中以数字形式出现的都是常量,它们在程序运行开始时会被加载入内存中的常量区里,块中的第四行就是通过宏实现对常量的替换。
int a = 22;
int a = MAX_LEN;
以上两行代码的效果是等效的,都实现了对a赋值22。这时有人可能会问了,不就赋个值嘛,为啥搞得这么麻烦。诶,你还别说,宏替换用到好处不仅不会使代码显得冗杂,还会提高代码的可读性,有利于程序的维护和开发, 不信,咱接着看。
常量替换的作用
- 赋予数据意义
在刚开始接触编程的时候,我们是为了学习编程而编程。这个阶段的编程脱离现实,或者说是对某些现实的抽象,我们们仅仅是重复性的使用编程规则已达到熟悉编程规则的目的,很少会根据具体的现实情景进行编程。
在深入学习编程之后,我们编程的目的从学习编程本身变成了通过编程来解决现实问题。解决现实问题的过程中,就需要对一些事物的属性进行抽象成数据,而有些数据总是不变的,我们这时候就可以以宏定义的方式对这些常量数据进行命名,来使代码更加的清晰、有条理。
#define MON_DAY 1
#define TUES_DAY 2
#define WEDNES_DAY 3
#define THURS_DAY 4
#define FRI_DAY 5
#define SATUR_DAY 6
#define SUN_DAY 7
在某些情景中,需要用到每天的日期信息,如果直接使用1、2、3…来表示会另阅读代码的其他人头疼不已,就连我们自己几个月后检查代码时也可能会忍不住飙几句脏话,如果使用宏定义则会明朗许多。
总之,前期的学习我们很少会遇到赋予常量意义的情况,这时候我们也不用担心,在后期面临现实情景的时候我们再在去认真思考也不迟,不过现实情况总是千遍万化,难有一个通法,需要实际问题,实际处理,上面用星期的举例也就当是抛砖引玉了,如何灵活、恰当的使用宏定义赋予数据意义,需要在阅读他人优秀代码与自己的实践中慢慢体会。
- 替换重复出现的固定常量
#define PI 3.14159
double r = 3;
double area = PI * r * r;
double perimeter = 2 * PI * r;
在上面这个例子中,圆周率是重复出现的,通过宏定义进行替换可以提高代码的可维护性,因为宏定义可以方便地修改常量的值,而不需要在多个地方进行修改。
- 替换目前不能确定或未来有可能改变的数值
#define MAX_LEN 20
char buf[MAX_LEN];
我以前在编程时,会习惯性的凭感觉设置数组大小,但是现实总是啪啪打脸,代码编译时没有问题,一运行段错误就出现了,问题是代码越栈了。如果码量小一点还好,一旦码量稍大,排查起来是真的痛苦。我们可以用宏来限定灵活限定数组大小,减少这类问题给编程带来的痛苦体验。
对于这类问题需要替换的仅仅是目前不能确定大小的数组,有的数组大小我们完全在编程时就能够明确,就完全没有替换的必要,就害怕有些小伙伴看到这么已用好像高大上的样子,不管三七二十一,盲目的对代码进行替换,需要记住我们使用的任何方法与技巧都是为了写出更优秀、更高质量的代码,而不是所谓花哨与高大上。
当然以上虽说是用数组进行举例,不过不能仅仅拘泥于数组,更多场景需要在编程时根据具体情况去发现、去处理,但是万变不离其宗是它们都有一个共同的特性——数值目前不能确定。
#define IP_DEER "192.168.1.100"
编程时有些数据在当下是确认的,但在未来也可能会被修改,这种改变的原因并不来源于错误,而可能伴随着代码需求的改变。前几天在进行网络编程时,需要确定被连接一端的IP地址,但是在刚开始编写时肯定是用自己周边的触手可及的一些IP地址来测试程序,而不是一上来就用最终实现的IP地址,这样做是为了前期方便编写以及排查程序问题。在这个过程中前期使用"192.169.1.100"
的目的是为了方便调试代码,后面将IP_DEER
修改为192.168.1.50
才算是整个程序的完工。
常量替换需谨慎
常量宏定义出现问题的原因并不来自于宏,而是来自常量本身不规范的使用。在 if(-1 > 2)这种简单的判断中,-1与2都是具有数据类型的常量,很多时候我们都会忽略-1与2本身的数据类型,在这个例子中两个常量被系统默认为int数据类型,因此我们得到了正确的判断结果,不过总有例外存在。当数据变成if(-1 > 2147483649)时,2147483649默认为long long型,而-1默认依旧为int型,这时候因为运算数据的类型不匹配,会导致导致编译不能通过,还有些编译器比较傻,虽然能编译通过,但是其内在隐患并没有解决掉。
以上是在常量使用中比较显式的一类问题,另一类问题比较隐式,是在不同数据类型间的赋值中可能产生的。当一个int类型常量给long long进行赋值,可以得到正确的结果,而当以上的赋值顺序交换,就有可能造成数据被截断。由于数据复制过程中得到的的结果有可能是对的,所以这种问题往往被人忽略。
总之,一般由程序员主动定义的变量在使用过程中都会留意,不过当数据是通过宏定义出现在式子中,就要谨慎了,因为一种数据的表达形式可能有不止一种的含义,比如说1可以是int型,也可以是long long,因此在编译的过程中,系统本身对数据类型的默认选择并不一定符合程序员的本意,也就导致了代码运行过程产生了歧意。其它的一些数据类型的宏替换,比如字符,字符串就没有类似的问题,对它们来说,一种表现形式往往有且只有一种意义。
对于这种由于宏定义导致的数据产生的歧意,可以通过在宏定义过程中添加后缀来解决。经过对宏添加后缀,我们可以对宏定义的常量数据类型进行限定,而不是由系统对数据类型进行控制,从而降低代码的相关风险。
#define CECOND_PER_YEAR (60*60*24*365UL)
上面这个例子中如果不加后缀而是以(60*60*24*365)
来表示,会产生数据截断,加上了UL后,该数据的存储方式会以无符号整型来存储,在对常量进行宏定义时要有加上后缀的意识,很多时候程序出现BUG都是因为编写者日常没有养成良好的编程习惯带来的,下面是数据类型与后缀的对应表项。
F(f) | float(浮点) |
U(u) | unsigned int(无符号整型) |
L (l) | signed long(符号长整型) |
LL(ll) | signed long long(符号长长整型) |
UL(ul) | unsigned int(无符号整型) |
ULL(ull) | unsigned long long(无符号长长整型) |
替换方案
小小的常量替换,大大的编程作用。不过在编程替换中只有宏定义一家独大吗?答案是否定的,除了宏定义还有const
关键字修饰的变量与 enum
可以担此大任。与其把被const修饰的变量称做常量,或许只读的变量才更符合它的真实情况,但是最终达成的作用却是类似的,都可以看成常量替换。相对而言,const 本身就具有类型检测功能,因为在定义时,我们必须给const 修饰的常量指定类型,这就避免了使用宏定义常量而存在的潜在问题,不过编者在平时编程中对于常量定义依旧是以宏定义为主,因为宏定义看起来更有美感,可怜的强迫症患者就是我了。
整体
什么是整体呢?一把伞由伞柄、伞骨和伞面组成。其中伞柄是握住伞的部分;伞骨是支撑伞面的部分;伞面是遮雨的部分,这几个部分在挡雨时缺一不可,如果缺少某个部分则就失去了伞的功能,就不能称之为整体。我理解的编程整体也是这样,它的功能具有单一性与唯一性,该整体不能有缺少,也不能画蛇添足,通过宏定义可以帮助我们封装一个编程整体。
一个宏定义的整体可以分为简单宏整体,复合宏整体两类。简单宏整体就是利用一些运算符结合起来的宏整体,比如下面这个比较数字大小的宏定义
#define MAX(x, y) ((x)>(y)?(x):(y))
当然这类宏整体并不都是这么短,下面是一个遍历数组的宏定义
#define FOREACH(item, array) \ // 定义一个遍历数组的宏
for(int keep=1, \
count=0, \
size=sizeof (array)/sizeof *(array); \
keep && count != size; \
keep = !keep, count++) \
for(item = (array)+count; keep; keep = !keep)
了解了简单宏定义后再来看一下复合宏整体,不过为什么称之为复合宏定义呢?所谓复合就是宏定义内不仅包含了一些运算符这些,还有了函数的参与
#define ECHO(s) (get(s), put(s))
ECHO(str);
以上这个例子中,用宏将get()
与put()
包裹起来,实现输入输出的一条龙服务,通过将宏定义用于函数的结合,使我们的操作更加灵活,也一定程度提高了代码的可读性。
在上面对两种宏整体的讲解例子中,都不同程度在宏定义中使用了参数,不过宏定义中的参数也可以不是固定的,这类宏定义被称为参数可变宏,它可以根据不同情况传递不同类型和数量的参数。参数可变宏的定义方法是在宏定义后面的参数列表中的最后一个参数为省略号(…)
,表示可以接受任意个数和类型的参数。例如:
#define PRINTF(...) printf(__VA_ARGS__) // 定义一个可以接受任意个数和类型的参数的宏
在使用参数可变宏时,需要用一个特殊的标识符 __VA_ARGS__
来表示所有传递给宏的可变参数。
PRINTF("Hello, world!\n"); // 调用宏,相当于printf("Hello, world!\n");
PRINTF("The answer is %d\n", 42); // 调用宏,相当于printf("The answer is %d\n", 42);
注意什么
随着我们宏定义的对象从简单的常量到相对复杂的整体,宏定义本身也从无参宏定义过渡到有参宏定义,但是由于宏定义仅仅是在程序预编译阶段暴力的直接展开,当我们写入带参宏定义的内容不只是一个简单数字而是一段表达式就有可能会出现歧义与错误。比如我们定义了一个计算平方的宏:
#define SQUARE(x) x * x
当使用该宏时,如果我们直接使用SQUARE (a + b)
,这个式子最后会被展开为a + b * a + b
而不是我们期望的(a + b) * (a + b)
,所以为了保证带参宏定义结果的正确性,我们应该像下面这样对被定义主体内的参数带上()
,以保证宏定义的正确结果
#define SQUARE(x) (x) * (x)
不过仅仅给内部的参数添加()
也并不能保证万事俱备,比如以下这个例子:
#define ADD(x) (x)+(x)
在使用该宏被用到ADD(x)*10
此式时,该式子会被展开为(x)+(x)*10
,而不是我们预想的((x)+(x))*10
,因此我们需要进一步的修改
#define ADD(x) ((x)+(x))
只有像上面这样,不仅给宏体中的每个参数和整个表达式都带上参数,才能保证计算次序的正确性,这两层括号也有另一个名字——宏定义完备括号。
替代方案
经过前面这么多的叙述,有些小伙伴可能已经意识到了这里提出来的整体的概念不就是函数吗?其实开始我也准备这么理解,但是宏就是宏,函数就是函数,总不能看到宏的这类用法就把宏归纳到函数的范畴吧,我们需要一个更加抽象的认识来统一这类用法,于是我就用了整体这个概念。既然这块内容讲的是替换方案,那我们另一个主角都不需要隆重介绍了,他就是 —— 函数。这时候问题就来了,宏定义能完全代替函数吗?或者说函数能完全代替宏定义吗?宏与函数虽然在某些共同之处,但是在一些方面也存在差异。
- 函数的调用不同于宏定义,它需要出栈与入栈的确操作,这些额外的开销会降低程序的执行效率,宏定义则是直接执行,但是宏定义的每处展开都会多一份内存空间的申请,不像函数那样一个程序只占用一个代码块。
- 含参宏定义在使用时,我们并没有像函数的参数那样指定具体类型,这给我们编程者带来一定便利,不过有时候这种无类型参数会带来一定隐患。
- 由于函数名就是一个指针,而没有指向宏定义的指针,因此宏无法得到指针带来的便利。
总之,函数与宏定义在作为整体出现在编程中时,各有其优势所在,在具体的编程环境中并没有什么最好之说,只有最适合的。
集合体
当一个集合有了专一的功能,我们称之为整体,而在编程中有些部分集合由于不具备这种专一性并不能称之为整体,却由于其较高的重复度而不得不封装起来,我们将这类组合称为集合体。
#define ERROR(m) \
do{ \
perror(m); \
tfer(); \
}while(0)
以上代码是我写的某个项目的一段,在每次处理完错误后都有这么一段重复内容,但是这部分代码前那部分与错误处理相关的内容并不总是相同,因此不能作为一个整体来看待,我只需要对这部分内容进行复用。这个集合体是用do{}while
封装的,有些小伙伴可能觉得直接用{}
也不错,但是使用后者有时会因为疏忽出现问题。
我们在编程语句的结尾会习惯性的加上;
,但在使用if else
语句时如果遇上被{}
封装的宏定义问题就显现出来了,比如下面的例子:
#define ERROR(m) \
{ \
perror(m); \
tfer(); \
}
if(echo_flag)
ERROR(echo_flag);
else
gets(str);
这个语句乍一看没有什么问题,但是把它展开会发现在else
前的;
会导致无法错误。
#define ERROR(m) \
{ \
perror(m); \
tfer(); \
}
if(echo_flag)
{
perror(echo_flag);
tfer();
};
else
gets(str);
而使用do{}while(0)
来包装就不会出现这种错误了。
if(echo_flag)
do{
perror(echo_flag);
tfer();
}while(0);
else
gets(str);
我们程序员在一句代码的结尾会习惯性加上;
,用do{}while(0)
进行封装结尾必须加上 ;
否则会报错,而{}
后则是可加可不加,然而有时不小心加上后会出现以上的问题。总之,{}
不是不能用,而是可能因为疏忽出现问题,而且由于一些编程习惯会让人用的很难受,所以这里还是建议使用do{}while(0)
。
以上三大块是我这篇文章的主要内容与总结,但是我这里还想给各位加一些饭后小甜点,宏定义的内容就是只是替换,但是#
与##
在宏定义中的妙用却被很多人疏忽了。
'#'的用法
宏定义中#
的作用是把其后面的变量转化为字符串。例如,如果定义了一个宏:
#define STR(s) #s
那么当使用这个宏定义时,RTR(hello)
会被替换为"hello"
,这样做可以更加方便的输出或处理字符串。
"##"的用法
宏定义中##
的作用是将其前后的两个变量无缝拼接在一起,并当做一个变量名使用。例如,我定义了这么一个宏:
#define NAME(n) num##n
当我使用这个宏时,就可以把它当做一个变量名来使用,在这里NAME(0)
会被替换为num0
,
int num1;
NAME(1) = 9;
num1 = 9;
在这个例子中这两条赋值语句是等效的,通过宏定义配合##
这种用法,可以方便的定义和使用一组相关的变量,提高编程代码的灵活性。
以上几乎就是宏定义从入门到进阶的全部内容了,写这篇文章的的起源是一次项目实践的总结,而选择以这种方式来呈现宏定义则是日常我对与编程知识总结的方法论而来的。
在刚开始学习宏定义时,我查过不少有关博客,但是这些博客有些要么集中讲宏定义的某个方面,对于有些复习的老手来说这不会有什么问题,但是对于新手而言,容易使他们形成对宏定义以偏概全的认识。另一方面很多博客总是简单粗暴的把宏定义分成带参数与不带参数,这样虽然让人容易回忆起,但是无论是函数还是宏定义,我们的目的都应当是以使用为导向的,在合适的时候用合适的方法,前者的简单分类并不能将使用者引导入合适的实践中去,没有深入实践的使用最终只是空中楼阁,只知道有这个东西,但是却总也用不上,总也用不好。这也是这篇文章最后给各位的一些思路,用合适分类方法,以合理的角度去理解技术工具,希望各位有所收获。