C语言 宏的学习笔记
对于宏有很多花里胡哨的用法,虽然C++已经不推荐使用复杂的宏,但是也要看得懂,因此整理一下。
基本概念
预处理器是一个正式编译C语言的源代码之前的文本处理工具,它负责执行预处理指令(#
开头的指令),通常包括头文件包含,条件编译,宏等。
宏是预处理器支持的一种重要功能,允许程序员定义一些简单的代码替换规则:通过宏创建符号常量或者简单的代码片段,并在代码中多次使用。 这些宏会在编译前被预处理器替换为相应的内容。值得注意的是,预处理器只是文本处理工具,它不会分析任何语法层面的内容,行为完全是文本层面的。
预处理器支持的命令主要包括
- 文件包含:
#include
用于在源文件中包含其他文件的内容 - 条件编译:
#if
、#ifdef
、#ifndef
、#elif
、#else
、#endif
用于条件编译,根据条件决定编译部分代码 - 宏定义:
#define
用于创建宏,可以是简单的文本替换或带参数的宏 - 取消宏定义:
#undef
用于取消已定义的宏
除此之外,还有几个不常见的命令:
- 错误指示:
#error
用于在预处理阶段生成一个错误消息,编译终止,通常是用在条件编译中终止某个错误情形。警告指示#warning
是非标准的:MSVC不支持,而GCC/Clang支持 - 行控制:
#line
用于修改行号和文件名信息,很少使用 - 特殊命令:
#pragma
用于向编译器发出特定命令或指示的预处理器指令,这些指令通常与编译器和平台密切相关,并不通用。一个最常见的特殊指令是#pragma once
,它被广泛支持,用于防止重复包含头文件
文件包含
包含头文件是预处理器的重要功能,#include
会直接(递归地)拷贝指定文件到当前位置。
1 |
|
关于头文件包含有一个重要的需求是避免头文件重复包含,通常有两类解决办法,
例如在头文件func.h
中,可以有两种做法:第一种做法是基于#ifdef
的
1
2
3
4
5
6
...
我们使用FUNC_H_
这个宏来标记当前是否导入了当前的头文件,如果已经导入了则不会再次导入。第二种做法是基于#pragma
的,此时的不重复导入由编译工具链直接保证
1
2
3
...
两种做法各有利弊:
- 第一种做法保证的是内容不会重复导入;第二种做法保证的是当前文件不会重复导入,但是如果在不同位置有完全重复的文件,则无法保证安全
- 第一种做法的兼容性更好;第二种做法主流的编译工具链都支持,但是早期的或者冷门编译器可能不支持
- 第一种做法可能出现重名或者手误打错了宏的名称,从而导致问题;第二种做法则更加简单直观
条件编译
我们可以使用条件语句来控制使用不同的编译内容,通过开启或关闭不同的宏定义来触发,基本的语句包括
#ifdef
:如果某个宏被定义,则编译下面的代码,否则删去#ifndef
:如果某个宏未被定义,则编译下面的代码,否则删去#else
:在条件不满足时编译其后的代码块#endif
:结束条件编译块,不可以省略
完整结构例如 1
2
3
4
5
...
...
再例如 1
2
3
4
5
...
...
其中的#else
部分可以整体省略,但是结束语句#endif
不可以省略,例如
1
2
3
4
5
6
7
...
...
除了基于宏定义,还可以支持一般表达式触发的条件编译,有如下的几个命令:
#if
:根据给定的条件表达式进行编译#elif
:用于在多个条件之间进行选择编译
这里的表达式可以是宏或者常量表达式,并且支持简单的运算,例如
1
2
3
4
5
6
7
8
9
...
...
...
使用#if defined(x)
则可以和前面的#ifdef x
作用相当,例如
1
2
3
4
5
6
7
8
9
...
...
...
条件编译可以使用更复杂的运算:支持使用逻辑与
(&&
)、逻辑或 (||
)、逻辑非
(!
) 进行组合判断,支持使用括号明确优先级,例如
1 |
|
一个很复杂的实例如下 1
2
3
4
5
6
7
典型的应用是判断当前平台和编译器 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* Windows -------------------------------------------------- */
/* Linux ---------------------------------------------------- */
/* Clang/LLVM. ---------------------------------------------- */
/* GNU GCC/G++. --------------------------------------------- */
/* Microsoft Visual Studio. --------------------------------- */
注意这里先判断__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
习惯上用来定义一个常量或者类型,对于类型而言,它可以发挥和typedef
类似的效果
1
2
3
4
5
typedef int * int_star;
INT_STAR a;
int_star b;
但是注意这两者并不是等价的,经典反例如下 1
2
3INT_STAR a,b; // int * a,b;
int_star c,d;
此时通过宏定义的类型并不能影响多个变量,b只是int类型的变量而非指针,其它三个变量均为指针。
类函数宏
宏也可以加上参数,发挥类似一个函数的功能,称为类函数宏(function-like
macro), 1
2
3
int a = ADD(1,2) // 1+2
注意,这里参数列表的括号和前面的名称不能有空格,否则会识别错误
1
2
3
int a = ADD(1,2) // ERROR
为了安全,尽量用括号把整个替换文本及其中的每个参数括起来,否则会引发错误,例如
1
2
3
4
5
int a = FUNC1(1+2,3); // 1+2*3
int b = FUNC2(1+2,3); // ((1+2)*(3))
即便如此也可能存在问题,例如 1
2
3
4
int s = 6;
int sum = FUNC(++s); // ((++s)+(++s))
此时会出现两次自增,与期望不一致,为了避免这种问题,要求对宏的参数自身不要进行++
,-
之类的运算。
宏可以使用多个语句,例如 1
2
3
4
5
int x=1;
int y=2;
SWAP(x,y);
对于多个语句,建议使用do{}while(0)
包裹起来,即
1
2
3
4
5
int x=1;
int y=2;
SWAP(x,y);
宏可以用于精简循环语句,例如 1
2
预定义宏
C语言提供了很多特殊的预定义宏,可以用于获取信息,有几个常见的宏通常用于程序追踪和调试:
__LINE__
:表示当前行号__FILE__
:表示当前文件名(其中的路径分隔符可能是斜线或反斜线)__FUNCTION__
和__func__
(C99 引入):表示当前函数名(在 C++ 中,还有__PRETTY_FUNCTION__
可以提供更详细的函数名信息,但是具体信息格式与编译工具链有关)__DATE__
:表示代码被编译的日期__TIME__
:表示代码被编译的时间
例如 1
2printf("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
_ACRTIMP void __cdecl _wassert(
_In_z_ wchar_t const* _Message,
_In_z_ wchar_t const* _File,
_In_ unsigned _Line
);
除此之外,还有很多的预定义宏,具体格式也与选择的编译工具链相关。
虽然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
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
2hello1.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
不论宏是哪一种形式,都可以使用#undef
撤销宏的定义
1
宏定义的生效范围默认是从定义位置开始,直到源文件结束,除非使用#undef
手动撤销。
取消未定义的宏不会引发错误,编译器会忽略这个指令。
如果宏需要跨行,可以使用\
取消换行,例如
1
2
注意换行后的行首不能出现空格,否则行首空格也会被视作宏替换的文本的一部分。
注意在源代码中,""
包裹的字符串中的内容不会被宏定义所替换,例如
1
2
3
printf("M*M") // "M*M"
宏定义进阶
#
运算符
#
是预处理器支持的字符串化操作符,在宏定义中使用#
操作符时,它会把紧随其后的宏参数转换为一个字符串字面量,注意:这个字符串是参数的原始文本,而不是参数的值。
例如 1
2
3
printf("%s\n", STR(Hello)); // 输出 "Hello"
##
运算符
##
是预处理器支持的连接操作符,在宏定义中,它可以将两个相邻的标记(tokens)连接在一起,形成一个新的标记。
例如 1
2
3
int xy = CONCAT(10, 20); // int xy = 1020;
可变宏
在宏定义中支持使用不定参数,在参数列表中使用...
,在内容中使用__VA_ARGS__
,此时称为可变宏,任意多个参数都会按照原样被依次传递。
1
2
3
4
5
6
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
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
def_work();
undef_work();
我们可以实现IFDEF
宏和IFNDEF
宏达到类似但略有不同的效果
1
2IFDEF(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
// macro testing
// See
// https://stackoverflow.com/questions/26099745/test-if-preprocessor-symbol-is-defined-inside-macro
// define placeholders for some property
// define some selection functions based on the properties of BOOLEAN macro
// simplification for conditional compilation
// keep the code if a boolean macro is defined
// 定义 IFNDEF 宏
测试代码如下 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
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;
hello(1);
nohello(1);
IFDEF(USE_HELLO, hello(20));
IFNDEF(USE_HELLO, nohello(20));
std::cout << "----------define USE_HELLO 1----------" << std::endl;
hello(3);
nohello(3);
IFDEF(USE_HELLO, hello(40));
IFNDEF(USE_HELLO, nohello(40));
std::cout << "----------define USE_HELLO 0----------" << std::endl;
hello(5);
nohello(5);
IFDEF(USE_HELLO, hello(60));
IFNDEF(USE_HELLO, nohello(60));
std::cout << "----------undefine USE_HELLO----------" << std::endl;
hello(7);
nohello(7);
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
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 # 返回执行状态
int main(){
std::cout << "hello,world!";
return 0;
}