C/C++可变参数列表实现原理与缺陷
欢迎转载,转载请注明出处!原文地址
Follow me on GitHub ^_^
C/C++支持函数的可变参数列表,这个可变参列表是通过宏来实现的,这些宏定义于
stdarg.h
头文件,它是标准库的一部分。这个头文件声明了一个类型va_list
和三个宏——va_start va_arg va_end
。我们可以声明一个类型为va_list
的变量,与这几个宏配合使用,访问参数的值。
——《C和指针》第二版
由于将
va_start va_arg va_end
定义成了宏,可变参数的类型和个数在改函数中完全由程序代码控制,并不能智能地进行识别,所以导致编译器对可变参数的函数原型检查不够严格,难于查错,不利于写出高质量的代码。
——《编写高质量代码:改善C++程序的150个建议》
今天我们就来探索一下可变参数列表的实现原理与缺陷
编译环境:win7 32bit vs2013 C语言编译器
平台工具集:Visual Studio 2013 - Windows Xp (v120_xp)
宏定义实现代码
在此只考虑关键实现部分,所以只展示了默认情况下执行的代码
// 1
...
typedef char * va_list
...
// 2
#define va_start _crt_va_start
#define va_arg _crt_va_arg
#define va_end _crt_va_end
// 3
#ifdef __cplusplus
#define _ADDRESSOF(v) ( &reinterpret_cast<const char &>(v) )
#else /* __cplusplus */
#define _ADDRESSOF(v) ( &(v) )
#endif /* __cplusplu */
// 4
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
// 5
#define _crt_va_start(ap,v) ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
// 6
#define _crt_va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
// 7
#define _crt_va_end(ap) ( ap = (va_list)0 )
解读
编号代表上面对应的注释下面的代码
-
将
va_list
类型定义为char *
-
封装了一层
-
如果是C语言的话这个宏定义仅仅是一个取址的操作,但如果是C++的话就有意思了:
首先
&reinterpret_cast<const char &>
是告诉编译器强制将某个类型对象内存解释为另一种类型(违反了“类型安全性”),然后强转成字符型引用再取地址所以,这里使用强制类型转换是违背C++设计理念的
-
这是一个求经过对齐后的数据的实际内存大小:
- 第一个表达式
sizeof(n) + sizeof(int) - 1
在这里可以理解为sizeof(n) + 3
,加上3
是因为接下来需要与4 == sizeof(int)
取模,而这个值是内存对齐的采用的地址对齐值 - 整个表达式合起来是(
N
代表第一个表达式):N & ~(sizeof(int) -1) == N & ~3
这其实就是对4 == sizeof(int)
取模的位运算表达式
- 第一个表达式
-
所以就很明确了:就是将
ap
指向在内存(函数栈的参数区)中v
的后面紧邻的数据的首地址 -
解析这个表达式:
- 表达式
ap += _INTSIZEOF(t)
根据第4、5步的理解,是将ap
指向内存(函数栈的参数区)中下个数据的首地址 - 表达式
(ap += _INTSIZEOF(t)) - _INTSIZEOF(t)
得到的结果就是ap
在上一步偏移前的地址 - 通过
*(t *)
就是通过当前指针获得t
类型的内存数据
所以整个表达式目的就是获取当前
ap
指针指向的t
类型数据,并将ap
偏移到下个数据首地址 - 表达式
-
将
ap
置为空指针
验证
代码与运行结果
#include <stdio.h>
#include <stdarg.h>
void simplePrint(char *format , ...){
// 保存可变参信息
va_list ap;
// 指向字符串指针,通过移动获取单个字符
char* pch = format;
// 更新ap信息
va_start(ap , format);
while(*pch != '\0'){
// 当前字符不为 % 时打印当前字符,并开始下次循环
if(*pch != '%'){
printf("%c" , *pch);
// 指向下个字符
++pch;
continue;
}
// 当前字符为 % 时
// 指向下个字符
++pch;
switch(*pch){
// 根据字符内容进行相应打印
case 'd':
printf("%d" , va_arg(ap , int));
break;
case 'c':
printf("%c" , va_arg(ap , char));
break;
case 's':
printf("%s" , va_arg(ap , char*));
break;
case 'f':
// 在此用 f 代表 double 型
printf("%lf" , va_arg(ap , double));
break;
default:
break;
}
// 指向下个字符
++pch;
}
// 停止对ap的监控与操作
va_end(ap);
}
// 调用
// 在此用 f 代表 double 型
simplePrint("hi, %s, %d, %f, %c" , "Hello world" , 4 , 8.8, 'k');
单步调试进入函数后查看函数栈内存分布情况:
下图为simplePrint()
函数在被调用时其栈的情况:
-
地址
0x0018FBD4
保存的是函数第一个参数"hi, %s, %d, %f, %c"
在常量区的地址 -
地址
0x0018FBD8
保存的是函数可变参的第一个参数"Hello world"
在常量区的地址 -
地址
0x0018FBDC
保存的是函数可变参的第二个参数4
的值 -
地址
0x0018FBE0
保存的是函数可变参的第三个参数8.8
的值 -
地址
0x0018FBE8
保存的是函数可变参第第四个参数'k'
的值
现在来观察函数执行过程中内存的变化
-
执行
va_start(ap, format)
后:此时,指针
ap
指向了函数栈中可变参列表的第一个参数的地址0x0018FBD8
,这就说明了宏va_start
的功能:将ap
指针偏移到第一个固定参数在函数栈中所占空间(经过对齐的实际所占空间空间,而非理论占用的空间)之后的位置(下一个参数的首地址) -
第一次执行
va_arg(ap, char*)
后:同样,指针
ap
偏移到了可变参的第二个参数的地址0x0018FBDC
,同时也获取到了可变参第一个第一个参数的内容(这里没有用变量保存获取到的结果,而是直接传给了printf
函数,所以此时已经可以在命令行看到打印出的参数的内容了) -
第二次执行
va_arg(ap, int)
后: -
第三次执行
va_arg(ap, double)
后: -
第四次执行
va_arg(ap, char)
后:此时我们发现,虽然已经获取完了所有参数,但是
ap
指针还是偏移到了第四个参数在栈中所占空间后面的位置,所以此时我们要是再执行一次va_arg()
的话还可以获取到内存中的值,只是这个值不确定而已另外,为什么第四个参数
'k'
是字符型数据,而且理论上只占1字节,但是为什么还偏移了4个字节,这是内存对齐的结果,详细可看后面“关于内存对齐”的分析 -
执行
va_end(ap)
后:我们发现这个结果和前面分析宏的功能相同,就是将
ap
置为空指针 -
命令行输出:
所以通过上面的单步调试分析得出,这几个宏仅仅是指针偏移并获取相应的数据,而并没有进行类型检查,因为有强制转换的存在也无法做到类型检查
关于内存对齐
调用代码:
simplePrint("hi, %s, %d, %f, %c, %c" , "Hello world" , 4 , 8.8, 'k', 'm');
下图为simplePrint()
函数被调用时其栈的情况:
我们注意到,在函数栈中,所有的参数都是以能被4整除的地址开始存储的。而且第四、五个参数('k' 'm'
)虽然本身只占1字节,但是编译器却默认给他们都分配了4字节的空间,以保证他们存储的起始地址是能被四整除的
因此,当我们在栈中获取下一个参数的内容时,都要将指针偏移当前参数在内存中实际占用的空间大小,所以这时就需要以4字节对齐了,或者说以sizeof(int)
字节对齐
缺陷分析
上面提到了这几个宏根本没有做类型检查,所以我们来看看下面两句的执行结果
simplePrint("hi, %s, %d, %d, %c" , "Hello world" , 4 , 8.8, 'k');
simplePrint("hi, %s, %c, %f, %c" , "Hello world" , 4 , 8.8, 'k');
当我们把格式控制符输错时,结果完全无法控制
请记住:
编译器对可变参数函数的原型检查不够严格,所以容易引起问题,难于查错,不利于写出高质量的代码。所以应当尽量避免使用C语言方式的可变参设计,而使用C++中更为安全方式(多态性)完美代替之。
——《编写高质量代码:改善C++程序的150个建议》