当前位置: 首页 > 科技观察

提高代码质量的利器:宏定义——从入门到放弃

时间:2023-03-16 20:21:01 科技观察

1.前言2.预处理器运行3.宏展开4.符号:#和##5.可变参数处理6.奇斯的宏奇思妙想7.总结1.前言我一直有这样的感受:当我在学习一个新领域的知识时,如果其中的某个知识点在刚接触的时候很难理解,很难理解,所以以后无论我花多长时间学习这个知识点,我都会一直认为这个知识点比较难,也就是说第一印象特别重要。比如C语言中的宏定义,就好像和我作对。我一直认为宏定义是C语言最难的部分,就像有些朋友总认为指针是C语言最难的部分一样。宏的本质是一个代码生成器,在预处理器的支持下实现代码的动态生成,具体的操作是通过条件编译和宏展开来实现的。我们首先在脑海中建立这样一个基本概念,然后通过实际的描述和代码来深入理解:如何控制宏定义。所以,今天我们就把宏定义的所有知识点进行总结和挖掘。我希望通过这篇文章,我可以摆脱这种心理障碍。看完这篇总结文章,相信你也能对宏定义有一个整体、整体的把握。二、预处理器的运行1、宏的作用环节:预处理编译C程序时,从源文件到最终的二进制可执行文件,要经过4个阶段:我们今天要讨论的是在第一个环节:预处理,由预处理器完成,包括以下四个任务:文件引入(#include);条件编译(#if..#elif..#endif);宏展开(macroexpansions);线路控制(linecontrol)。2.条件编译一般情况下,C语言文件中的每一行代码都需要编译,但有时为了程序代码的优化,希望只编译其中的一部分代码。在程序中加入条件,使编译器只编译满足条件的代码,丢弃不满足条件的代码。这是条件编译。简单来说:预处理器根据我们设置的条件动态地处理代码,将有效的代码输出到一个中间文件中,然后送到编译器进行编译。条件编译基本上在所有的项目代码中都会用到。例如,当需要考虑以下情况时,必须使用条件编译:需要将程序编译成不同平台的可执行程序;代码需要运行在同一平台的不同功能产品上;程序中有一些测试用的代码,如果不想污染产品级代码就需要屏蔽。这里举3个例子,在有关条件编译的代码中经常看到:例子一:用于区分C和C++代码在开源库中看到,主要目的是混合C和C++编程,具体来说:如果使用gcc编译,那么宏__cplusplus将不存在,extern"C"将被忽略;如果使用g++编译,那么宏__cplusplus存在,其中的extern"C"就会生效,编译后的函数名hello不会被g++编译器改写,所以可以被C代码调用;例2:使用区分不同平台#ifdefined(linux)||defined(__linux)||defined(__linux__)sleep(1000*1000);//调用linux平台下的库函数#elifdefined(WIN32)||defined(_WIN32)Sleep(1000*1000);//调用Windows平台下的库函数(首字母大写)#endif那么,这些linux,__linux,__linux__,WIN32,_WIN32是从哪里来的呢?我们可以认为编译目标平台(操作系统)已经为我们预先准备好了。例3:Windows平台下写动态库时,声明导出和导入函数dllexport)#else#defineLIBA_API__declspec(dllimport)#endif#endif#endif//函数声明LIBA_APIvoidhello();这段代码直接取自我之前在B站录制的一个小视频中的例子,当时主要是为了演示如何在Linux平台下使用make和cmake构建工具进行编译。后来朋友让我在Windows平台下使用make和cmake进行构建,于是写了上面的宏定义。使用MSVC编译动态库时,需要在编译选项(Makefle或CMakeLists.txt)中定义宏LIBA_API_EXPORTS,那么导出函数hello的第一个宏LIBA_API会被替换为:__declspec(dllexport),意思是出口业务;在编译应用程序时,如果使用了动态库,则需要包含动态库的头文件。这时候不需要在编译选项中定义宏LIBA_API_EXPORTS,那么hello函数开头的LIBA_API就会被__declspec(dllimport)代替,也就是import操作;还有一点:如果你用的是静态库,编译选项里不需要任何宏定义,那么宏LIBA_API就是空的。3.平台预定义的宏正如我们上面看到的,目标平台会为我们预定义一些宏,方便我们在程序中使用。除了上面操作系统相关的宏之外,还有一类在日志系统中被广泛使用的宏定义:FILE:当前源代码文件名;LINE:当前源代码行号;FUNCTION:当前执行的函数名;DATE:编译日期;TIME:编译时间;例如:printf("filename:%s,functionname=%s,currentline:%d\n",__FILE__,__FUNCTION__,__LINE__);3.宏展开所谓宏展开就是代码替换,这部分内容也是我要表达的主要内容。宏扩展的最大好处如下:减少重复代码;完成一些无法通过C语法实现的功能(字符串拼接);动态定义数据类型,实现类似于C++中模板的功能;程序更容易理解和修改(例如:数字、字符串常开);我们在写代码的时候,凡是用到宏名的地方都可以理解为占位符。在编译器的预处理环节,这些宏名会被宏定义中的那些代码段替换。注意:这只是一个简单的文本替换。1.最常用的宏为了下面的描述方便,先来看一些常用的宏定义:(1)数据类型定义#ifndefBOOLtypedefcharBOOL;#endif#ifndefTRUE#defineTRUE#endif#ifndefFALSE#defineFALSE#endif定义在数据类型,需要注意的一点是:如果你的程序需要用不同平台的编译器编译,那么你需要检查这些宏定义所控制的数据类型是否已经被你使用的编译器定义。例如:在gcc中没有BOOL类型,但是在MSVC中,BOOL类型定义为int类型。(2)获取最大值和最小值#defineMAX(a,b)(((a)>(b))?(a):(b))#defineMIN(a,b)(((a)<(b))?(a):(b))(3)计算数组的元素个数#defineARRAY_SIZE(x)(sizeof(x)/sizeof((x)[0]))(4)位操作#defineBIT_MASK(x)(1<<(x))#defineBIT_GET(x,y)(((x)>>(y))&0x01u)#defineBIT_SET(x,y)((x)|(1<<(y)))#defineBIT_CLR(x,y)((x)&(~(1<<(y))))#defineBIT_INVERT(x,y)((x)^(1<<(y)))2。区别从上面的宏来看,这些操作都可以用函数来实现,那么它们的优缺点是什么?函数实现:需要判断形参的类型,调用时检查参数;调用Functions需要额外的开销:操作函数栈中的形参、返回值等;通过宏实现:无需校验参数,参数传递更灵活;直接展开宏的代码,执行时不需要调用函数;如果在多个地方调用同一个宏,会增加代码量;最好举个例子来说明,我们以上面的比较大小为例:(1)用宏实现#defineMAX(a,b)(((a)>(b))?(a):(b))intmain(){printf("max:%d\n",MAX(1,2));}(2)用函数实现intmax(inta,intb){if(a>b)returna;returnb;}intmain(){printf("max:%d\n",max(1,2));}除了函数调用的开销外,似乎没有什么区别。这里比较的是2个整型数据,如果需要比较2个浮点型数据怎么办?使用宏调用:MAX(1.1,2.2);一切都好;使用函数调用:max(1.1,2.2);编译错误:类型不匹配。这时候使用宏的优势就体现出来了:因为宏中没有类型的概念,调用者可以传入任何数据类型,然后在后面的比较操作中,大于或者小于操作都是使用C语言用自己的语法来执行。如果用函数实现,那么就需要定义一个操作浮点类型的函数,以后可以比较:char类型,long类型数据等,在C++中,可以实现这样的操作通过参数模板,这也是一种动态代码生成机制。当定义一个函数模板时,会根据调用者的实际参数动态生成多个函数。例如定义如下函数模板:templateTmax(Ta,Tb){if(a>b)return;returnb;}max(1,2);//实参为整数max(1.1,2,2);//实参为浮点数。当编译器看到max(1,2)时,会动态生成一个函数intmax(inta,intb){...};当编译器看到max(1.1,2.2)时,将动态生成另一个函数floatmax(floata,floatb){...}。因此,从动态代码生成的角度来看,宏定义有点类似于C++中的模板参数,只不过宏定义只是代码扩展而已。下面这个例子也挺好的,使用类型无关的宏动态生成结构:#defineVEC(T)\structvector_##T{\T*data;\size_tsize;\};intmain(){VEC(int)vec_1={.data=NULL,.size=0};VEC(float)vec_2={.data=NULL,.size=0};}本例中使用了##,下面将对这个知识点进行说明。在前面的例子中,宏的参数传递的是一些变量,这里传递的宏参数是数据类型。通过宏的类型独立性,达到“动态”创建结构的目的:structvector_int{int*data;size_tsize;}structvector_float{float*data;size_tsize;}这里有一个陷阱需要注意:传递的数据类型不能有空格,如果你这样使用:VEC(longlong),那么替换后你get:structvector_longlong{//语法错误longlong*data;size_tsize;}4.符号:#和##这两个符号在编程中的作用也很巧妙。夸张地说,在任何框架代码中都可以看到它们!函数如下:#:将参数转换为字符串;##:连接参数。1.#:字符串化直接看最简单的例子:#defineSTR(x)#xprintf("stringof123:%s\n",STR(123));输入是一个数字123,输出结果是一个字符串“123”,这就是字符串化。2.##:参数连接宏中的参数根据字符拼接得到一个新的标识符,例如:#defineMAKE_VAR(name,no)name##nointmain(void){intMAKE_VAR(a,1)=1;intMAKE_VAR(b,2)=2;printf("a1=%d\n",a1);printf("b2=%d\n",b2);return0;}当调用宏MAKE_VAR(a,1)最后,符号##将两边的name和no分别替换为a和1,再连接得到a1。然后call语句前面的int数据类型表明a1是整型数据,最后初始化为1。五、可变参数的处理1.宏定义的参数名和参数个数可以不确定,就像调用printf打印函数一样。定义时,可以用三个点(...)来表示可变参数,也可以在三个点前面加上可变参数的名称。如果使用三个点(...)来接收可变参数,那么就需要用VA_ARGS来表示可变参数,如下:#definedebug1(...)printf(__VA_ARGS__)debug1("thisisdebug1:%d\n",1);如果在三个点(...)前面加了一个参数名,那么在使用的时候就必须使用这个参数名,而不是用VA_ARGS来表示可变参数,如下:#definedebug2(args...)printf(args)debug1("thisisdebug2:%d\n",2);2.看看可变参数个数为零时的这个宏:#definedebug3(format,...)printf(format,__VA_ARGS__)debug3("thisisdebug4:%d\n",4);编译和执行都没有问题。但是如果你像这样使用宏:debug3("hello\n");编译时会报错:error:expectedexpressionbefore')'token。为什么?看宏展开后的代码(__VA_ARGS__为空):printf("hello\n",);你看到问题了吗?格式字符串后面多了一个逗号!为了解决这个问题,预处理器为我们提供了一个方法:通过##符号自动删除多余的逗号。于是宏定义改成下面的就没有问题了。#definedebug3(format,...)printf(format,##__VA_ARGS__)类似,如果定义变量参数名,在前面加上##,如下:#definedebug4(format,args...)printf(format,##args)6.精彩的宏宏扩展的本质是文本替换,但是一旦加上可变参数(__VA_ARGS__)和##的连接功能,就可以变化无限想象。我一直坚信,模仿是成为大师的第一步。只有见多识广,多看,多学别人怎么用宏,然后为自己所用,按照“先加固——再优化——最后固化”这个步骤去训练,总有一天你可以成为一个大师。在这里,我们看一些使用宏定义的巧妙实现。1.日志功能在代码中加入日志功能几乎是每个产品的标准配置。通常,最常见的用法如下:#ifdefDEBUG#defineLOG(...)printf(__VA_ARGS__)#else#defineLOG(...)#endifintmain(){LOG("name=%s,age=%d\n","zhangsan",20);return0;}编译时,如果需要输出日志功能,传入宏DefineDEBUG,这样就可以打印调试信息了。当然,在实际产品中需要写入文件。如果不需要打印语句,可以通过将打印日志信息的语句定义为空语句来达到目的。换个思路,我们也可以通过条件判断语句来控制打印信息,如下:#ifdefDEBUG#definedebugif(1)#else#definedebugif(0)#endifintmain(){debug{printf("name=%s,age=%d\n","zhangsan",20);}return0;}这种方式控制日志信息的人不多,但也能达到目的。这里只是为了拓宽你的思路。2.使用宏遍历每个参数#definefirst(x,...)#x#definerest(x,...)#__VA_ARGS__#definedestructive(...)\do{\printf("firstis:%s\n",first(__VA_ARGS__));\printf("restare:%s\n",rest(__VA_ARGS__));\}while(0)intmain(void){破坏性(1,2,3);return0;}主要思想是:每次把变量参数VA_ARGS中的第一个参数分开,然后递归处理后面的参数,这样就可以把每个参数都分开了。记得侯捷老师在C++视频中也用可变参数模板的语法实现了类似的功能。刚才在有道笔记里找到了侯捷老师演示的代码。熟悉C++的可以研究一下下面的代码://最后一次递归调用voidmyprint(){}templatevoidmyprint(constT&first,constTypes&...args){std::cout<