概述
本文重点分析论证了,在可变参函数参数表中指定变参个数的必要性,以及指定变参列表首元素的不必要性,是对C&C++ 可变参函数设计与实践系列文章的进一步扩展。在整理《语言基础 /C&C++ 可变参函数设计与实践》相关文章内容的时候,发现,在变参函数实践过程中,存留了两个问题:
1、变参函数的形参列表中,一定要指定可变参数个数吗?函数 printf 和 vprintf 没有指定变参个数?
2、变参函数的形参列表中,一定要指定用户变参数列表的首个元素吗?
@History
如上两个问题并不大,也不很关键,不影响我对变参函数的实现和使用过程,但每每想起来,就觉得不舒服。本来是将 “可变参函数,强制参数不可以是char和short等类型”、“可变参函数,必须要指定可变参数的个数?”、“回调变参函数和替代变参函数方案” 等相关内容塞到一篇文章中的,奈何篇幅太长,故拆分出此篇文章。
转载请标明原文链接,
https://blog.csdn.net/quguanxin/category_6223029.html
语法符号 …
先要说明的一点是,…省略号是什么? 它是一种语法结构(符号)。
//指定参数个数做可变函数强制参数
void print_Integers2(int param_count, ...) {
va_list argptr;
va_start(argptr, param_count); //初始化变参列表
for (int i = 0; i < param_count; i++) {
int value = va_arg(argptr, int);
//do something .., or save first..
qDebug("test2_Param%d:%d ", i+1, value);
}
va_end(argptr); //清空变参列表
}
int main() { //Test
print_Integers2(3, 100, 200, 300);
return 0;
}
如上述函数定义中,省略号 … 它并不是关键字或占位符,而是一种特殊的语法结构,用于表示函数可以接受可变数量的参数(列表)。这里,省略号 …语法结构符号,其作用是告诉编译器,在函数体内可以通过某些手段(如 <stdarg.h> 头文件中的宏和类型)来访问和处理可变参数列表中的参数。
变参函数的强制参数
在代表变参列表的语法符号… 前,要至少有一个确定的形参,当然,你可以在变参函数形参类表中添加多个确定的参数。紧挨着…的那个确定参数,也即最后一个确定参数,一般称为变参函数的强制参数,如上文 print_Integers2 函数中的 param_count,如下文 print_Integers1函数中的 param_one。
在QtCreator+Mingw下,我们测试一个没有强制参数的变参函数实现,
如上,va_start 宏,并不能接受 null 实参,那会导致编译器崩溃,故强制参数是 va_start 宏函数的钢需,强制要求存在。在《语言基础 /C&C++ 可变参函数设计与实践,强制参数不可以是char和short等类型!》中,我们讲解了强制参数的类型问题,这里不再赘述。
没必要指定变参首元素
在很长一段时间内,我自定义的变参函数都是同时指定参数个数和用户参数列表的第一个元素做确定形参,如,
//某项目中抽出来的伪代码 /Here功能上...是与xBoardType相同数据类型的参数
bool BuildDynamicDevModel(THandleBoard *ptUserDt, unsigned int xBoardTypeCount, unsigned int xBoardTypeOne, ...)
{
//存储变参列表的变量/首个元素直接赋值,其他置零
unsigned int ParamList[/*MAX_COUNT*/10] = {xBoardTypeOne, 0};
//参数序号
unsigned char u8Index = 0;
va_list args; //
va_start(args, xBoardTypeCount); //初始化变参列表,可变参数列表的起始位置
for (u8Index = 0; u8Index < xBoardTypeCount; u8Index++) {
// //warning if short or unsigned char /+1: for skip u8BoardTypeOne
ParamList[u8Index + 1] = va_arg(args, int);
}
va_end(args); //清理va_list参数列表
//do someting by ParamList...
}
脱离项目,我们单独看一个完整的测例,
//'不太优雅的'惯用格式?
void print_Integers1(short param_count, int param_one, ...) {
qDebug("test1_Param1:%d ", param_one);
va_list argptr;
va_start(argptr, param_one); //start
for (int i = 0; i < param_count-1; i++) {
param_one = va_arg(argptr, int);
qDebug("test1_Param%d:%d ", i+1, param_one);
}
va_end(argptr); //End
}
也记不清楚是从哪里习得的上述模式,或者是如何自己摸索出来的,它们是准确无误运行的。只是但每每看到上述样式的变参函数签名,一股莫名的拙劣感就迎面而来。最根本的,由于变参表首元素–变参param_one的引入,使得变参类表的处理过程略显繁琐,又是加1又是减1的。那么最初的我,为什么要加param_one这个参数呢?
回顾历史,
我当时误认为,va_start 是需要变参的首元素来进行初始化的,而不能以其他的参数来初始化,尤其是当变参为结构体指针等类型的时候。但实际上我担心过火了,任意合法数据类型(如int、long等)的强制参数,都可以作为va_start的实参,且没有必要与变参元素是相同的类型。这个已经验证过多次了,上节中的 print_Integers2 函数就是一个很好的例子,此处不再赘述。
承上启下,
截止前文,我们已经消除了概要中的第2个问题,即,变参列表中,没有必要指定变参表的首元素。而且通过前文中我们也明白了变参函数强制参数的功能和必要性,一个变参函数不能只包含…这个语法结构符号。
自以为是,找到了不定义’变参个数’的方法
在很久前的一次记录中,我定义了函数,
void TestVaridicFuncOfStruct(void *pvUserDt, TParam *args, ...) {
...
}
可能是当时不知道哪里来的小幸运,其执行过程竟然没有出现任何异常,那导致我在一段时间内误以为是发现了新大陆,以为我找到了不用添加 ’ 变参个数形参’ 的方法。但后来的测试,这种幸运没有再出现过,我都怀疑自己当时是看花眼了,从而自己骗了自己许久。
//只携带参数列表首元素,做普通形参
void print_Integers3(int param_one, ...) {
short index = 0;
qDebug("test3_Param_%d:%d ", ++index, param_one);
//
va_list argptr;
va_start(argptr, param_one); //初始化变参列表
do {
int param_other = va_arg(argptr, int);
if (0 == param_other) break;
qDebug("test3_Param_%d:%d ", ++index, param_other);
} while (true);
va_end(argptr); //清空变参列表
qDebug("test3_Param_End");
}
int main() {
//首元素是单独的形参
print_Integers1(3, 100, 200, 300);
//所有变参统一使用...定义
print_Integers2(3, 100, 200, 300);
//不传递变参个数
print_Integers3(100, 200, 300);
...
}
如上 print_Integers3 的处理结果,并没有按照预期输出结果,期望的是只遍历出3个实参,而是出现了第4个假实参。由于变参类型只是普通的int类型,上述异常并未导致程序崩溃。
我们看点更危险的,
void TestVaridicFuncOfStruct2(void *pvUserDt, ...) {
va_list ap;
int param_index = 0; //参数编号
va_start(ap, pvUserDt);
do {
TParam *args = va_arg(ap, TParam*);
if (args == 0) break;
qDebug("argIndex[%d] argAddr[0x%.16llx], argValue[%d-%d] ",
param_index, (unsigned long long)args, args->iSeg1, args->iSeg2);
param_index++;
} while (param_index < MAXARGS);
va_end(ap);
}
程序输出,
param_index == 2 时已是异常,其==3时,如下,程序直接崩溃,
通过上述两个测试用例和运行结果,基本可得,变参数目这一信息对于变参列表的正确解析似乎是必要的。
函数 printf 和 vprintf 隐式的指明了变参个数
//
int __CRTDECL printf(char const* const format, ...);
//
int __CRTDECL vprintf(const char* format, va_list argptr);
如上,printf 和 vprintf 函数都是 C 标准库中的函数,都用于根据提供的格式化字符串和参数列表进行输出,它们的第一个参数同是格式化字符串 format,用于指定输出的格式,不同的是可变参数列表的形式。在大多数实现中,printf函数的底层实现可能使用了vprintf函数,即,printf函数会将format字符串和可变参数列表传递给vprintf函数来进行实际的输出,但具体实现会因编译器和操作系统而异。
下文以 GNU C 库实现为例(可在此 链接A / 链接B 页面下载源码),观察 printf 和 vprintf 函数的实现,
将下载到的 glibc-2.9.tar.gz 解压后,分别找到 printf 和 vprintf 的实现如下,
// file : printf.c
#undef printf
/* Write formatted output to stdout from the format string FORMAT. */
int __printf (const char *format, ...) {
va_list arg;
int done;
va_start (arg, format);
done = vfprintf (stdout, format, arg);
va_end (arg);
return done;
}
// file : vprintf.c
#undef vprintf
/* Write formatted output to stdout according to the format string FORMAT, using the argument list in ARG. */
int __vprintf (const char *format, __gnuc_va_list arg) {
return vfprintf (stdout, format, arg);
}
因此在glibc中,printf 函数的最终依托 vfprintf 函数,其实现比较庞杂,下文只截取小部分,
/* The function itself. */
int vfprintf (FILE *s, const CHAR_T *format, va_list ap) {
...
/* This table maps a character into a number representing a class. In each step there is a destination label for each class. */
static const int jump_table[] = {
...
/* '0' */ 5, /* '1' */ 8, /* '2' */ 8, /* '3' */ 8,
/* '4' */ 8, /* '5' */ 8, /* '6' */ 8, /* '7' */ 8,
/* '8' */ 8, /* '9' */ 8, 0, 0,
0, 0, 0, 0,
0, /* 'A' */ 26, 0, /* 'C' */ 25,
0, /* 'E' */ 19, /* F */ 19, /* 'G' */ 19,
...
};
#define NOT_IN_JUMP_RANGE(Ch) ((Ch) < L_(' ') || (Ch) > L_('z'))
#define CHAR_CLASS(Ch) (jump_table[(INT_T) (Ch) - L_(' ')])
...
#define STEP0_3_TABLE \
/* Step 0: at the beginning. */ \
static JUMP_TABLE_TYPE step0_jumps[30] = { \
...
REF (width), /* for '1'...'9' */ \
REF (mod_long), /* for 'l' */ \
...
REF (form_float), /* for 'E', 'e', 'F', 'f', 'G', 'g' */
...
}; \
/* Step 1: after processing width. */ \
static JUMP_TABLE_TYPE step1_jumps[30] = \
...
/* Step 2: after processing precision. */ \
static JUMP_TABLE_TYPE step2_jumps[30] = \
...
/* Step 3a: after processing first 'h' modifier. */ \
static JUMP_TABLE_TYPE step3a_jumps[30] = \
...
/* Step 3b: after processing first 'l' modifier. */ \
static JUMP_TABLE_TYPE step3b_jumps[30] = \
....
#define STEP4_TABLE \
/* Step 4: processing format specifier. */ \
static JUMP_TABLE_TYPE step4_jumps[30] = \
....
不再细究,有兴趣可直接研读 glibc - vfprintf.c 文件 。vfprintf实现思路还是读明白了一丁点的,当 vfprintf 函数遇到一个特定的格式化输出类型时,它会通过格式化字符串中的格式指示符来索引跳转表,并根据对应的索引值来获取变参列表中的实参和相应的处理函数。无论 vfprintf 的实现多么精妙绝伦,本质上,它都是解析 format 格式化字符串中的占位符,包括其个数、类型、对应的实参等,我们也可以根据这种思路,实现类似的可变参函数,甚至是是简单的自定义语义的某种功能。
伪代码版本的 vfprintf 实现,
如上,也感受到了,实际的 printf/vprintf 函数处理了非常多的格式化符号、选项和功能,性能优化措施和不一般的复杂性。现在的我是真的有些吃不消,于是写了如下示例,来简要叙述格式化字符串是如何表达参数个数这一信息的,并不做其他深究。
#include <stdio.h>
#include <stdarg.h>
int my_printf(const char* format, ...)
{
va_list argptr;
va_start(argptr, format);
int count = 0;
const char* p = format;
while (*p != '\0') {
if (*p == '%') {
p++; // 跳过 '%'
switch (*p) {
case 'd': {
int value = va_arg(argptr, int);
printf("%d", value);
count++;
break;
}
case 's': {
char* str = va_arg(argptr, char*);
printf("%s", str);
count++;
break;
}
// 其他格式化符号的处理...
default:
putchar(*p);
break;
}
}
else {
putchar(*p);
}
p++;
}
va_end(argptr);
//
return count;
}
int main() {
int a = 10;
char* str = "Hello";
my_printf("Value: %d, String: %s\n", a, str);
return 0;
}
通过上述简单的示例程序,可知,在逐字符解析 format 格式化字符串的过程中,就很容易得到了期望的实参的个数,在此我们姑且称之为隐式声明了变参数个数。也很容易理解到:
如果实际参数多于格式化字符个数,则多出来的被舍弃;如果实参类型与格式字符不匹配可能带来程序异常;如,
上文将 LINE 错误地使用 %s格式化字符串接收,导致程序崩溃。类似的,如果实际参数少于格式化字符个数,则程序行为是未定义的,如访问的内存溢出到了变参实参栈外…
宏函数 va_arg 透析
在前文中,从实践层面上就基本验证了,对于变参函数的实现,指定变参数的个数是必要的,无论是显示的还是隐式的。因为变参函数的实现过程中,我们并不能通过判断 NextParam 值为 0 或 NULL 等来有效结束变参列表的遍历过程。接下来,我们透过对 va_arg 宏函数的分析,从理论上,谈谈为啥要指定变参个数…
我们早知道 va_arg 函数 是C语言标准库中定义的一个宏,用于在可变参数函数中访问可变参数列表中的下一个参数。以 VS2015 为例,截取 va_arg 函数在部分OS类型下的定义。
如上定义在 Microsoft Visual Studio 14.0\VC\include\vadefs.h 文件下,并最终通过 Microsoft Visual Studio 14.0\VC\include\stdarg.h 头文件导出给用户使用。只关注 va_arg 定义,如下,
//river.qu/Microsoft Visual Studio 14.0\VC\include\vadefs.h //#elif defined _M_IX86
#define _INTSIZEOF(n) ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
#define __crt_va_arg(ap, t) (*(t*)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
//river.qu/Microsoft Visual Studio 14.0\VC\include\vadefs.h //#elif defined _M_X64
#define __crt_va_arg(ap, t) \
((sizeof(t) > sizeof(__int64) || (sizeof(t) & (sizeof(t) - 1)) != 0) \
? **(t**)((ap += sizeof(__int64)) - sizeof(__int64)) \
: *(t* )((ap += sizeof(__int64)) - sizeof(__int64)))
//river.qu/其他来源的定义/看看别当真/使用数组负下标访问是没有问题的
#define va_arg(ap, type) (*(type *)(ap += _INTSIZEOF(type)))[-1]
在 《语言基础 /C&C++ 可变参函数设计与实践,变参函数语法与实现方法》文中,已详细讲述了 va_list 类型和系列宏函数,这里不再过多赘述,只回顾下重点关联内容。
如上 va_arg 定义中,参数 ap 是一个 va_list 类型(大多时候它本质是char*)的变量,用于存储可变参数的信息,参数 t 是要获取的参数的类型(如int,char*, structT* 等)。
粗略地观察 X86和X64上的定义,va_arg 宏就是通过指针运算来访问可变参数的值,它首先将ap指针按照参数类型的大小进行偏移(+=操作会变更ap自身),然后将指针转换为指定类型的指针(对当前的ap指针先加再减参数类型大小,等于直接强转了变更前的ap指针),并通过解引用操作获取参数的值。
因此,在使用va_arg宏时,必须确保提供的参数类型与实际的可变参数类型匹配,以避免指针操作异常。如果变参函数实现者,不能有效感知参数个数,或者实际参数个数与感知到参数个数存在差异,轻则造成信息丢失,重则造成程序崩溃,前文printf 函数的描述中也有提到,不再赘述。
小节
通过相关联的几遍文章的整理,对变参函数的语法、实现、调用等逐渐明晰。在 ‘强制参数类型不必与变参元素类型相同’ 、‘变参个数是必要输入信息’ 等结论的基础上,容易让人想到的一点是:既然参数个数是必须的,强制参数也是必须的,那么,直接使用 ‘参数个数’ 这个确定参数来做 ‘强制参数’ 也许是一种不错的选择。
很明确,宏 va_arg 本身并不能自动识别可变参数的个数。在可变参数函数中,参数的个数通常是通过额外的机制传递给函数,比如,使用函数参数列表中的一个确定参数来指定可变参数的个数,比如,使用格式化字符串format(后续章节有详解),或者约定如0xFFFF之类的特殊值代变参表遍历过程结束等。至此,彻底放下了对变参函数实现中 param_count 形参的偏见,意识到了它的必要性。