宏定义简介
在计算机编程领域,宏指令(macro)或称宏,表示一类将输入转换为输出的处理规则。和子程序(函数)不同的是,宏一般发生在源文件还未编写完成的阶段(即预处理阶段),并且宏一般只是通过简单地替换指令来工作,而不管这些执行是如何执行的甚至是否能执行。
宏处理器会先将宏扩展为实际的指令再执行它们,这一过程称为宏展开(macro expansion)。宏可以将一系列处理指令用作单条指令使用,在消除重复性工作的同时降低这一过程中出错的可能。(因为一个“小”的宏指令可能包含很“大”的指令序列,所以这也是它被称为“宏”的原因。)
许多软件和编程语言都支持使用宏的概念。例如在文本处理工具中,宏可以帮助用户自动化处理重复的编辑任务;在电子表格应用中,宏可以自动执行一系列复杂计算。在 C 语言中,宏可以向源码中插入更多的代码,而不必手动重复编写它们。
预处理器指令#define
#define
是一个预处理器指令,用于定义一个宏。#define
预处理器指令和其它预处理器指令一样,要求 #
符号是一行的第一个非空白字符。
一个基本的宏通过如下形式定义:
identifier
表示宏的名称,需要遵循 C 语言标识符的命名规则(只能使用字符、数字和下划线 _
字符,而且首字符不能是数字)。replacement-list
称为替换列表或替换体。一旦预处理器在程序中发现了一个宏对应的标识符,它就会使用替换体的内容代替该宏。
以下演示了宏的基本用法:
当预处理器遇到标识符 PI
时,它会被展开为浮点数字面量 3.141592653589793
;当预处理器遇到标识符 PRINT_X
时,它会被展开为函数调用 printf(FORMAT_X, x)
,这个替换体中嵌套了其它宏,预处理器会将其进一步扩展为完整的形式 printf("X is %.4f.\n", x)
。
使用 gcc 编译器的话可以使用
-E
参数获得预处理后代码的完整形式。不过注意,这一步不仅会展开所有的宏,还会引入头文件的所有内容。$ gcc -E macro.c ... double x = sin(3.141592653589793 / 6); printf("X is %.4f.\n", x); ...
从这个示例中可以看出宏的特点:在代码编译之前,预处理器会将宏的定义替换到程序中,因此宏其实是一种源代码中的文本替代机制。一般而言,预处理器发现程序中的宏后,会用宏定义中的替换文本替换它。如果替换的表达式或语句中还包含宏,则继续替换这些宏。记住,预处理器不做计算,不对表达式求值,它只替换文本。
一个 #define
的替换体是从宏标识符的空格后面到换行符的所有文本。如果要使宏的定义跨越多行,需要使用续行符 \
,例如:
宏展开后等价于:
不过在 #define
的行中存在的注释不算替换体,注释的去除比预处理更早发生,因此不会参与替换。例如宏:
最终 LENGTH
被展开后为 16
,后面的注释会被忽略。
注意宏定义末尾不需要分号;如果提供分号,那么这个分号会作为替换体的一部分被插入目标位置。如果定义的宏用起来像一个值,那么这个多余的分号用在表达式中往往会造成问题。
如果宏用起来类似一条语句,那么通常的做法是定义时省略最后的分号,使用时再加上,就像以上用法一样。
宏有许多用法,以上只是演示了其中一种用法,即将一个复杂表达式或若干语句封装成一个简单的符号,这样在源代码中通过简单地写出这个符号,来引用这个复杂的表达式或语句。这些用起来像一个变量或一条语句之类的宏被称为类对象宏(object-like macro)。
该指令可以出现在源文件的任何地方,其定义从指令出现的地方到该文件末尾有效。但是这种用途的宏一般习惯性写在源代码文件的顶部,
#include
内容的后面。
在以上示例中,通过使用 #define
指令为数值字面量起了一个别名,这种形式定义的常量称为明示常量(manifest constant)。宏定义、const
变量与 enum
枚举类型都是一种给数值起别名的方式,它们可以提高代码的可读性和可维护性,防止代码中出现“魔法数字”(即在代码中直出现的、没有解释的数字)。这三种使用方式有些相似,但使用场景又有一些区别:
宏定义相比 const
变量,它发生在预处理阶段,只是执行简单的文本替换,没有实际分配存储空间,预处理后这个符号就消失了。const
关键字定义的是变量,它会在编译期分配空间,并具有类型信息、作用域以及链接形式相关的概念。不过宏定义可以配合其它的预处理器指令实现更复杂的功能。
另外,const
变量不能像宏一样参与各种预处理操作。例如,宏可以这样用:
如果用的是 const
变量,可能需要通过 strcat()
的方式拼接字符串。
宏的这种特点使其可以对 C 语法做一些扩展,例如将语句块的花括号改成一些编程语言惯用的
BEGIN
和END
的关键字形式:#define THEN { #define END } if (condition) THEN /* ... */ END
最后,宏定义可以直接写到 .h
头文件中,但 const
常量如果写在头文件中,被多个源文件包含时会产生重复定义。
enum
枚举类型可以为一组整型常量提供了更好的组织结构。enum
类型的含义通常是某些常量的集合。枚举自身是一种类型,它可以提示使用者定义了哪些可用的枚举值,并由编译器确保只用了这些合理的枚举值;但宏在预处理阶段后就消失了,编译器无法确定使用的值是否合理。
不过,但实际应用中一些常量是单独存在的,例如 BUF_SIZE
,它们既难以分类,也不存在“枚举”的字面意思,不应该强行用作枚举值。此外,枚举常量是有类型的,默认为 int
。有些时候如果需要定义一个无符号整型或长整型,如果使用 enum
就会有潜在的问题。C 语言不支持字符串作为枚举值,这时也只能使用宏代替。
带参数的宏
#define
中的标识符名也可以像函数一样,使用圆括号创建带参数形式的宏:
圆括号中的参数列表 identifier-list
可以有一个或多个参数,这些参数可以被用在替换体中。带有参数的宏也和函数一样,采用圆括号的形式调用。以下演示了一个简单的(但实际上有一些问题的)带参数宏的用法:
以上使用宏时,SQUARE
被替换为 X*X
,宏参数 X
进一步被替换为 4
,因此展开的结果是 4*4
。也就是说,带参数的宏经过两次文本替换。
使用带参数的宏时,参数的替换规则和宏的替换规则一样:预处理器只会替换完整的记号,而不会替换作为预处理器指令、另一个标识符的一部分、字符或字符串字面量之中和宏名相同的文本。例如,假设有以下宏的定义和调用:
那么参数替换的结果为 FLAG_X && printf("X is %d.\n", 100)
。这个替换规则也适用于宏本身。因此可以认为带参数的宏实际上发生了两次展开。
一个带参数的宏可以可以具有空的参数列表,例如:
使用空的参数而不是不带参数的宏,可以使宏更像一个函数而不是一个值。
这种定义和用法都和函数类似的宏称为类函数宏(function-like macro)
注意,类函数宏在定义时,宏的标识符和左括号之间不能有空格,否则预处理器会将其视为一个简单的宏,并将
(identifier-list) replacement-list
作为完整的替换体。
之所以说以上定义的宏带有一些问题,是因为带参数的宏在替换时,多个参数展开后可能存在运算符优先级的问题,例如:
本意是计算 4 + 2
的平方,但是宏先展开为 X*X
,参数再展开后变成 4 + 2*4 + 2
,运算符的优先级使得中间的乘法先执行,因此得到了偏离预期的结果。
为了避免这种情况,一般都会在宏定义中使用足够多的圆括号保证实际计算顺序和理论一致:
如果不带参数的宏的替换体是一个表达式,那么也应该给这个表达式加上圆括号,防止运算受影响。
类函数宏用起来很像函数,但毕竟不是真正的函数。类函数宏的参数是没有类型的,这既是一个缺点,也是一个优点:编译器无法检查宏的参数是否传入了合适的值,也不会自动将参数转换为合理的类型。不过这使得宏的参数可以支持不同类型的值,例如前面实现的 MAX
宏既可以比较整型值的大小,也可以比较浮点类型的大小,从而一定程度上实现泛型。但如果参数是一个表达式,函数对其只会求值一次,而宏可能会不止一次求值。如果参数有副作用,多次求值可能会产生不可预知的结果。
非内联的函数在调用时有一定开销,但它只保存一份代码副本,可以减小代码体积。函数还可以被指针指向,这种情况下宏不能实现回调函数这种效果。
因此,宏一般只用于定义一些简短的代码片段,或者用在不实现一个完整的逻辑的场景。以下提供了一些类函数宏的定义示例:
变参宏
C99 允许在宏中定义可变长度的参数列表,就像可变参数的函数一样。
在带参数宏的定义中,可以在参数列表的最后使用 ...
符号,表示可变个数的参数。在含有 ...
参数的宏的替换列表中,可以使用一个标识符 __VA_ARGS__
代表所有与 ...
符号相对应的参数。...
符号可以对应 0 个或任意个参数,在 0 个参数的情况下,__VA_ARGS__
为空。
变参宏可以更好地和可变参数的函数交互,例如以下定义:
宏 assert
至少要有 2 个参数:第一个参数匹配普通的形式参数,第二个参数用作 fprintf
的格式字符串,然后可以有任意个参数用于格式化打印。以下是两个调用该宏的示例:
变参宏之前在语言层面有一个小的缺陷,以上的示例不太能体现。如果宏 assert
是这样定义的:
这时,新定义的 assert
宏变成了至少要有 3 个参数:如果只传递两个参数,它会展开成:
→ ((arg1 > 0) ? : fprintf(stderr, "error: invalid argument", ))
如果只传递两个参数,可变参数 ...
将接收 0 个参数,__VA_ARGS__
展开结果为空,但是参数前有一个逗号 ,
,正是这个小小的逗号导致函数调用时发生了语法错误。
C++20 引入了一个类函数宏 __VA_OPT__()
,该宏只能用于变参宏的定义中:只有当可变参数 ...
至少接收了一个参数时,__VA_OPT__()
展开为其参数的内容;否则该宏展开为空。借助 __VA_OPT__()
,可以将 assert
宏改写如下:
__VA_OPT__()
已经正式被 C23 标准接收,并且一些编译器早就将其作为扩展实现。
GCC 之前提供了另一种方式处理这个问题:通过在逗号
,
和__VA_ARGS__
中使用##
预处理运算符:#define assert(expr, format, ...) \ ((expr) ? : fprintf(stderr, format, ## __VA_ARGS__))如果
__VA_ARGS__
为空,那么逗号将被忽略。不过这只是 GCC 提供的一种语言扩展,只有 GCC 等少部分编译器实现了这个特性。关于##
预处理运算符的内容马上就会介绍。
宏与预处理器
定义与重定义
如果先定义了一个宏,但在该文件中又定义了相同名字的宏,这个过程称为重定义。标准规定只有新定义和旧定义完全相同才允许重定义,大多数编译器在宏发生重定义时也会给出警告。
当源文件中包含了很多头文件时,难以确定到底定义了哪些宏,也就很可能发生宏的重定义。#undef
指令用于取消已定义的 #define
指令。假设有以下定义:
使用指令
在移除上面的定义后,重新定义 MAX
宏为一个新值。
即使原来没有定义 MAX
,使用 #undef MAX
取消该宏的定义也不会出错。因此,可以在不确定时使用 #undef
指令,以防止重复定义宏。
C 语言还提供了预处理器指令 #ifdef
,可以将宏是否定义作为一个条件。
#ifdef
指令需要配合 #endif
指令使用。#endif
指令说明当宏被定义后,哪些代码将被处理并编译。#ifdef
指令配合宏,可以得到条件编译(conditinal compilation)手段,告诉编译器根据编译时的条件执行或忽略特定的代码块。
考虑以下宏:
这个宏的替换体是空的,这样的宏定义也是合法的。这种空宏一般用于配合 #ifdef
指令实现条件编译。例如以下用法:
表示如果定义了宏 DEBUG_MODE
,则输出一条调试信息,否则这条语句将被忽略。
再如以下用法:
表示当定义了宏 LARGE_DEVICE
后,在结构体内额外定义一个成员 REG_DMA
,否则忽略这个定义。
所以有时会看到将一个宏定义在一个第三方头文件的上面:
这时,头文件将会根据该宏是否被定义来生成不同的代码。但一般情况下不要将宏的定义放在头文件上,以免不小心和头文件中的其它标识符冲突。
还有两个和 #ifdef
用途相同的指令:
#ifndef
指令判断后面的标识符是否是未定义的#else
指令说明当#ifdef
或#ifndef
逻辑相反时,应当处理的代码
C23 还提供了
#elifdef
和#elifndef
指令,用于实现并列的逻辑关系。
#ifndef
指令和 #ifdef
指令虽然逻辑相反,但应用场景有一些区别。例如,#ifndef
指令可以用于条件性地定义一些宏:
这段代码说明如果没有定义宏 BUFSIZE
,那么程序自行提供一个默认的定义;否则,使用别处定义的该宏。
这种性质使得 #ifndef
通常用于防止多次包含一个文件。这种情况下头文件中应该包含类似这样的代码:
当该文件首次被包含时,__MY_HEADER_H__
是未定义的,在定义 __MY_HEADER_H__
后正常引入头文件的所有内容。该文件被被重复包含时,__MY_HEADER_H__
已经被定义了,预处理器便跳过了该文件的其它部分。
使用宏防止头文件重复包含的方式比 #pragma once
更通用,任何标准的 C 编译器都能正确执行它。但为了确保相关的宏没有在别处定义,需要按一种约定名称来定义在这种场景下使用的宏(通常用与文件名相关的名称来定义此处的宏)。
另一类条件编译的方式是使用 #if
指令。#if
指令与 C 语言中的 if
语句类型,它后面需要跟随一个整型常量表达式(或可以展开为整型常量表达式的宏):如果表达式为非零,则表达式为真,解析它对应的所有代码。
#if
指令同样需要使用 #endif
指令标记处理的结束位置。#if
指令可以配合使用 #elif
指令实现并列的逻辑关系,例如:
当条件指令包含的内容很多时,一个比较好的习惯是为每一个
#endif
都添加注释,指明对应的#ifdef
判断哪个宏或#if
测试哪个条件。
一些编码风格使用
#if 0
来编写注释,这样做的好处是编辑器可以高亮其中的代码。这种手段也经常用于临时屏蔽某些代码(但稍后可能会用上)。
#if
指令虽然没有对应的“否定”版本,但它可以在指令中使用关系运算符和逻辑运算符:
较新的编译器提供了运算符 defined(MACRO)
,用于在 #if
指令中判断宏是否已定义:如果宏 MACRO
已经用 #defined
定义过,则返回 1
;否则返回 0
。
使用 #if defined(MACRO)
代替 #ifdef MACRO
的好处是,可以在同一个指令中同时判断宏是否已定义和判断定义的值:
如果宏 DEBUG_LEVEL
没有定义,逻辑运算符的短路性质会使得相等运算符不会执行,而避免出错。
#if ... #else
很像 if ... else
,两者的主要区别是:
#if ... #else
发生在预处理阶段,而if ... else
发生在编译阶段if ... else
根据花括号{ }
标记语句块,而预处理器不识别#if ... #else
中的花括号,因此需要提供#endif
来标记结束位置
这些指令结构也可以通过嵌套实现更复杂的判断效果。
通过以上介绍,可以了解为什么这些指令被称为“条件编译”指令:它们可以根据某些特征,控制特定代码部分是否被编译器编译。不同的编译器、操作系统甚至处理器都会提供不同的特征,通过使用条件编译,可以创建易于维护和适应不同环境需求的代码,而无需改变源代码基础,增强了代码的可移植性和灵活性。例如,以下是一个高性能运算库的部分代码,其中包含判断不同处理器后再编译的相关代码,真正做到了极致的性能优化:
预处理运算符#
在类函数宏的替换体中,#
号是一个预处理运算符,可以把参数转换成字符串字面量。如果 x
是一个宏的形式参数,并且使用时向其传入了参数 var
,那么 #x
会在将 x
替换为 var
后,将其转换为字符串字面量形式的 "var"
。这个过程称为字符串化(stringizing)。
这个预处理运算符一般配合 C 语言字符串的串联特性,在预处理时批量生成字符串字面量。以下程序演示了它的用法:
这两个包含宏的语句展开结果等价于:
预处理运算符##
##
运算符把两个记号“粘合”成一个记号。例如,假设有这样的宏:
那么调用 NAMED_X(3)
会展开为一个完整的记号 x3
,可以像使用一个标识符一样使用它,例如:
程序运行结果会输出信息 x3 is 3
。
与 #
运算符类似,##
运算符一般用在类函数宏的替换部分处理参数。如果其中一个操作数是宏参数,“粘合”会在形式参数被相应的实际参数替换后发生,并且这个过程最多只发生一次替换,如果要将两个宏展开后的内容粘合在一起,需要像上一节的示例一样定义两层的宏。
##
运算符用的不多,一种可能的使用场景是批量生成一类变量或函数。例如,假设有这样的定义:
然后就可以这样批量生成并使用函数:
如果后续定义了复数或矩阵等类型,并提供相应命名规则的函数,那么 MUL(Complex, c1, c2);
之类的调用也可以正常工作。
预定义宏
标准规定了一些预定义宏,这些宏一定是已定义的,而且不能取消定义。这些预定义宏如下:
宏 | 替换体 |
---|---|
__STDC__ | 设置为 1 时,表明实现遵循 C 标准 |
__STDC_VERSION__ | 表示当前标准的整型常量(例如支持 C99 标准,则设置为 199901L ;支持 C11 标准,则设置为 201112L ) |
__DATE__ | 预处理发生的日期,是一个 "Mmm dd yyyy" 形式的字符串字面量 |
__TIME__ | 翻译代码的时间,是一个 "hh:mm:ss" 形式的字符串字面量 |
__FILE__ | 宏展开后,位于文件名的字符串字面量 |
__LINE__ | 宏展开后,位于行号的整型常量 |
C99 标准还提供了标识符 __func__
,它是一个代表位于函数名的字符串字面量(和其它几个不同,__func__
并不是宏,而是一个标识符,相当于在函数体的开始位置声明的变量)。除此之外 C99 还提供了一些其它用途的宏,不过用的较少,这里就不介绍了。
__DATE__
和 __TIME__
可以用于确定程序编译的时间,从而帮助区分同一个程序的不同版本。
__FILE__
、__LINE__
和 __func__
经常作为一个调试的手段,帮助快速定位问题发生的源文件具体行号和函数位置。例如,以下代码使用这些预定义宏,定义了一个用于提示错误信息的宏,当这个宏被调用时会输出一条详细的错误信息:
这个宏可以这样使用:
宏的定义细节
关于记号
之前曾将宏的替换体称为替换列表,这是有原因的:宏的替换体应该被看作是一系列记号(token),而不仅仅是一系列字符。记号是 C 编译器解析代码的最小单位。一般来说,C 编译器会根据空白符将源代码分割为一系列记号后,分析这些记号的语法。
因此,两个宏只有记号相同、且顺序也相同时才不会发生重定义。例如,以下几个定义相同:
它们都包含记号 (2
、*
和 3)
。而这些宏的定义是不同的,尽管它们看起来和用起来都很像:
此外,##
运算符执行的是记号拼接而不是文本拼接,它会将两侧的记号拼接成一个记号。但如果两个记号不知道如何拼接为一个记号,或无法拼接成一个有效的记号,那么结果是未定义的,有些预处理器在这种场景下会给结果两侧增加空白符。例如,对于以下宏:
以下用法:
这个拼接是合理的,因为 x_
会被视为标识符的一部分作为一个记号,数字也会被视为标识符的一部分作为另一个记号,这两个记号的拼接结果是一个完整的标识符。但是以下用法:
是有问题的,因为 1.23
是一个有效的数字形式的记号,但 e-1
不知道如何与数字形式的记号拼接,这时会产生不可知的结果(例如 gcc 8.1.0 会将其展开为 1.23e -1
,但这不是一个合法的浮点数字面量)。
不过这种形式的用法是可以的:
这时 1.2
和 3e-1
都是数字形式的记号,所以它们会被拼接为一个合法的浮点数字面量 1.23e-1
。
关于展开
前文介绍了宏展开的条件,所有(在宏定义之后的代码中)出现宏名的地方都会被替换为对应的替换体。宏的替换是递归进行的,即替换内容中如果还包含宏名,这些宏同样会被展开。并且前文说明了出现在字符串或字符常量中、作为另一个标识符一部分的宏不会被展开。对于带参数的宏,在宏展开时会先将实际参数替换到宏定义中的参数位置,然后对整个宏内容进行展开。
早期的预处理器在遇到嵌套或递归的宏时,可能会陷入无限循环。但现代 C 语言标准规定,在扩展宏的过程中出现的相同宏名不会再次被替换。
#
运算符和 ##
运算符会在代入参数后立刻将其变为字符串。如果参数还是一个宏,那么这个宏并不会展开,而是直接将宏的名称变成字符串。
因此,如果想要将某个宏展开的内容字符串化,需要定义一个两层的宏,就像这样:
这个技巧对 ##
运算符也适用。再如,假设要定义一个宏 foreach
,实现大致这样的效果:
那么直接定义具有两个参数的宏是不对的,因为编译器会将 i in a
看作一个完整的参数传入宏 foreach
中,因此在展开类函数宏 foreach
时,in
宏并没有展开,foreach
只传入了一个参数而发生错误。
解决方法是定义两层的宏,第一层具有一个参数,第二层具有两个参数,in
宏在第一宏的替换体中被展开,将一个参数分成两个参数:
关于宏参数
带参数的宏在调用时,由逗号确定各个参数的对应。不过这个规则实际上会更复杂一些:如果调用的参数内还有一对匹配的圆括号,那么这对圆括号之间的逗号不会分隔参数。
也就是说,如果有一个这样的宏:
那么调用
实际上会展开为 f(2, 3) + 4
,而不是 f(2 + 3), 4
,更不会被视为三个参数 f(2
、3)
和 4
。
C99 标准允许宏调用中的任意或所有参数为空,但应有的逗号不能省略。空的调用参数替换内容为空。
例如,对以上宏省略参数的调用:
展开结果为 + 4
。
当空的实际参数被 #
或 ##
运算符操作时,结果有特殊规定:空的实际参数经由 #
运算符结果为空字符串 ""
;如果 ##
运算符操作的一个实际参数为空,它会被一个称为“位置标记(placemarker)”的记号代替:两个位置标记会被拼接为一个单独的位置标记,而位置标记和其它标记拼接结果将会去除这个位置标记。例如以下宏:
那么这些省略参数的调用:
展开结果如下:
省略的参数由位置标记代替,并在与其它参数拼接或最终展开的过程消失。
宏的高级用法
复杂语句作为宏
前面说过多条复杂的语句可以通过宏简化为一条语句,但定义宏时要小心,因为多条语句也会像多个表达式一样可能受上下文影响。
以下给出了一个宏的示例,该宏用于生成一个打印数组各元素的完整语句:
这个宏相比使用函数的优点是,不但能在结果中输出数组名,而且可以通过简单的形式自行提供转换说明符来打印各种类型的元素,或使用各种打印格式:
但是这个宏有一些问题:虽然这个宏用起来像一条语句,但它替换后会产生不止一条一条语句。如果这个宏的上下文只接收一条语句,那么可能会出现一些问题。例如,假设这个宏被用在一个 if
语句中:
那么当宏展开后,if
真正起作用的只有宏中的第一条语句:
一种更合理的定义方式是给替换体中的语句加上花括号:
但是这种情况下,如果 if
语句有对应的 else
语句,又会出现新的问题:
当宏展开后,问题就比较明显了:
编译器会将 if
大括号后的分号视为一条空语句,使得后面的 else
无法配对这条 if
语句而产生错误。不过所幸这个问题在编译时就会显露,因此比上面的方案更好一些。
可以通过不在宏后面加上分号,或强制所有 if
等语句必须加上花括号解决这个问题,但这样会使得涉及该宏的代码必须得小心处理,总的来说这种方案明显不能让人满意。
对这个问题的一种标准的处理技巧是将语句放在 do while
循环中,并将条件设置为假:
这样做的好处是,do { ... } while (0)
语句会执行但只会执行一次,并且在宏定义时故意省略了 while (0)
后面的分号,在使用时需要补全分号让这个宏用起来更像一个语句,也不会和上下文其它结构发生冲突。大多数编译器在编译时也会将循环相关的代码去除,可以说完美地解决了这个问题。
除此之外,还有一种容纳多条语句的定义方式是使用逗号运算符,将多个表达式放在一条语句中:
但对于较复杂的情况(如上面示例中多条语句还包含 for
循环),逗号表达式就无法处理了。
宏与Generic
C11 新增了泛型选择表达式(generic selection expression) _Generic
,可根据表达式的类型(即表达式的类型是 int
、double
还是其它类型)选择一个值。泛型选择表达式不是预处理器指令,但是在一些泛型编程中它常用作宏定义的一部分。
_Generic
是一个关键字,它后面的圆括号中包含多个用逗号分隔的项:第 1 个项是一个表达式,后面的每个项都由类似 float: 1
的一个类型、一个冒号和一个值组成。下面是一个泛型选择表达式的示例:
第1个项的类型匹配哪个标签,整个表达式的值是该标签后面的值。例如,假设上面表达式中 x
是 float
类型的变量,x
的类型匹配 float:
标签,那么整个表达式的值就是 1
。如果没有与类型匹配的标签,表达式的值就是 default:
标签后面的值。泛型选择语句与 switch
语句类似,只是前者用表达式的类型匹配标签,而后者用表达式的值匹配标签。
下面是一个把泛型选择语句和宏定义组合的示例,泛型选择表达式使得宏可以在打印变量时,自动根据变量的不同类型选择不同的转换说明:
这个宏对于不同类型的变量适应情况很好:
最后要注意的是,泛型选择表达式用起来更像一个函数,因为它不会在预处理阶段展开它的结果。
更高级的变参宏
变参宏有个使用技巧:通过背后定义一系列辅助作用的宏,变参宏不仅能接收任意个参数,还能在接收不同个数参数时生成完全不同的代码。
首先需要定义一个这样的宏:
这个宏的功能为:不管传入了多少个参数,它固定只展开第 11 个参数,然后抛弃所有前面或后面的参数。根据这个特性,可以定义如下的变参宏,使得当参数个数改变时,它的第 11 个参数始终对应传入的参数个数:
以下是一些使用示例:
有了 VA_ARGS_N
宏后,就可以利用它完成一些操作了:例如当传入参数个数改变时,利用 ##
操作符拼接为不同的宏名,从而实现不同的展开。这一过程的思路为:
range(low, high) → __range_2(low, high)
range(start, stop, step) → __range_3(start, stop, step)
一个实现示例如下,注意为了确保正确的展开,需要定义多层的宏:
这个宏对于一些简单场景还是比较好用的,而且这一过程发生在预处理阶段,实际运行时不会产生任何额外的开销。
X-macro
X-macro 是宏一个强大的使用技巧。X-macro 通过使用宏定义技术来生成重复代码,从而自动维护一张列表的所有变量。
X-macro 的基本概念是定义一个列表宏,该宏包含对另一个宏的多次调用,例如:
X-macro 的精髓在于,通过在每次展开 MAKE_COLORS
时提供不同的 COLOR
定义,可以使这张列表中的变量用于不同的场景中。例如,当需要提供一系列变量定义时:
MAKE_COLORS
展开后可以生成一系列结构变量。再如,如果要初始化一个数组,那么将 COLOR
宏重定义为:
再如,如果要打印这些颜色变量的参数,那么只需要提供定义:
这样的好处是,当需要增加一个新的颜色时,只需要在 MAKE_COLORS
宏中增加一行 COLOR(purple, 0x994cc3)\
,而不需要在每个使用到这组颜色的地方手动添加。当需要用到这组颜色时,只需要提供一个 COLOR
宏,MAKE_COLORS
就可以用于不同的场景。这种技术虽然会使得代码更难理解,但对于复杂的生成逻辑或是大量重复代码使用时可以大幅减少维护成本。
总的来说,宏作为 C 语言提供的一种功能强大的预处理工具,在不损失代码运行效率的同时可以实现非常强大的效果,一定程度上也方便维护代码。但是过度使用宏也会造成一些缺点,例如导致代码的可读性降低,且难以调试。如何平衡宏的便利性与潜在问题,是在使用宏时必须考虑的问题。
参考资料/延伸阅读
Macros (The C Preprocessor) - GNU
Preprocessor - cppreference.com
其它大部分内容都是阅读《C Primer Plus》和《C语言程序设计:现代方法》的读书笔记。