本文讨论了如何有效地使用变参函数,以及背后的实现细节

简介

在C++语言中,函数经常被使用到,并可以提供良好的模块化设计。通常来说,每个函数都有固定数量的实参(argument)或者形参(parameter),在使用这些参数的时候要指明其数据类型。同时,这些函数有1个或者0个变量或常量(构造函数和析构函数)作为返回值。一个典型的C++函数如下所示:

ReturnType function(Datatype var1, Datatype var2)

你曾经遇到过含有可变数目参数的函数吗?一个最经常使用的函数便是printf。如果你有一些UNIX背景知识的话,你肯定看到过下面的两个函数:

int execl(const char *path, const char *arg, …);
int execlp(const char *file, const char *arg, …);

execl函数提供了一种将当前调用进程转换为一个新进程的方法。这些函数能够接受可变数目的参数,但最后一个参数必须为NULL。例如:

execl(“/bin/echo”, “echo”, “Ghulam”, “Ishaq”, “Khan”, “Institute”, NULL);

这个例子传递了包含末尾参数NULL共计7个参数给execl函数。现在再看看下面这个函数调用。

execl(“/bin/echo”, “echo”, “Code”, “Project”, NULL);

这次我们传递了5个参数给该函数。可见,execl函数可以接受可变数目的实参。这些函数都被定义在unistd.h(一个Unix系统头文件)中。Windows平台上也有与此类似的函数_execl和_execlp,它们定义在头文件process.h中。printf也是一种变参函数。

那么,如何定义变参函数?程序运行时对这些参数是如何获取的呢?

如何定义和使用变参函数?

好,首先,变参函数需要至少一个固定的参数。如果这个用来占位的参数没有被提供,就没有办法获取其他的参数了。这类函数以省略号(…)作为最后一个形参声明,通知编译器该函数可能包含可变个数的参数。例如,一个累加N个整型数的函数可以声明为如下形式:

int add(int first, …);
注意:Microsoft C++规定,在省略号与之前的最后一个参数之间必须有“,”(逗号)间隔。因而,在Microsoft C++中,int add(int x, …);是合法的但int add(int x …);是不合法的。

如何获取这些数量可变的参数值?

标准头文件本身支持这个功能,它提供了一些相应的宏方法以便获取到这些数量可变的参数值。变参函数包含一个指示结束的参数,即NULL参数。正如上述execl函数那样,尽管这个参数不会被使用,但其作为结束标记,指示了函数参数列表结束的位置。下面,让我们看看这些参数具体是如何获取的。

int add(int x, …)
{
  va_list list; 
  //调用va_start()宏,将va_list(char *)变量list初始化为首个未知参数的地址
  va_start(list, x);

  int result = 0;

   for(;;)
   {
    //在循环内,获取每一个参数值
    //va_arg宏的第2个参数为预获取变量的数据类型
    int p = va_arg(list, int);
    if(p == 0)
      break;
    result += p;
   }

   //清理操作,设置list为NULL
   va_end(list);
}

首先,我们定义了一个va_list变量,并通过调用va_start()将其初始化。va_list()是一个宏,它包含两个参数,分别是va_list变量名和最后一个形式参数的地址。然后,va_arg()宏被用来获取未知可变参数的值。在每一次调用过程中,预获取参数的数据类型都要被指定。而va_arg()宏也假定传递过来的实际参数也为该数据类型。但是,要注意的是,这仅仅是个约定,在被调用函数中是没有任何方法确认传递的实参的真正的数据类型。上面这个例子的功能是累加所有的整数,因此所有的va_arg()调用中第二个参数均为int。参数值返回后,对其进行检查,若为0(即NULL)则判定为最后一个参数,这时函数退出for循环并且返回计算后的结果。在这之前,我们必须调用va_end()宏,并将va_list变量名传递给它。这么做的原因是,函数一开始调用的va_start()的时候,可能会修改栈数据,导致最后函数不能够正常返回,而调用va_end()宏可以将这些栈数据恢复回去。

关于可变参数函数的知识就这些吗?当然不是,事情没有这么简单。还有一些其他的知识点在使用这些函数的过程中需要格外注意。

变量的类型提升(Variable Promotions)

如果变量未声明,编译器得不到任何信息,从而不能以对其进行标准类型检查和类型转换。对于变参函数,省略号的使用将导致程序不能对参数进行强制类型安全检查,这确实是C++的一个主要目标,而不是BUG,尽管有时被某些人批评为一种坏的程序设计实践。但是,你可能不可避免地要经常需要使用一些变参函数,尤其是在处理一些C语言旧式风格函数的时候。与形式参数和实际参数均已声明的函数相比,声明中包含省略号的函数对于参数类型转换的规则也是不同的。


1.    函数被调用时,所有float类型的实参将被类型提升为double类型。
2.    通过整型类型提升,所有有符号/无符号的char、short、枚举类型或者位字段将被类型转换为相应的有符号/无符号int类型。
3.    所有class类型的参数将作为一个数据结构体整体进行值传递,其备份通过二进制拷贝创建,而不是调用类的拷贝构造函数(如果存在拷贝构造函数的话)。
 
可见,如果传递的参数类型为float,获取的参数类型将被转换为double;如果传递的参数类型是char或short,获取的参数类型将为signed int或unsigned int。看看下面的例子,这段代码将返回一个错误的结果。

float add(float x, …)
{
  va_list list;
  va_start(list, x);
  float result = 0;
 
  for(;;)
  {
     //此处,我们调用va_arg(),传递float作为预返回的数据类型
     //但实际上,float型参数却已经被类型提升为double型
     float p = va_arg(list, float);
     if(p == 0)
      break;
     result += p;
  }
  va_end(list);
  return result;
}

  返回结果错误的原因是float类型和double类型占用的字节数是不一样的。编译器传递的变量类型为double,而程序却将类型指定为float。当程序通过增加float型所占的字节大小,将list变量递增指向下一个参数地址的时候,list便指向一个错误的地址。正确的方法是将参数类型指定为double。最后,根据需要可以将变量强制类型转换回float类型即可。

float add(float x, …)
{
  va_list list;
  va_start(list, x);
  float result = 0;

  for(;;)
  {
     //注意:此处输入的实际参数为float型,根据变量类型提升的原则将被类型转换为double型,故指定double作为预获取参数的数据类型。
     float p = (float)va_arg(list, double);
     if(p == 0)
      break;
     result+=p;
  }
  va_end(list);
  return result;
}

  其它的类型提升在函数调用时的使用与此类似。

这些宏在后台是怎么工作的?

下面我们讨论一下这些宏的实现,以及编译器是如何运用这些宏获取参数值的。

  va_list是通过typedef定义的用以表示可变参数列表的自定义类型,其定义在stdio.h文件:

typedef char* va_list

  va_start宏通过最后一个固定参数的地址和所占字节大小,计算出下一个参数(即第一个未知参数)的地址,并用该值初始化va_list变量。现在,这个va_list变量便指向第一个未知参数了(即包含第一个位置参数的地址)。va_start定义如下所示:

#define va_start(ap,v) (ap=(va_list)&v+_INTSIZEOF(v))

  如果变量“v”定义为寄存器类型变量,这个宏的行为将是未定义的,即可能得到错误的结果。va_start宏必须保证在va_arg首次调用之前使用。通过va_arg宏,我们可以得到下一个未知变量的参数值,该宏声明如下:

#define va_arg(ap,t) (*(t*)((ap+=_INTSIZEOF(t))-_INTSIZEOF(t)))

  该宏的实现中包含了一些小技巧。在表达式(ap+=_INTSIZEOF(t))执行后,ap指针递增并指向下一个类型为t的参数的地址。假定这个值表示为X,现在这个宏可以表示成:

#define va_arg(ap,t) (*(t*)(X-_INTSIZEOF(t)))

  (X - _INTSIZEOF(t))的结果正是变量ap在执行(ap+=_INTSIZEOF(t))递增操作之前的值,假定这个值用Y表示,该宏又可表示为:

#define va_arg(ap,t) (*(t*)Y)

  这时将值Y强制类型转换为t*型,并从该指针指向的地址获取到下一个t类型参数的值。这样,对于每一次调用va_arg宏,都将返回下一个参数的值,并将变量ap递增。最后,调用va_end()宏将ap指针设置为NULL,如下:

#define va_end(ap) (ap=(va_list)0)

另一个问题:如果末尾已知参数是引用类型,结果会如何?

如果末尾的已知参数是引用类型,会导致错误结果,并且通过使用上述讨论的宏本身是无法得到解决的。下面重点分析这一点。

通过上面的讨论可以看出,我们是根据末尾已知参数的地址和类型,来计算第一个未知变参的地址,即将第一个变量参数类型所占的字节数与末尾已知变量地址相加,得到第一个变参的地址。在C++中,如果对一个引用类型变量进行取地址操作(即&),将得到被引用变量的地址。va_start宏通过末尾命名参数的地址来计算并定位到随后的参数,这时,如果这个末尾命名参数是一个引用,va_start宏得到的将不是当前调用栈的某个地址,而是被末尾命名参数引用的原变量在内存中的下一个变量的地址(该变量可能位于上一个调用栈空间或者全局内存空间内),这将导致不可预测的后果。

  为了解决这个问题,可以在你的代码中使用下面的宏来代替前面讨论的宏定义:

#ifdef va_start
#undef va_start

#ifdef _WIN32
#define va_start(ap,v){int var=_INTSIZEOF(v);\
__asm lea eax,v __asm add eax,var\
__asm mov ap,eax\
}
#else
#define va_start(ap,v){int var=_INTSIZEOF(v);\
__asm lea ax,v __asm add ax,var\
__asm mov ap,ax\
}
#endif
#endif

  这里,我们将汇编代码内嵌到C语言代码内。首先,通过lea指令将参数v的有效内存地址装载到寄存器(注意,通过装载v的有效内存地址或实际内存地址,我们最终解决了问题),累加数据类型所占的字节数,并将值存储回ap指针参数。具体使用例子详见本文附件varargRef_src.zip。

  使用变参函数是一个不良好的程序设计实践。Bajarne Stroustrup在他的著作《The C++ Programming Language(第二版)》中说:
  “在一个定义良好的程序中,要尽可能的减少没有完全指定参数类型的函数。在大多数情况下,除非有意不指定参数类型,否则重载函数和使用默认参数的函数都能够很好地对参数进行类型检查。只有当参数的数目和类型不确定的时候才有必要使用省略符。

  好,关于可变参数函数的讨论就到这里,希望各位能够从中得到自己想要的东西。

许可声明

  本文未声明明确的使用许可协议,但文章或附件内可能包含某些使用条款。如有疑问,请通过下面的留言板与作者取得联系。

附件
argfunctions_src
www.codeproject.com/KB/cpp/argfunctions/argfunctions_src.zip
 
关于作者
CodeProject
用户名:abc876
职  业:Web开发者
国  籍:巴基斯坦

原文链接
  www.codeproject.com/KB/cpp/argfunctions.aspx

译者
  jizhouli

注1:鉴于个人水平所限,文章翻译难免存在错误或不妥之处,欢迎与本人联系指正,谢谢。
注2:转载请注明原作者及译者信息,谢谢。