对于宏有很多花里胡哨的用法,虽然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的,此时的不重复导入由编译工具链直接保证

1
2
3
#pragma once

...

两种做法各有利弊:

  • 第一种做法保证的是内容不会重复导入;第二种做法保证的是当前文件不会重复导入,但是如果在不同位置有完全重复的文件,则无法保证安全
  • 第一种做法的兼容性更好;第二种做法主流的编译工具链都支持,但是早期的或者冷门编译器可能不支持
  • 第一种做法可能出现重名或者手误打错了宏的名称,从而导致问题;第二种做法则更加简单直观

条件编译

我们可以使用条件语句来控制使用不同的编译内容,通过开启或关闭不同的宏定义来触发,基本的语句包括

  • #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
/* Windows -------------------------------------------------- */
#elif __linux__
/* Linux ---------------------------------------------------- */
#else
#error unsupported platform
#endif

#if defined(__clang__)
/* Clang/LLVM. ---------------------------------------------- */
#elif defined(__GNUC__) || defined(__GNUG__)
/* GNU GCC/G++. --------------------------------------------- */
#elif defined(_MSC_VER)
/* Microsoft Visual Studio. --------------------------------- */
#else
#error unsupported compiler
#endif

注意这里先判断__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_MACROmy_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 * 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

注意,这里参数列表的括号和前面的名称不能有空格,否则会识别错误

1
2
3
#define ADD (x,y) x+y

int a = ADD(1,2) // ERROR

为了安全,尽量用括号把整个替换文本及其中的每个参数括起来,否则会引发错误,例如

1
2
3
4
5
#define FUNC1(x,y) x+y
#define FUNC2(x,y) ((x)+(y))

int a = FUNC1(1+2,3); // 1+2*3
int b = FUNC2(1+2,3); // ((1+2)*(3))

即便如此也可能存在问题,例如

1
2
3
4
#define FUNC(x,y) ((x)+(y))

int s = 6;
int sum = FUNC(++s); // ((++s)+(++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这个宏的影响,如果定义了NDEBUGassert无效,例如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); // line 14
}

int main(int, char *[]) {
log("Hello world!"); // line 18
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撤销宏的定义

1
#undef MAX

宏定义的生效范围默认是从定义位置开始,直到源文件结束,除非使用#undef手动撤销。 取消未定义的宏不会引发错误,编译器会忽略这个指令。

如果宏需要跨行,可以使用\取消换行,例如

1
2
#define HELLO "hello \
the world"

注意换行后的行首不能出现空格,否则行首空格也会被视作宏替换的文本的一部分。

注意在源代码中,""包裹的字符串中的内容不会被宏定义所替换,例如

1
2
3
#define M 10

printf("M*M") // "M*M"

宏定义进阶

#运算符

#是预处理器支持的字符串化操作符,在宏定义中使用#操作符时,它会把紧随其后的宏参数转换为一个字符串字面量,注意:这个字符串是参数的原始文本,而不是参数的值。

例如

1
2
3
#define STR(x) #x

printf("%s\n", STR(Hello)); // 输出 "Hello"

##运算符

##是预处理器支持的连接操作符,在宏定义中,它可以将两个相邻的标记(tokens)连接在一起,形成一个新的标记。

例如

1
2
3
#define CONCAT(x, y) x##y

int xy = CONCAT(10, 20); // int xy = 1020;

可变宏

在宏定义中支持使用不定参数,在参数列表中使用...,在内容中使用__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
// macro concatenation
#define concat_temp(x, y) x##y
#define concat(x, y) concat_temp(x, y)

// macro testing
// See
// https://stackoverflow.com/questions/26099745/test-if-preprocessor-symbol-is-defined-inside-macro
#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 placeholders for some property
#define __P_DEF_0 X,
#define __P_DEF_1 X,

// define some selection functions based on the properties of BOOLEAN macro
#define MUXDEF(macro, X, Y) MUX_MACRO_PROPERTY(__P_DEF_, macro, X, Y)

// simplification for conditional compilation
#define __IGNORE(...)
#define __KEEP(...) __VA_ARGS__

// keep the code if a boolean macro is defined
#define IFDEF(macro, ...) MUXDEF(macro, __KEEP, __IGNORE)(__VA_ARGS__)
// 定义 IFNDEF 宏
#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]----------" << std::endl;

#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----------" << std::endl;

#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----------" << std::endl;

#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----------" << std::endl;

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