第 17 章 类函数宏
本章涵盖:
- 核验宏实参
- 访问宏调用语境
- 使用可变实参宏
- 为函数调用提供默认实参
我们已经显式(见第 10.2.1 节)或隐式地遇到过类函数宏。C 标准库中的某些接口通常用它们实现,例如 <tgmath.h> 中的类型泛型接口。我们也已经看到,类函数宏很容易使代码晦涩难懂,并且要求遵循一套严格规则。要避免类函数宏带来的许多问题,最简单的策略是:只在它们不可替代时使用;凡是能够替代,就采用适当的其他手段。
要点 17 #1
只要可能,就优先选择内联函数,而非类函数宏。
也就是说,当实参数量固定且类型已知时,应当以函数原型的形式提供真正类型安全的接口。假设有一个带副作用的简单函数:
unsigned count(void) {
static counter = 0;
++counter;
return counter;
}2
3
4
5
现在,考虑使用一个宏对值求平方:
#define square_macro(X) (X*X) // 糟糕:不要使用。
...
unsigned a = count();
unsigned b = square_macro(count());2
3
4
这里,square_macro(count()) 会被替换为 count()*count(),从而执行两次 count。缺乏经验的读者大概不会在此处预料到这种结果。
练习 7
证明 b == a*a + 3*a + 2。
要取得与类函数宏相同的性能,在头文件中提供内联定义就完全足够:
inline unsigned square_unsigned(unsigned x) { // 良好
return x*x;
}
...
unsigned c = count();
unsigned d = square_unsigned(count());2
3
4
5
6
这里,square_unsigned(count()) 只会执行一次 count。
练习 8
证明 d == c*c + 2*c + 1。
不过,许多情况下,类函数宏确实能完成函数做不到的事情。它们可以:
- 强制执行某些类型映射与实参核验;
- 跟踪执行;
- 提供实参数量可变的接口;
- 提供类型泛型接口;
- 为函数提供默认实参。
本章将说明怎样实现这些特性。我们还会讨论另外两项应当明确区分的 C 特性:_Generic,因为它适用于宏;如果没有宏,使用它会十分繁琐。另一项是可变实参函数,因为它如今大多已经过时,不应在新代码中使用。
这里还应当给出一项警告:宏程序设计很快就会变得丑陋,几乎无法阅读,因此你需要耐心与善意,才能理解这里的一部分代码。来看一个示例:
#define MINSIZE(X, Y) (sizeof(X)<sizeof(Y) ? sizeof(X) : sizeof(Y))右侧替换序列相当复杂,其中含有四次 sizeof 求值,以及组合它们的若干运算符。不过,这个宏用起来不应困难:它只是计算实参大小的较小者。
要点 17 #2
类函数宏应当为复杂任务提供简单接口。
17.1 类函数宏怎样工作
为了提供前面列出的特性,C 选择了一条与其他流行编程语言大不相同的道路:文本替换。如前所述,宏会在编译很早的一个阶段——预处理——中替换。替换遵循 C 标准规定的一套严格规则;同一平台上的所有编译器,都应当把任意源代码预处理成完全相同的中间代码。
在示例中再加入:
#define BYTECOPY(T, S) memcpy(&(T), &(S), MINSIZE(T, S))现在有两个宏定义,分别对应 MINSIZE 和 BYTECOPY。第一个带有形参列表 (X, Y),定义了两个形参 X 和 Y,替换文本为:
(sizeof(X)<sizeof(Y) ? sizeof(X) : sizeof(Y))其中引用了 X 和 Y。类似地,BYTECOPY 有两个形参 T 和 S,其替换文本以 memcpy 开头。
这些宏满足我们对类函数宏的要求。它们只对每个实参求值一次,把所有实参都放在 () 中,而且没有意外控制流之类的隐藏效果。
练习 9
为什么每个实参只求值一次?
宏的形参必须是标识符。一项特殊的作用域规则把这些标识符的有效范围限制在替换文本内部。
当编译器遇到类函数宏名称,后面又跟着一对闭合的 () 时——例如 BYTECOPY(A, B)——它会把这视为宏调用,并按照以下规则执行文本替换:
- 暂时禁用该宏定义,以避免无限递归。
- 扫描
()内的文本,也就是实参列表,以查找括号和逗号。每一个左括号(都必须与一个)匹配。不位于这种附加()内的逗号,用来把实参列表分成各个实参。这里,实参数量必须与宏定义中的形参数量匹配。 - 递归展开各实参中可能出现的宏。在示例中,
A本身可能是另一个宏,并展开成某个对象名,例如redA。 - 把实参展开得到的文本片段赋给相应形参。
- 复制替换文本,并把形参的每次出现替换为相应定义。
- 再次对所得替换文本执行宏替换。
- 在源代码中用最终替换文本取代宏调用。
- 重新启用该宏定义。
这套过程乍看有些复杂,实际却很容易实现,也提供了可靠的替换次序。它保证避免无限递归和复杂的局部对象赋值。在这里,展开 BYTECOPY(A, B) 会得到:
memcpy(&(redA), &(B),
(sizeof((redA))<sizeof((B)) ? sizeof((redA)) : sizeof((B))))2
我们已经知道,宏的标识符(无论是否为类函数宏)处在自己的命名空间中,原因非常简单。
要点 17.1 #1
宏替换发生在很早的翻译阶段,早于对组成程序的记号赋予任何其他解释。
因此,预处理阶段对后续翻译阶段的关键字、类型、对象或其他构造一无所知。
由于宏展开显式禁用递归,函数甚至可以与类函数宏使用同一个标识符。例如,下面是有效的 C:
inline
char const* string_literal(char const str[static 1]){
return str;
}
#define string_literal(S) string_literal("" S "")2
3
4
5
它定义了函数 string_literal,该函数接收字符数组作为实参;同时还定义了同名宏,以一种古怪的方式整理实参后调用函数。稍后会看到这样做的原因。另有一条更专门的规则,帮助我们处理宏与函数同名的情况。它类似于函数退化(要点 11.4 #1)。
要点 17.1 #2(宏保留)
类函数宏后面如果没有 (),就不会展开。
在前面的示例中,函数与宏的定义依赖其出现次序。如果先给出宏定义,函数定义会立即展开成类似:
inline
char const* string_literal("" char const str[static 1] ""){ // 错误
return str;
}2
3
4
这是错误代码。不过,如果用括号围住名称 string_literal,它就不会展开,仍是有效定义。完整示例如下:
// 头文件
#define string_literal(S) string_literal("" S "")
inline char const* (string_literal)(char const str[static 1]){
return str;
}
extern char const* (*func)(char const str[static 1]);
// 一个翻译单元
char const* (string_literal)(char const str[static 1]);
// 另一个翻译单元
char const* (*func)(char const str[static 1]) = string_literal;2
3
4
5
6
7
8
9
10
也就是说,内联定义和实例化函数的声明都由周围的 () 保护,不会展开类函数宏。最后一行展示了该特性的另一种常见用途。这里,string_literal 后面没有 (),所以会应用两项规则:首先,宏保留抑制宏展开;随后,函数退化(要点 11.4 #1)把函数的使用求值为指向该函数的指针。
17.2 实参核验
如前所述,如果实参数量固定,而且 C 类型系统能够妥善建模其类型,就应使用函数,而非类函数宏。我们甚至可以为 C 库函数提供更适合核验实参的接口:
snprintf_swapped
与 snprintf 类似,但会依据大小核验缓冲区实参。
为实现这种核验,函数先接收大小实参,再接收缓冲区;于是可以把缓冲区指定为依赖该大小的数组形参。
[[maybe_unused, __gnu__::__format__(__printf__, 3, 4)]]
static inline
int snprintf_swapped(size_t n, char s[restrict static n],
char const* restrict form, ...) {
va_list ap;
va_start(ap);
int ret = vsnprintf(s, n, form, ap);
va_end(ap);
return ret;
}2
3
4
5
6
7
8
9
10
这个函数只是一个小包装器,因此声明为 static 和 inline,并放在头文件中。这样一来,几乎可以确定它在每个调用位置都会内联,产生的额外开销很小。对该函数的调用可以核验第一和第二个实参是否一致,尤其可以核验所提供缓冲区是否至少具有第二个实参所指明的大小。
稍后会看到怎样从一个更加通用的包装器中使用这个函数,以取代 C 库函数 snprintf(见第 18.2 节)。该函数还使用 <stdarg.h> 中的宏 va_start,把可变形参列表的信息存入 va,再把整体信息转发给 C 库函数 vsnprintf;该函数专门处理这种 va_list 信息(见第 17.4.2 节)。
遗憾的是,C 的类型系统无法覆盖我们可能希望区分的所有特殊情况。一个示例是想传给 printf 等潜在危险函数的字符串字面量。如第 5.6.1 节所述,字符串字面量只读,却没有 const 限定。此外,语言并不强制执行早先 string_literal 函数那样使用 [static 1] 的接口,因为不带 [static 1] 的原型与之等价。
C 没有任何方式规定函数接口的形参 str 必须满足以下约束:
- 是字符指针;
- 必须非空;
- 必须不可修改;[1]
- 必须以 0 结尾。
这些性质尤其适合在编译期核验,但函数接口中根本没有表达它们的方法。
宏 string_literal 填补了语言规约中的这项空白。其展开中的古怪空字符串字面量 "" X "",确保只能以字符串字面量调用 string_literal:
string_literal("hello"); // "" "hello" ""
char word[25] = "hello";
...
string_literal(word); // "" word "" // 错误2
3
4
宏与函数 string_literal 只是这项策略的简单示例。下面是更有用的例子。
TRACE_PRINT0
一个简单版本的宏:要么调用 fprintf,要么什么也不做。
#if NDEBUG
# define TRACE_PRINT0(F, X) do { /* 什么也不做 */ } while (false)
#else
# define TRACE_PRINT0(F, X) fprintf(stderr, F, X)
#endif2
3
4
5
在程序的调试构建中,可以使用该宏插入调试输出:
TRACE_PRINT0("my favorite variable: %g\n", sum);它看起来无害而高效,却有一个陷阱:实参 F 可以是任意 char 指针,尤其可能是位于可修改内存区域的格式字符串。这会造成一种后果:错误或恶意地修改该字符串,可能产生无效格式,进而使程序崩溃或泄露秘密。第 17.4 节会更详细地说明,这对 fprintf 之类的函数为何格外危险。
在示例那样的简单代码中,我们把简单字符串字面量传给 fprintf,这些问题不应出现。现代编译器实现能够追踪传给 fprintf(及类似函数)的实参,核验格式说明符是否与其他实参匹配。
如果传给 fprintf 的格式不是字符串字面量,而只是任意 char 指针,这项核验就不起作用。为抑制这种情况,可以强制使用字符串字面量:
TRACE_PRINT1
一个简单版本的宏,确保 fprintf 的格式是字符串字面量。
作为附加功能,它还会在输出末尾加入换行符,使使用者不必每次指定换行。
#if NDEBUG
# define TRACE_PRINT1(F, X) do { /* 什么也不做 */ } while (false)
#else
# define TRACE_PRINT1(F, X) fprintf(stderr, "" F "\n", X)
#endif2
3
4
5
现在,F 必须接收字符串字面量,编译器随后就能完成工作,并警告不匹配。
宏 TRACE_PRINT1 仍有一个弱点。如果在设置了 NDEBUG 的情况下使用,实参会被忽略,也就不会核验一致性。长期后果可能是:不匹配一直未被发现,直到调试时突然出现。
因此,宏的下一个版本分两步定义。第一步使用类似构造定义新宏 TRACE_ON。
TRACE_ON
根据是否设置 NDEBUG,求得 0 或 1 的宏。
#ifdef NDEBUG
# define TRACE_ON 0
#else
# define TRACE_ON 1
#endif2
3
4
5
程序员可能把 NDEBUG 宏设置为任意值,而这个新宏保证只保存 1 或 0。第二步,使用普通的 if 条件定义 TRACE_PRINT2。
TRACE_PRINT2
一个简单版本的宏,确保始终对 fprintf 调用求值。
#define TRACE_PRINT2(F, X) \
do { if (TRACE_ON) fprintf(stderr, "" F "\n", X); } \
while (false)2
3
只要条件实参为 0,任意现代编译器都应当能够优化掉 fprintf 调用。它不应省略的,是对形参 F 和 X 的实参核验。因此,不论是否正在调试,传给宏的实参都必须始终匹配,因为 fprintf 对此作出要求。
与前面使用空字符串字面量 "" 类似,还有其他技巧可以强制宏实参采用特定类型。一项技巧是加上适当的 0:+0 强制实参是标量类型(整数、浮点数或指针);类似 +0.0F 的写法则把任意算术类型提升到浮点类型。
例如,如果只是想在调试时用更简单的形式打印值,而不跟踪值的类型,下面的宏可能已经足够:
TRACE_VALUE0
跟踪一个值,无须指定格式。
#define TRACE_VALUE0(HEAD, X) \
TRACE_PRINT2(HEAD " %Lg", (X)+0.0L)2
只要值 X 是整数或浮点数,它就能工作。用于 long double 的格式 "%Lg" 可以确保以适当方式呈现任意值。现在,实参 HEAD 不得包含任何 fprintf 格式;如果发生不匹配,编译器会告诉我们。
复合字面量可以方便地核验形参 X 的值是否与类型 T 赋值兼容。先考虑第一次尝试打印指针值的版本:
TRACE_PTR0
跟踪指针,无须指定格式。
该版本把 X 强制转换为 void*。
#define TRACE_PTR0(HEAD, X) \
TRACE_PRINT2(HEAD " %p", (void*)(X))2
它尝试用格式 "%p" 打印指针值,该格式期望接收 void* 类型的通用指针。因此,宏使用强制转换,把 X 的值和类型转为 void*。与大多数强制转换一样,如果 X 不是指针,这里的转换就可能出错;由于强制转换告诉编译器我们知道自己在做什么,所有类型核验实际上都被关闭。
可以先把 X 赋给 void* 类型的对象,以避免这一点。赋值或初始化只允许有限的一组隐式转换;这里允许从指向任意对象类型的指针转换到 void*:
TRACE_PTR1
跟踪指针,无须指定格式。
#define TRACE_PTR1(HEAD, X) \
TRACE_PRINT2(HEAD " %p", ((void*){ (X) }))2
技巧是使用形如 ((T){ (X) }) 的构造,核验 X 是否与类型 T 赋值兼容。复合字面量 ((T){ }) 首先创建类型为 T 的临时对象,再用 X 初始化它。现代优化编译器同样应当优化掉临时对象,只为我们完成类型核验。[2]
17.3 访问调用语境
由于宏只是文本替换,它们能够与调用语境进行远为紧密的交互。对于一般功能,这通常并不可取;最好清晰地区分调用方语境(对函数实参求值)与被调用方语境(使用函数形参)。
不过在调试期间,我们往往希望打破这种严格分隔,以观察代码特定位置的部分状态。原则上,可以从宏内部访问任意对象;但通常需要的是关于调用环境的更具体信息:追踪某条调试输出来自何处。
C 为此提供若干构造。特殊宏 LINE 始终展开成一个整数常量,表示源代码中的实际行号:
TRACE_PRINT3
向跟踪信息加入当前行号。
#define TRACE_PRINT3(F, X) \
do { \
if (TRACE_ON) \
fprintf(stderr, "%lu: " F "\n", __LINE__+0UL, X); \
} while (false)2
3
4
5
类似地,宏 DATE、TIME 与 FILE 分别包含表示编译日期、编译时间和当前 TU 名称的字符串字面量。另一个构造 func 是一个局部 static 对象,保存当前函数的名称:
TRACE_PRINT4
向跟踪信息加入当前函数的名称。
#define TRACE_PRINT4(F, X) \
do { \
if (TRACE_ON) \
fprintf(stderr, "%s:%lu: " F "\n", \
__func__, __LINE__+0UL, X); \
} while (false)2
3
4
5
6
如果以下调用位于源文件第 24 行,而且周围函数是 main:
TRACE_PRINT4("my favorite variable: %g", sum);相应输出类似:
main:24: my favorite variable: 889像本例这样自动使用 fprintf 时,还应牢记另一个陷阱:实参列表中的所有实参都必须具有说明符给出的正确类型。对于 func,这不是问题;根据定义,我们知道它是字符数组,所以说明符 "%s" 正确。
LINE 则不同。我们知道它是表示行号的整数常量;大多数时候,它只是一个十进制字面量。回顾第 5.3 节的十进制字面量类型规则,可以看到类型取决于值。在嵌入式平台上,INT_MAX 可能小到 32,767,而非常大的源文件(也许由程序自动生成)可能拥有更多行。出现这种情况时,优秀的编译器应当发出警告。
要点 17.3 #1
LINE 中的行号可能无法装入 int。
要点 17.3 #2
使用 LINE 天生具有危险。
在宏中,我们通过两种方式避免该问题:要么把类型固定为 unsigned long,[3] 要么在编译期间把数字转换成字符串。
调用语境中还有另一类信息经常有助于跟踪:我们实际作为宏实参传入的表达式。由于这经常用于调试,C 为此提供了特殊运算符 #。如果展开时 # 出现在某个宏形参前,传给该形参的实际实参就会字符串化,也就是把它的全部文本内容放入字符串字面量。
下面这个跟踪宏版本包含 #X:
TRACE_PRINT5
加入所求值表达式的文本版本。
#define TRACE_PRINT5(F, X) \
do { \
if (TRACE_ON) \
fprintf(stderr, \
"%s:" STRGY(__LINE__) ":(" #X "): " F "\n", \
__func__, X); \
} while (false)2
3
4
5
6
7
每次调用宏时,#X 都会替换为第二个实参的文本。对于以下调用:
TRACE_PRINT5("my favorite variable: %g", sum);
TRACE_PRINT5("a good expression: %g", sum*argc);2
相应输出类似:
main:25:(sum): my favorite variable: 889
main:26:(sum*argc): a good expression: 17782
由于预处理阶段对这些实参的解释一无所知,这种替换纯粹是文本性的;除空白可能略有调整外,结果应当与源代码中的写法相同。
要点 17.3 #3
使用运算符 # 字符串化时,不会展开实参中的宏。
考虑到前面提到的 LINE 潜在问题,我们还希望直接把行号转换成字符串。这样有两个优点:避免类型问题,而且字符串化完全在编译期完成。如前所述,# 运算符只作用于宏实参,所以直接写 # LINE 不会产生所需效果。
现在考虑下面的宏定义:
#define STRINGIFY(X) #X字符串化发生在实参替换之前,因此 STRINGIFY(LINE) 的结果是 "LINE";宏 LINE 没有展开。所以,这个宏仍不足以满足需求。
现在,STRGY(LINE) 会先展开成 STRINGIFY(25)(假定当前位置是第 25 行),后者再展开成 "25",也就是字符串化后的行号:
#define STRGY(X) STRINGIFY(X)要点 17.3 #4
嵌套宏定义可以多次展开宏实参。
我们已经见过另一种只在预处理阶段有效的运算符:记号拼接运算符 ##。后文将看到,在编写完整的宏库、需要自动生成其他宏名或函数名时,它有何用处。
17.4 可变长度实参列表
我们已经见过接受可变长度实参列表的函数:printf、scanf 及相关函数。它们在形参列表末尾使用记号 ... 表示这项特性;在若干已知的起始实参(例如 printf 的格式)之后,还可以提供任意长度的附加实参列表。
稍后,第 17.4.2 节会简要讨论怎样定义这种函数。由于不具备类型安全性,这项特性很危险,也近乎过时,因此我们不会着重介绍。作为替代,将介绍一种类似特性——可变实参宏;它已经在很大程度上取代函数的对应特性。
17.4.1 可变实参宏
可变长度实参宏,简称“可变实参宏”,使用同一个记号 ... 表明这项特性。与函数一样,该记号必须出现在形参列表末尾:
TRACE_PRINT6
允许在同一条跟踪信息中打印多个实参。
#define TRACE_PRINT6(F, ...) \
do { \
if (TRACE_ON) \
fprintf(stderr, "%s:" STRGY(__LINE__) ": " F "\n", \
__func__, __VA_ARGS__); \
} while (false)2
3
4
5
6
在 TRACE_PRINT6 中,这表示调用时可以在格式实参 F 后提供任意附加实参列表。[4] 展开后的实参列表可以在宏展开中通过标识符 VA_ARGS 访问。因此,下面的调用:
TRACE_PRINT6("a collection: %g, %i", sum, argc);会把实参转发给 fprintf,并产生输出:
main:27: a collection: 889, 2遗憾的是,对于这个宏的当前写法,VA_ARGS 中的列表不能为空或缺席,否则 printf 调用就会出现空实参。最简单的解决办法是为列表缺席的情况单独编写一个宏:
TRACE_PRINT7
只跟踪一条文本消息,不打印值。
#define TRACE_PRINT7(...) \
do { \
if (TRACE_ON) \
fprintf(stderr, \
"%s:" STRGY(__LINE__) ": " __VA_ARGS__ "\n", \
__func__); \
} while (false)2
3
4
5
6
7
利用 C23 的 VA_OPT 特性,可以把两项功能合并进一个宏:
TRACE_PRINT8
既可以跟踪值,也可以在没有值时跟踪。
该实现使用 C23 特性 VA_OPT,在 fprintf 实参末尾加入一个可能为空的可变列表。
#define TRACE_PRINT8(F, ...) \
do { \
if (TRACE_ON) \
fprintf(stderr, "%s:" STRGY(__LINE__) ": " F "\n", \
__func__ __VA_OPT__(,) __VA_ARGS__); \
} while (false)2
3
4
5
6
这里,VA_OPT(,) 确保只有当可变实参列表 VA_ARGS 非空时,才插入逗号。因此,printf 的实参列表始终有效:
TRACE_PRINT8("a collection: %g, %i", sum, argc);
TRACE_PRINT8("another string");2
恰好得到我们想要的输出:
main:29: a collection: 889, 2
main:30: another string2
实参列表的 VA_ARGS 部分也可以像其他宏形参一样字符串化:
TRACE_PRINT9
先给出实参的文本表示,再进行跟踪。
#define TRACE_PRINT9(F, ...) \
TRACE_PRINT8("(" #__VA_ARGS__ ") " F __VA_OPT__(,) __VA_ARGS__)2
下面这些实参的文本表示:
TRACE_PRINT9("a collection: %g, %i", sum*acos(0), argc);会连同分隔实参的逗号一起插入:
main:31: (sum*acos(0), argc) a collection: 1396.44, 2VA_OPT 特性还可以分派到两个不同的目标宏:
#define fprintf(STREAM, FORMAT, ...) \
FPRINTF_II ## __VA_OPT__(Iplus) \
(STREAM, FORMAT __VA_OPT__(,) __VA_ARGS__)
#define FPRINTF_II(STREAM, FORMAT) fputs(FORMAT, STREAM)
#define FPRINTF_IIIplus(STREAM, FORMAT, ...) \
fprintf(STREAM, "" FORMAT "", __VA_ARGS__)2
3
4
5
6
这里,构造 FPRINTF_II ## VA_OPT(Iplus) 会把标识符 FPRINTF_II 与 Iplus 拼接起来(形成 FPRINTF_IIIplus),或者与空记号拼接、保持原样。
宏 FPRINTF_II(带两个实参的 fprintf)把调用分派给 fputs;FPRINTF_IIIplus(带三个或更多实参的 fprintf)则分派回 fprintf。后者还用空字符串字面量 "" 围住格式字符串,所以只有 FORMAT 展开成字符串字面量时,展开才有效。
目前为止,具有可变实参数量的跟踪宏仍必须在格式实参 F 中接收正确的格式说明符。这可能是一项繁琐工作,因为它迫使我们始终跟踪待打印列表中每个实参的类型。内联函数与宏的组合可以提供帮助。先看函数 trace_values。
trace_values
打印值列表的函数。
备注: 只通过宏 TRACE_VALUES 调用该函数;宏会提供必要的语境信息。
inline
void trace_values(FILE* s,
char const func[static 1],
char const line[static 1],
char const expr[static 1],
char const head[static 1],
size_t len,
long double const arr[len]) {
fprintf(s, "%s:%s:(%s) %s %Lg", func, line,
trace_skip(expr), head, arr[0]);
for (size_t i = 1; i < len-1; ++i)
fprintf(s, ", %Lg", arr[i]);
fputc('\n', s);
}2
3
4
5
6
7
8
9
10
11
12
13
14
该函数打印一个 long double 值列表,并在前面加上与此前相同的头部信息。只是这一次,函数通过一个长度已知为 len 的 long double 数组接收值列表。出于稍后会看到的原因,函数始终跳过数组的最后一个元素。它还使用函数 trace_skip 跳过形参 expr 的起始部分。
把语境信息传给函数的宏分为两个层次。第一层 TRACE_VALUES 以不同方式整理实参列表。首先,借助稍后将看到的 ALEN,它求取列表中的元素数;然后把列表字符串化,最后附上列表本身。所有这些内容都传给 TRACE_VALUES0。
TRACE_VALUES
跟踪实参列表,无须指定每个实参的类型。
备注: 该宏用所有实参构造一个临时数组,并把它们全部转换为 long double。因此,始终保证发生到该类型的隐式转换。
#define TRACE_VALUES(...) \
TRACE_VALUES0(ALEN(__VA_ARGS__), \
#__VA_ARGS__, \
__VA_ARGS__ \
)
#define TRACE_VALUES0(NARGS, EXPR, HEAD, ...) \
do { \
if (TRACE_ON) { \
if (NARGS > 1) \
trace_values(stderr, __func__, STRGY(__LINE__), \
"" EXPR "", "" HEAD "", NARGS, \
(long double const[NARGS]){ __VA_ARGS__ }); \
else \
fprintf(stderr, "%s:" STRGY(__LINE__) ": %s\n", \
__func__, HEAD); \
} \
} while (false)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这里,不含 HEAD 的列表用作 long double const[NARGS] 类型复合字面量的初始化器。请注意,NARGS 始终至少为 1,因为计数中包含了 HEAD 字符串,所以数组类型始终有效。其次,如果 NARGS 为 1,初始化器列表为空;自 C23 起,这是一种有效初始化器形式。掌握实参列表长度后,我们还可以区分唯一实参是否只是格式字符串。最后,来看宏 ALEN。
ALEN
返回可变列表中的实参数量。
这个版本适用于最多包含 31 个元素的列表。
备注: 空实参列表视为含有一个空实参。
#define ALEN(...) ALEN0(__VA_ARGS__, \
0x1E, 0x1F, 0x1D, 0x1C, 0x1B, 0x1A, 0x19, 0x18, \
0x17, 0x16, 0x15, 0x14, 0x13, 0x12, 0x11, 0x10, \
0x0E, 0x0F, 0x0D, 0x0C, 0x0B, 0x0A, 0x09, 0x08, \
0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0x00)
#define ALEN0(_00, _01, _02, _03, _04, _05, _06, _07, _08, \
_09, _0A, _0B, _0C, _0D, _0F, _0E, _10, _11, \
_12, _13, _14, _15, _16, _17, _18, _19, _1A, \
_1B, _1C, _1D, _1F, _1E, ...) _1E2
3
4
5
6
7
8
9
其思想是取得 VA_ARGS 列表,并在后面附加一个递减数列:31、30、…、0。然后使用 ALEN0 返回新列表中的第 31 个元素。视原列表长度而定,该元素会是其中某个数。事实上,很容易看出:只要原列表至少包含一个元素,返回的数恰好等于原列表的长度。在我们的使用场景中,至少始终存在格式字符串,所以不会出现空列表这一边界情况。
17.4.2 题外话:可变实参函数
现在简要看一下可变实参函数,也就是带有可变长度实参列表的函数。如前所述,在函数声明中使用运算符 ... 指定它,例如:
int printf(char const format[static 1], ...);这类函数的接口定义存在一个根本问题。与普通函数不同,在调用侧并不清楚某个实参应当转换成哪种形参类型。例如,调用 printf("%d", 0) 时,编译器无法立即知道,被调用函数期望的是哪一种 0。对于这类情况,C 有一套规则,用来确定实参转换到的类型。它们几乎与算术规则相同。
要点 17.4.2 #1
传给可变形参时,所有算术类型都会像参与算术操作时那样转换;唯一例外是 float 实参,它会转换为 double。
因此,尤其是 char 和 short 等类型,传给可变形参时会转换成较宽类型,通常是 int。
到目前为止一切顺利,我们现在知道这类函数怎样被调用。遗憾的是,这些规则丝毫没有说明被调用函数应当期望接收哪种类型。
要点 17.4.2 #2
可变实参函数必须获得有关可变列表中每个实参类型的有效信息。
printf 系列函数通过在格式实参中规定类型来摆脱这一困难。来看一小段代码:
unsigned char zChar = 0;
printf("%hhu", zChar);2
它会对 zChar 求值,将其提升为 int,再把它作为实参传给 printf;后者读取这个 int,并把值重新解释为 unsigned char。这套机制:
复杂:函数实现必须为所有基本类型提供专门代码。
容易出错:每次调用都依赖实参类型正确传给函数。
苛刻:程序员必须核验每个实参的类型。
尤其是,最后一点可能造成严重的可移植性缺陷,因为字面量在不同平台上可能具有不同类型。例如,看似无害的调用:
printf("%d: %s\n", 65536, "a small number"); // 不可移植在大多数平台上工作良好,也就是 int 超过 16 位的平台。但在某些平台上,它可能在运行时失效,因为 65536 是 long。这类潜在失效最糟糕的示例是宏 NULL:
printf("%p: %s\n", NULL, "print of NULL"); // 不可移植第 11.1.5 节已经看到,只保证 NULL 是空指针常量。编译器实现者可以自由选择提供哪一种形式。有些选择类型为 void* 的 (void*)0;大多数选择类型为 int 的 0。在指针与 int 宽度不同的平台上——例如所有现代 64 位平台——结果就是程序崩溃。[5]
要点 17.4.2 #3
除非把每个实参强制为特定类型,否则使用可变实参函数不具备可移植性。
这与前面 TRACE_VALUES 使用可变实参宏的情况很不一样。那里,我们把可变列表用作数组初始化器,所有元素都会自动转换成正确的目标类型。
要点 17.4.2 #4
新接口应避免使用可变实参函数。
它们根本不值得承受这些痛苦。不过,如果必须实现可变实参函数,就需要 C 库头文件 <stdarg.h>。该头文件定义一种类型 va_list,以及四个类函数宏;它们可以访问隐藏在 va_list 后面的不同实参。其伪接口如下:
void va_start(va_list ap, ...);
void va_end(va_list ap);
type va_arg(va_list ap, type);
void va_copy(va_list dest, va_list src);2
3
4
传统上,va_start 还有第二个实参,也就是被调用函数中 ... 之前最后一个形参的名称。自 C23 起,不再需要这项信息,可以省略 va_start 的第二个实参。
与 snprintf_swapped 类似,下面的示例说明怎样避免自行编写可变实参函数的核心部分。凡是涉及格式化打印,都已有现成函数可用。
printf_debug
向调试流 iodebug 打印。
[[gnu::format(printf, 1, 2)]]
int printf_debug(const char *format, ...) {
int ret = 0;
if (iodebug) {
va_list va;
va_start(va);
ret = vfprintf(iodebug, format, va);
va_end(va);
}
return ret;
}2
3
4
5
6
7
8
9
10
11
这里,iodebug 是一个线程局部指针,可能指向有效 IO 流,也可能不指向:
iodebug
一个简单的调试流。
按照约定,它为空时不产生输出。
可以在当前线程内设置它,使不同线程能够把各自的调试消息打印到不同文件或流。
thread_local FILE* iodebug = nullptr;函数中使用 va_start 和 va_end 所做的唯一事情,就是创建 va_list 实参列表,并把这项信息传给 C 库函数 vfprintf。这让我们完全不必进行分类讨论和跟踪实参。
属性的 gnu 前缀表明它特定于编译器(这里是 GCC 及相关编译器)。如果采用已知的形参约定,而且编译器可以作出有益诊断来确保实参有效,这种附加特性可能非常有用。
现在来看一个接收 n 个 double 值并求和的可变实参函数。
练习 15
如果可变实参函数只接收类型完全相同的实参,就可以用可变实参宏和接收数组的内联函数替换它。请完成这种替换。
sumIt
一个小而无用的函数,用来展示可变实参函数怎样工作。
double sumIt(size_t n, ...) {
double ret = 0.0;
va_list va;
va_start(va);
for (size_t i = 0; i < n; ++i)
ret += va_arg(va, double);
va_end(va);
return ret;
}2
3
4
5
6
7
8
9
va_list 由 va_start 调用初始化。请注意,通过某种魔法,va_start 会直接操纵 va;这里不使用取地址运算符 &。随后,在循环内部,通过 va_arg 宏取得列表中的每个值;该宏要求显式指定其类型实参,这里是 double。
我们还必须自行维护列表长度,把长度作为实参传给函数。函数的程序员负责对实参类型进行编码(这里是隐式编码),也负责发现列表末尾。
要点 17.4.2 #5
va_arg 机制无法取得 va_list 的长度。
要点 17.4.2 #6
可变实参函数需要一项专门约定来表示列表长度。
17.5 默认实参
C 库中的一些函数带有若干形参,而绝大多数时候,它们收到的都是同样乏味的实参。strtoul、strtod 及相关函数就是如此。请回想,它们接收多个实参:
unsigned long int strtoul(char const nptr[restrict],
char** restrict endptr,
int base);
double strtod(char const nptr[restrict],
char** restrict endptr);2
3
4
5
第一个实参是待转换字符串;endptr 将指向字符串中数字的末尾。此外,对于 strtoul,base 是解释字符串所用的整数进制。
这里有两项特殊约定:endptr 可以是空指针;如果 base 为 0,就根据字符串前缀选择解释方式——以 "0b" 开头时为二进制,以 "0x" 开头时为十六进制,以 "0" 开头时为八进制,否则为十进制。
绝大多数时候,使用这些函数时都不需要 endptr 特性;如果存在进制实参,通常也把符号进制设置为 0。例如:
int main(int argc, char* argv[argc+1]) {
if (argc < 3) return EXIT_FAILURE;
size_t len = strtoul(argv[1], nullptr, 0);
double piv = strtod(argv[2], nullptr);
...
}2
3
4
5
6
为了避免重复,并让代码读者专注于重要内容,可以引入一层中间宏,在省略 nullptr 和 0 实参时自动补上它们。对于 strtod,因为我们只想提供一个实参,事情相对简单:
#define strtod(NPTR, ...) \
STRTOD_I __VA_OPT__(I) (NPTR __VA_OPT__(,) __VA_ARGS__)
#define STRTOD_I(NPTR) strtod(NPTR, nullptr)
#define STRTOD_II(NPTR, ENDPTR) strtod(NPTR, ENDPTR)
#define strtof(NPTR, ...) \
STRTOF_I __VA_OPT__(I) (NPTR __VA_OPT__(,) __VA_ARGS__)
#define STRTOF_I(NPTR) strtof(NPTR, nullptr)
#define STRTOF_II(NPTR, ENDPTR) strtof(NPTR, ENDPTR)
#define strtold(NPTR, ...) \
STRTOLD_I __VA_OPT__(I) (NPTR __VA_OPT__(,) __VA_ARGS__)
#define STRTOLD_I(NPTR) strtold(NPTR, nullptr)
#define STRTOLD_II(NPTR, ENDPTR) strtold(NPTR, ENDPTR)112
113
114
115
116
117
118
119
120
121
122
与前例中的 printf 一样,这些宏至少接收一个实参 NPTR;其余实参如果存在,就通过 VA_OPT(,) 和 VA_ARGS 构造附加到列表中。展开结果随后分派给不同宏版本:后缀 _I 表示接收一个实参,_II 表示接收两个实参。
strtoul 的情形更加复杂,因为它有两个可以使用默认值的实参:endptr 默认为 nullptr,base 默认为 0。我们将以更加通用的方式处理它,首先准备一些宏,为空实参提供默认值。第一个宏处理一个预期形参:
DEFAULT1
如果存在第二个实参,就输出它;否则输出默认值 DEF0。
给出两个以上实参时产生错误。
#define DEFAULT1(DEF0, ...) \
ID_I ## __VA_OPT__(Iplus_DEFAULT) \
(DEF0 __VA_OPT__(,) __VA_ARGS__)
#define ID_IIplus_DEFAULT(_01, ...) ID_I(__VA_ARGS__)2
3
4
这里的基本思想与前面相同:使用 VA_OPT 在两个不同的目标宏之间分派。这些宏的名称以 ID_ 开头,后附的 I 数量表示它们期望接收多少个实参。
#define ID_()
#define ID_I(_01) _01
#define ID_II(_01, _2) _01, _2
#define ID_III(_01, _2, _3) _01, _2, _3134
135
136
这些宏会输出收到的实参,因此 ID_I 尤其会产生该实参。DEFAULT 宏收到两个或更多实参时,会进入非空分支 ID_IIplus_DEFAULT;该分支同样分派给 ID_I,但传入第二个实参。
总体而言,每次调用 DEFAULT1 的每个分支,最终都会调用 ID_I:
DEFAULT1(a) → ID_I(a) → a
DEFAULT1(a,) → ID_I(a) → a
DEFAULT1(a, A) → ID_IIplus_DEFAULT(A)
→ ID_I(A) → A
DEFAULT1(a, A, B) → ID_IIplus_DEFAULT(A, B)
→ ID_I(A, B) → 错误2
3
4
5
6
尤其可以看到,使用两个以上附加实参调用会产生错误,而有效调用会按预期展开。
对于含有两个默认值的实参列表,我们使用 DEFAULT2:
DEFAULT2
从第三个位置开始输出以逗号分隔的实参,并用前两个位置提供的默认值填充。
给出四个以上实参时产生错误。
#define DEFAULT2(DEF0, DEF1, ...) \
ID_II ## __VA_OPT__(Iplus_DEFAULT) \
(DEF0, DEF1 __VA_OPT__(,) __VA_ARGS__)
#define ID_IIIplus_DEFAULT(DEF0, DEF1, _01, ...) \
DEFAULT1(DEF0, _01), DEFAULT1(DEF1, __VA_ARGS__)2
3
4
5
这里,ID_IIIplus_DEFAULT 分派到对 DEFAULT1 的两次调用:
DEFAULT2(a, b)
→ ID_II(a, b)
→ a, b
DEFAULT2(a, b,)
→ ID_II(a, b)
→ a, b
DEFAULT2(a, b, A)
→ ID_IIIplus_DEFAULT(a, b, A)
→ DEFAULT1(a, A), DEFAULT1(b, )
→ A, b
DEFAULT2(a, b, A,)
→ ID_IIIplus_DEFAULT(a, b, A)
→ DEFAULT1(a, A), DEFAULT1(b, )
→ A, b
DEFAULT2(a, b, A, B)
→ ID_IIIplus_DEFAULT(a, b, A, B)
→ DEFAULT1(a, A), DEFAULT1(b, B)
→ A, B
DEFAULT2(a, b,, B)
→ ID_IIIplus_DEFAULT(a, b, , B)
→ DEFAULT1(a, ), DEFAULT1(b, B)
→ a, B
DEFAULT2(a, b,,, C)
→ ID_IIIplus_DEFAULT(a, b,,, C)
→ DEFAULT1(a,), DEFAULT1(b,, C)
→ a, 错误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
对于三个默认实参,一般模式在宏 DEFAULT3 中更加清晰:
DEFAULT3
从第四个位置开始输出以逗号分隔的实参,并用前三个位置提供的默认值填充。
给出六个以上实参时产生错误。
#define DEFAULT3(DEF0, DEF1, DEF2, ...) \
ID_III ## __VA_OPT__(Iplus_DEFAULT) \
(DEF0, DEF1, DEF2 __VA_OPT__(,) __VA_ARGS__)
#define ID_IIIIplus_DEFAULT(DEF0, DEF1, DEF2, _01, ...) \
DEFAULT1(DEF0, _01), DEFAULT2(DEF1, DEF2, __VA_ARGS__)2
3
4
5
ID_IIIIplus_DEFAULT 把第一个默认值 DEF0 与第一个一般实参 _01 分离出来,传给 DEFAULT1。随后,它以少一个默认值(DEF0)和少一个实参(_01)的形式,“递归”调用 DEFAULT2。
练习 16
像处理 DEFAULT2 一样,计算 DEFAULT3 所有可能实参组合的结果。
为了更加通用地使用这些宏,还必须用宏 CALLN 包装对 DEFAULTN 的调用,由它们实际执行函数调用:
#define CALL1(FUNC, DEF0, ...) \
FUNC(DEFAULT1(DEF0, __VA_ARGS__))
#define CALL2(FUNC, DEF0, DEF1, ...) \
FUNC(DEFAULT2(DEF0, DEF1, __VA_ARGS__))
#define CALL3(FUNC, DEF0, DEF1, DEF2, ...) \
FUNC(DEFAULT3(DEF0, DEF1, DEF2, __VA_ARGS__))161
162
163
164
165
有了这些宏,包装 strto 系列函数调用的目标宏如下:
#define strtoul(...) \
CALL3(strtoul, "0", nullptr, 0, __VA_ARGS__)
#define strtoull(...) \
CALL3(strtoull, "0", nullptr, 0, __VA_ARGS__)
#define strtol(...) \
CALL3(strtol, "0", nullptr, 0, __VA_ARGS__)
#define strtoll(...) \
CALL3(strtoll, "0", nullptr, 0, __VA_ARGS__)168
169
170
171
172
173
174
只要调用这些函数时省略了某些实参,这就会加入默认值 "0"、nullptr 和 0。因此,只用一个实参调用 strtoul 时,替换序列如下:
strtoul(argv[1])
→ CALL3(strtoul, "0", nullptr, 0, argv[1])
→ strtoul(DEFAULT3("0", nullptr, 0, argv[1]))
→ strtoul(argv[1], nullptr, 0)2
3
4
如果改用三个非空实参调用 strtoul:
strtoul(argv[1], ptr, 10)
→ CALL3(strtoul, "0", nullptr, 0, argv[1], ptr, 10)
→ strtoul(DEFAULT3("0", nullptr, 0, argv[1], ptr, 10))
→ strtoul(argv[1], ptr, 10)2
3
4
替换序列最终有效地产生与起始时相同的记号序列。
小结
- 类函数宏比内联函数更加灵活。
- 它们允许我们实现具有可变实参列表的类型安全特性。
- 它们可以为函数接口补充编译期实参核验。
- 它们可以提供来自调用环境的信息。
- 它们可以提供默认实参。