对于宏有很多花里胡哨的用法,虽然C++已经不推荐使用复杂的宏,但是也要看得懂,因此整理一下。
基本概念 预处理器是一个正式编译C语言的源代码之前的文本处理工具,它负责执行预处理指令(#开头的指令),通常包括头文件包含,条件编译,宏等。
宏是预处理器支持的一种重要功能,允许程序员定义一些简单的代码替换规则:通过宏创建符号常量或者简单的代码片段,并在代码中多次使用。 这些宏会在编译前被预处理器替换为相应的内容。值得注意的是,预处理器只是文本处理工具,它不会分析任何语法层面的内容,行为完全是文本层面的。
预处理器支持的命令主要包括
文件包含:#include 用于在源文件中包含其他文件的内容
条件编译:#if、#ifdef、#ifndef、#elif、#else、#endif 用于条件编译,根据条件决定编译部分代码
宏定义:#define 用于创建宏,可以是简单的文本替换或带参数的宏
取消宏定义:#undef 用于取消已定义的宏
除此之外,还有几个不常见的命令:
错误指示:#error 用于在预处理阶段生成一个错误消息,编译终止,通常是用在条件编译中终止某个错误情形。警告指示#warning是非标准的:MSVC不支持,而GCC/Clang支持
行控制:#line 用于修改行号和文件名信息,很少使用
特殊命令:#pragma 用于向编译器发出特定命令或指示的预处理器指令,这些指令通常与编译器和平台密切相关,并不通用。一个最常见的特殊指令是#pragma once,它被广泛支持,用于防止重复包含头文件
文件包含 包含头文件是预处理器的重要功能,#include会直接(递归地)拷贝指定文件到当前位置。
1 2 3 4 5 6 #include <stdio.h> int main (void ) { printf ("Hello, world!\n" ); return 0 ; }
关于头文件包含有一个重要的需求是避免头文件重复包含,通常有两类解决办法, 例如在头文件func.h中,可以有两种做法:第一种做法是基于#ifdef的
1 2 3 4 5 6 #ifndef FUNC_H_ #define FUNC_H_ ... #endif
我们使用FUNC_H_这个宏来标记当前是否导入了当前的头文件,如果已经导入了则不会再次导入。第二种做法是基于#pragma的,此时的不重复导入由编译工具链直接保证
两种做法各有利弊:
第一种做法保证的是内容不会重复导入;第二种做法保证的是当前文件不会重复导入,但是如果在不同位置有完全重复的文件,则无法保证安全
第一种做法的兼容性更好;第二种做法主流的编译工具链都支持,但是早期的或者冷门编译器可能不支持
第一种做法可能出现重名或者手误打错了宏的名称,从而导致问题;第二种做法则更加简单直观
条件编译 我们可以使用条件语句来控制使用不同的编译内容,通过开启或关闭不同的宏定义来触发,基本的语句包括
#ifdef:如果某个宏被定义,则编译下面的代码,否则删去
#ifndef:如果某个宏未被定义,则编译下面的代码,否则删去
#else:在条件不满足时编译其后的代码块
#endif:结束条件编译块,不可以省略
完整结构例如
1 2 3 4 5 #ifdef DEMO ... #else ... #endif
再例如
1 2 3 4 5 #ifndef DEMO ... #else ... #endif
其中的#else部分可以整体省略,但是结束语句#endif不可以省略,例如
1 2 3 4 5 6 7 #ifdef DEMO ... #endif #ifndef DEMO ... #endif
除了基于宏定义,还可以支持一般表达式触发的条件编译,有如下的几个命令:
#if:根据给定的条件表达式进行编译
#elif:用于在多个条件之间进行选择编译
这里的表达式可以是宏或者常量表达式,并且支持简单的运算,例如
1 2 3 4 5 6 7 8 9 #if VERSION == 2 ... #endif #if COUNT > 5 ... #elif COUNT == 2 ... #endif
使用#if defined(x)则可以和前面的#ifdef x作用相当,例如
1 2 3 4 5 6 7 8 9 #if defined(A) ... #endif #if defined(A) ... #elif defined(B) ... #endif
条件编译可以使用更复杂的运算:支持使用逻辑与 (&&)、逻辑或 (||)、逻辑非 (!) 进行组合判断,支持使用括号明确优先级,例如
1 2 3 4 5 6 7 #if defined(DEBUG) && (VERSION == 2) ... #endif #if !defined(A) ... #endif
一个很复杂的实例如下
1 2 3 4 5 6 7 #if (!defined __STRICT_ANSI__ && !defined _ISOC99_SOURCE && \ !defined _POSIX_SOURCE && !defined _POSIX_C_SOURCE && \ !defined _XOPEN_SOURCE && !defined _BSD_SOURCE && \ !defined _SVID_SOURCE) # define _BSD_SOURCE 1 # define _SVID_SOURCE 1 #endif
典型的应用是判断当前平台和编译器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #ifdef _WIN32 #elif __linux__ #else #error unsupported platform #endif #if defined(__clang__) #elif defined(__GNUC__) || defined(__GNUG__) #elif defined(_MSC_VER) #else #error unsupported compiler #endif
注意这里先判断__clang__,因为clang的兼容性过强,可能将自身伪装为其它编译器,补充定义了其它编译工具链所使用的宏。 (这也是因为无论在哪一个平台上,clang都只能算二等公民,只能主动兼容)
注意#ifdef XXX和#if XXX的效果是完全不同的:
#ifdef XXX判断当前XXX宏是否被定义,不关心定义的值,值为0一样会触发;
#if XXX不仅要求XXX被定义,还要求XXX的值为真,例如值为1可以触发,值为0不会触发。
宏定义 关于宏名有以下要求:
以字母或下划线开头:宏名可以以字母(A-Z、a-z)或下划线(_)开头,不能以数字开头。
包含字母、数字和下划线:在开头之后,宏名可以包含字母、数字和下划线的组合,不含空格。
不能是关键字:宏名不能是C或C++中的关键字或保留字,如 if、else、while 等。
大小写:宏名区分大小写,例如,MY_MACRO和my_macro是两个不同的宏。
在处理宏定义之前,注释已经被抹除了,因此不需要考虑行尾注释的问题。
类对象宏 宏可以发挥类似一个对象的功能,称为类对象宏(object-like macro),此时没有参数,宏的作用就是将宏名进行简单的字符串替换,例如
1 2 3 #define VERSION 2 #define NUM 50 #define PI 3.1415926
习惯上用来定义一个常量或者类型,对于类型而言,它可以发挥和typedef类似的效果
1 2 3 4 5 #define INT_STAR int * typedef int * int_star;INT_STAR a; int_star b;
但是注意这两者并不是等价的,经典反例如下
1 2 3 INT_STAR a,b; int_star c,d;
此时通过宏定义的类型并不能影响多个变量,b只是int类型的变量而非指针,其它三个变量均为指针。
类函数宏 宏也可以加上参数,发挥类似一个函数的功能,称为类函数宏(function-like macro),
1 2 3 #define ADD(x,y) x+y int a = ADD(1 ,2 )
注意,这里参数列表的括号和前面的名称不能有空格,否则会识别错误
1 2 3 #define ADD (x,y) x+y int a = ADD(1 ,2 )
为了安全,尽量用括号把整个替换文本及其中的每个参数括起来,否则会引发错误,例如
1 2 3 4 5 #define FUNC1(x,y) x+y #define FUNC2(x,y) ((x)+(y)) int a = FUNC1(1 +2 ,3 ); int b = FUNC2(1 +2 ,3 );
即便如此也可能存在问题,例如
1 2 3 4 #define FUNC(x,y) ((x)+(y)) int s = 6 ;int sum = FUNC(++s);
此时会出现两次自增,与期望不一致,为了避免这种问题,要求对宏的参数自身不要进行++,-之类的运算。
宏可以使用多个语句,例如
1 2 3 4 5 #define SWAP(a,b) c=a;a=b;b=c int x=1 ;int y=2 ;SWAP(x,y);
对于多个语句,建议使用do{}while(0)包裹起来,即
1 2 3 4 5 #define SWAP(a,b) do{c=a;a=b;b=c;}while(0) int x=1 ;int y=2 ;SWAP(x,y);
宏可以用于精简循环语句,例如
1 2 #define foreach(item, list) \ for (typeof(list) item = list; item != NULL; item = item->next)
预定义宏 C语言提供了很多特殊的预定义宏,可以用于获取信息,有几个常见的宏通常用于程序追踪和调试:
__LINE__:表示当前行号
__FILE__:表示当前文件名(其中的路径分隔符可能是斜线或反斜线)
__FUNCTION__和__func__(C99 引入):表示当前函数名(在 C++ 中,还有 __PRETTY_FUNCTION__ 可以提供更详细的函数名信息,但是具体信息格式与编译工具链有关)
__DATE__:表示代码被编译的日期
__TIME__:表示代码被编译的时间
例如
1 2 printf ("File: %s, Line: %d\n" , __FILE__, __LINE__);printf ("Compiled on: %s, at: %s\n" , __DATE__, __TIME__);
还有几个宏,用于标记调试模式,assert语句直接受到NDEBUG这个宏的影响,如果定义了NDEBUG则assert无效,例如MSVC的实现如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #ifdef NDEBUG #define assert(expression) ((void)0) #else _ACRTIMP void __cdecl _wassert( _In_z_ wchar_t const * _Message, _In_z_ wchar_t const * _File, _In_ unsigned _Line ); #define assert(expression) (void)( \ (!!(expression)) || \ (_wassert(_CRT_WIDE(#expression), _CRT_WIDE(__FILE__), (unsigned)(__LINE__)), 0) \ ) #endif
除此之外,还有很多的预定义宏,具体格式也与选择的编译工具链相关。
虽然C++一直都不推荐使用宏,但是__LINE__等宏仍然被长期且广泛地使用,因为它们确实具有不可替代的功能。 直到C++20时,才引入了替代这些特殊宏的库:<source_location>,使用示例如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #include <iostream> #include <source_location> #include <string_view> void log (const std::string_view message, const std::source_location location = std::source_location::current()) { std::clog << location.file_name () << '(' << location.line () << ':' << location.column () << ") `" << location.function_name () << "`: " << message << '\n' ; } template <typename T>void fun (T x) { log (x); } int main (int , char *[]) { log ("Hello world!" ); fun ("Hello C++20!" ); }
运行结果如下
1 2 hello1.cpp(18:8) `int main(int, char**)`: Hello world! hello1.cpp(14:8) `void fun(T) [with T = const char*]`: Hello C++20!
补充 不支持对宏重复定义,编译器会直接报错
1 2 #define VALUE 10 #define VALUE 20
不论宏是哪一种形式,都可以使用#undef撤销宏的定义
宏定义的生效范围默认是从定义位置开始,直到源文件结束,除非使用#undef手动撤销。 取消未定义的宏不会引发错误,编译器会忽略这个指令。
如果宏需要跨行,可以使用\取消换行,例如
1 2 #define HELLO "hello \ the world"
注意换行后的行首不能出现空格,否则行首空格也会被视作宏替换的文本的一部分。
注意在源代码中,""包裹的字符串中的内容不会被宏定义所替换,例如
1 2 3 #define M 10 printf ("M*M" )
宏定义进阶 #运算符#是预处理器支持的字符串化操作符,在宏定义中使用#操作符时,它会把紧随其后的宏参数转换为一个字符串字面量,注意:这个字符串是参数的原始文本,而不是参数的值。
例如
1 2 3 #define STR(x) #x printf ("%s\n" , STR(Hello));
##运算符##是预处理器支持的连接操作符,在宏定义中,它可以将两个相邻的标记(tokens)连接在一起,形成一个新的标记。
例如
1 2 3 #define CONCAT(x, y) x##y int xy = CONCAT(10 , 20 );
可变宏 在宏定义中支持使用不定参数,在参数列表中使用...,在内容中使用__VA_ARGS__,此时称为可变宏,任意多个参数都会按照原样被依次传递。
1 2 3 4 5 6 #define PRINT(...) printf(__VA_ARGS__) int main () { PRINT("Hello, %s! The number is %d\n" , "World" , 10 ); return 0 ; }
宏的嵌套 宏在使用中是支持嵌套的,但是具体的处理逻辑非常复杂,甚至与编译工具链和平台相关。
一个简单的流程图如下:
注意:对于嵌套的处理是从外到内的,含有#和##的宏会影响嵌套的处理逻辑。
考虑一个例子(虽然还是看不太懂,记录一下吧)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #include <cstdio> #define TO_STR1(x) #x #define TO_STR2(x) #x #define TO_STR3(x) a_##x #define TO_STR(x) TO_STR1(x) #define PARAM(x) #x #define ADDPARAM(x) INT_##x int main () { const char *str1 = TO_STR(PARAM(ADDPARAM(1 ))); printf ("%s\n" ,str1); const char *str2 = TO_STR2(PARAM(ADDPARAM(1 ))); printf ("%s\n" ,str2); const char *str3 = TO_STR(TO_STR3(PARAM(ADDPARAM(1 )))); printf ("%s\n" ,str3); return 0 ; }
运行结果为
1 2 3 "ADDPARAM(1)" PARAM(ADDPARAM(1)) a_PARAM(INT_1)
分析如下:
关于str1的展开:TO_STR(PARAM(ADDPARAM(1)))
展开PARAM:TO_STR(“ADDPARAM(1)”)
展开TO_STR:TO_STR1(“ADDPARAM(1)”)
展开TO_STR1:”"ADDPARAM(1)"“
结束
关于str2的展开:TO_STR2(PARAM(ADDPARAM(1)))
展开TO_STR2:”PARAM(ADDPARAM(1))”
结束
关于str3的展开:TO_STR(TO_STR3(PARAM(ADDPARAM(1))))
展开TO_STR3:TO_STR(a_PARAM(ADDPARAM(1))),注意此次展开后,PARAM宏名被破坏了,a_PARAM不再是有效的宏名了
展开ADDPARAM:TO_STR(a_PARAM(INT_1))
展开TO_STR:TO_STR1(a_PARAM(INT_1))
展开TO_STR1:”a_PARAM(INT_1)”
结束
宏的有趣应用 IFDEF 和 IFNDEF 很多时候我们需要使用下面的片段,当XXX宏被定义时调用特定语句
1 2 3 4 5 6 7 #ifdef XXX def_work(); #endif #ifndef XXX undef_work(); #endif
我们可以实现IFDEF宏和IFNDEF宏达到类似但略有不同的效果
1 2 IFDEF(XXX,def_work()); IFNDEF(XXX,undef_work());
效果如下:
IFDEF要求XXX被定义,并且被定义为有效的非空字符串 ,此时语句变为def_work();否则语句变为空语句。
IFNDEF要求XXX没有被定义,或者被定义为无效的空字符串 ,此时语句变为undef_work();否则语句变为空语句。
具体实现如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #define concat_temp(x, y) x##y #define concat(x, y) concat_temp(x, y) #define CHOOSE2nd(a, b, ...) b #define MUX_WITH_COMMA(contain_comma, a, b) CHOOSE2nd(contain_comma a, b) #define MUX_MACRO_PROPERTY(p, macro, a, b) \ MUX_WITH_COMMA(concat(p, macro), a, b) #define __P_DEF_0 X, #define __P_DEF_1 X, #define MUXDEF(macro, X, Y) MUX_MACRO_PROPERTY(__P_DEF_, macro, X, Y) #define __IGNORE(...) #define __KEEP(...) __VA_ARGS__ #define IFDEF(macro, ...) MUXDEF(macro, __KEEP, __IGNORE)(__VA_ARGS__) #define IFNDEF(macro, ...) MUXDEF(macro, __IGNORE, __KEEP)(__VA_ARGS__)
测试代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 #include <iostream> void hello (int s) { std::cout << "hello " << s << "\n" ; }void nohello (int s) { std::cout << "no hello " << s << "\n" ; }int main () { std::cout << "----------define USE_HELLO [None]----------\n" ; #define USE_HELLO #ifdef USE_HELLO hello (1 ); #else nohello (1 ); #endif IFDEF (USE_HELLO, hello (20 )); IFNDEF (USE_HELLO, nohello (20 )); #undef USE_HELLO std::cout << "----------define USE_HELLO 1----------\n" ; #define USE_HELLO 1 #ifdef USE_HELLO hello (3 ); #else nohello (3 ); #endif IFDEF (USE_HELLO, hello (40 )); IFNDEF (USE_HELLO, nohello (40 )); #undef USE_HELLO std::cout << "----------define USE_HELLO 0----------\n" ; #define USE_HELLO 0 #ifdef USE_HELLO hello (5 ); #else nohello (5 ); #endif IFDEF (USE_HELLO, hello (60 )); IFNDEF (USE_HELLO, nohello (60 )); #undef USE_HELLO std::cout << "----------undefine USE_HELLO----------\n" ; #ifdef USE_HELLO hello (7 ); #else nohello (7 ); #endif IFDEF (USE_HELLO, hello (80 )); IFNDEF (USE_HELLO, nohello (80 )); return 0 ; }
运行结果为
1 2 3 4 5 6 7 8 9 10 11 12 ----------define USE_HELLO [None]---------- hello 1 no hello 20 ----------define USE_HELLO 1---------- hello 3 hello 40 ----------define USE_HELLO 0---------- hello 5 hello 60 ----------undefine USE_HELLO---------- no hello 7 no hello 80
伪装 bash 脚本 我们可以利用宏给cpp源文件加上特殊头部,例如下面的例子,这里#if 0 ... #endif部分会被编译器忽略,但是直接执行这个文件会被Linux当作bash脚本来执行,从而达到直接执行cpp文件的目的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #if 0 bin="$(basename " $0 ")" ext="${bin##*.}" # 获取文件扩展名 bin="${bin%%.*}_$(date +%s)" # 使用时间戳生成唯一的文件名 # 判断扩展名,选择编译器 if [ "$ext" = "cpp" ]; then g++ -o "$bin" "$0" || exit # 编译C++文件 elif [ "$ext" = "c" ]; then gcc -o "$bin" "$0" || exit # 编译C文件 else echo "Unsupported file extension: $ext" exit 1 fi exec ./"$bin" "$@" # 执行生成的可执行文件 ret=$? # 保存执行状态 rm -f "$bin" # 删除可执行文件 exit $ret # 返回执行状态 #endif #include <iostream> int main (){ std::cout << "hello,world!" ; return 0 ; }