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 )

解读

编号代表上面对应的注释下面的代码

  1. va_list类型定义为char *

  2. 封装了一层

  3. 如果是C语言的话这个宏定义仅仅是一个取址的操作,但如果是C++的话就有意思了:

    首先&reinterpret_cast<const char &>是告诉编译器强制将某个类型对象内存解释为另一种类型(违反了“类型安全性”),然后强转成字符型引用再取地址

    所以,这里使用强制类型转换是违背C++设计理念的

  4. 这是一个求经过对齐后的数据的实际内存大小:

    1. 第一个表达式sizeof(n) + sizeof(int) - 1在这里可以理解为sizeof(n) + 3,加上3是因为接下来需要与4 == sizeof(int)取模,而这个值是内存对齐的采用的地址对齐值
    2. 整个表达式合起来是(N代表第一个表达式):N & ~(sizeof(int) -1) == N & ~3这其实就是对4 == sizeof(int)取模的位运算表达式
  5. 所以就很明确了:就是将ap指向在内存(函数栈的参数区)中v的后面紧邻的数据的首地址

  6. 解析这个表达式:

    1. 表达式ap += _INTSIZEOF(t)根据第4、5步的理解,是将ap指向内存(函数栈的参数区)中下个数据的首地址
    2. 表达式(ap += _INTSIZEOF(t)) - _INTSIZEOF(t)得到的结果就是ap在上一步偏移前的地址
    3. 通过*(t *)就是通过当前指针获得t类型的内存数据

    所以整个表达式目的就是获取当前ap指针指向的t类型数据,并将ap偏移到下个数据首地址

  7. 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()函数在被调用时其栈的情况:

现在来观察函数执行过程中内存的变化

所以通过上面的单步调试分析得出,这几个宏仅仅是指针偏移并获取相应的数据,而并没有进行类型检查,因为有强制转换的存在也无法做到类型检查

关于内存对齐

调用代码:

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个建议》