去年参加MSC的决赛的时候,遇到了关于变参函数的问题。其实这类函数很套路,但是考虑到之后想写的关于宏的一篇文章中有些东西跟变参函数有些类似,而且网上关于变参函数的教程都几乎是千篇一律、不中要害,因此先写一篇关于变参函数的文章,对其使用方法进行探讨。


〇、引言-常规的函数

  在C语言中,函数在声明时往往就要指定参数的数量与类型,编译的时候,编译器也会替我们检查,看函数调用有无问题。

  例如:

#include <stdio.h>
int func(int i, int j)
{
} 
int main()
{
	func(100); // 实参数目不对
	return 0;
}

  此时编译器就会报错:

error: too few arguments to function 'func'
func(100);
^

  再如:

#include <stdio.h>
int func(int i, int j)
{
}
int main()
{
	int* a; 
	func(a, 100);
	return 0;
}

  此时编译器不会报错(at least for GCC),但是会给出一个Warning:

warning: passing argument 1 of 'func' makes integer from pointer without a cast
  func(a, 100);
       ^
note: expected 'int' but argument is of type 'int *'
 int func(int i, int j)
     ^

  在这里我是故意用的一个指针,如果你用的是double或者char的话,连warning都不会出现,到时候一个强制类型转化塞给func()

  于是,我们大部分时候接触到的函数就都是这种参数个数、类型都不可变的函数了。

  但其实有一个函数,我们从Hello World就开始用了,而且几乎目前的每个程序里都要用到,而它其实是一个变参函数。

一、printf() scanf() 就是变参函数!

  没错,C 语言中最常用的可变参数函数例子其实就是printf()scanf()。仔细想想,每次你在调用这两个函数的时候,其后面的参数个数以及类型是不是的确在变化?

  为了进一步了解其实现的机制,我们来看看这两个函数的函数原型。(以TDM-GCC为例)

int __cdecl printf(const char * __restrict__ _Format,...);

int __cdecl scanf(const char * __restrict__ _Format,...) __MINGW_ATTRIB_DEPRECATED_SEC_WARN;

  其中的...就是变参函数中可变参数部分的声明。

二、变参函数的特点

  我们来就这两个经典的函数,简要分析一下变参函数的特点。

1)一定要有固定数量的强制参数(mandatory argument)

  变参函数必须至少有一个强制参数。

  在这两个函数中,它体现为一个格式化字符串——const char * __restrict__ _Format

2)省略号(…)代表可选参数

  在格式化字符串之后,紧跟着的是一个逗号和三个点构成的省略号。这个省略号代表的就是可选参数。可选参数的类型可以变化。

3)参数列表的格式是强制性参数在前、可选参数在后

  如果我强行在可选参数之后再添加一个强制参数,比如下面的一波操作:

int sumplus(int count, ..., int m)
{
}

  编译会报错:

error: expected ')' before ',' token
 int sumplus(int count, ..., int m) 
                           ^

  一定会有人说,可选参数在最后不应该是天经地义的吗?但是到后面你就会发现我这番尝试并不是空穴来风。

4)编译器和函数并不知道可选参数有多少个

  肯定又会有人说,printf() scanf() 不是可以按照后面参数的要求输出、输入吗?确实可以,但是它们真的不知道自己后面有几个参数。

  实验为证:

#include <stdio.h>
int main()
{
	printf("%d\n", 100, 200);
	return 0;
}

  编译通过:

Compilation results...
--------
- Errors: 0
- Warnings: 0

  运行试试?

100

  杠精会说:嗯,那是printf()自动帮你把后面多的参数扔掉了。

  Really?

  依然以实验为证:

#include <stdio.h>
int main()
{
	printf("%d %d %d %d %d %d\n", 100, 200);
	return 0;
}

  依然编译通过:

Compilation results...
--------
- Errors: 0
- Warnings: 0

  再运行试试?

100 200 17 42 4199400 0

  开始乱码了……

  这就已经充分说明编译器和函数完全不知道你给它了多少给参数。它们完全不知道这些,只是按照一定的规则在试图读取这些可变的参数。那规则是什么呢?答案马上揭晓。

三、实现变参函数的方法

0)搬运过来的题目背景介绍

  在 C 调用约定下我们可以使用 va 系列宏来轻松的实现一些变参函数,例如:

#include <stdarg.h>
int sum(int count, ...) 
{
   	va_list args;
   	va_start(args, count);
   	int res = 0;
   	for (int i = 0; i < count; i++) 
   	{
   		int val = va_arg(args, int);
   		res += val;
   	}
   	va_end(args);
   	return res;
}

  代码本身很简单,这里稍微解释。

  va_list args; 定义一个指向可变参数列表的指针

  va_start(args, count); 使参数列表指针指向函数参数 count

  va_arg(args, int) 把参数列表指针当前所指位置以类型 int 读取出来并移动参数列表指针

  va_end(args) 清空参数列表。va_startva_end 必须一一对应 。”每次调用va_start() / va_copy()后,必须得有相应的va_end()与之匹配。参数指针可以在参数列表中随意地来回移动,但必须在va_start()va_end()之内

  当然大家可以发现这种形式的可变参数函数是非常危险的,va_arg 基本就是强制类型转换,而且读取的参数个数也只能通过用户输入来确认,很容易出现访问参数越界的情况。同时 va_arg 取出的参数类型和实际传入的类型不一致,或是访问最后一个参数之后的参数是未定义行为,在不同平台、不同编译器实现下的结果可能不完全相同,所以在使用这些函数的时候也请大家务必注意参数的个数和类型问题。

  这段话不是我写的。我是直接从决赛题的题目背景里搬过来的。其实它讲的已经比较清楚了,网上的各种教程基本上都是这段代码和解释。但是,有几点想与大家探讨一下。

1)关于可变参数的读取个数的问题

  最后一段话中其实解释了这个问题:读取的参数个数也只能通过用户输入来确认

  这就是为什么前面printf()的例子中没有报错的原因了——编译器不会检查函数的参数个数。printf()完全是依赖格式化字符串中的种种要求来读取后面的可变参数的。而这又引发了另一个问题,何时停止读取?——依然依赖格式化字符串中的要求。因此,当格式化字符串中有要求输出、而后面的可变参数中并没有对应参数时,就出现了访问参数越界的情况——这也正是前面输出乱码的原因。

2)关于va_start()

va_start(args, count); 使参数列表指针指向函数参数 count

  这句话这么说其实不太合适。(刚刚还说这段话讲得比较清楚……,就当是出题人美中不足吧)

  要解释清楚va_start(args, count);,我们还是来做实验为好。

#include <stdio.h>
#include <stdarg.h>
int sumplus(int count, int m, ...) 
{
    va_list args;
    va_start(args, count);
    int res = 0;
    for (int i = 0; i < count; i++) 
    {
        int val = va_arg(args, int);
        res += val;
    }
    va_end(args);
    return res;
}
int main()
{
	int a = sumplus(3, 2, 1, 3, 100);
	printf("%d\n", a); 
	return 0;
}

  这段代码编译会有warning:

warning: second parameter of 'va_start' not last named argument [-Wvarargs]
     va_start(args, count);
     ^

  运行试试?

104

  这就说明编译之后,参数还是从 ... 的位置开始读取的。(应该是编译器帮忙优化的,自动将指针挪到last named argument那里了)

  我们来看看GCC中va_start()的声明。

#define va_start(v,l)	__builtin_va_start(v,l)

  emmm,意义不大……

  再看看VC6.0中的声明:

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

  看来这是将第一个可选参数的地址赋值给ap。(至少在VC6.0里面是这样的)

  因此va_start()的作用是使参数列表指针指向第一个可选参数。

  引用一下cplusplus对于va_start()的介绍来证明和完善这一观点。

va_start

void va_start (va_list ap, paramN);

Initialize a variable argument list

Initializes ap to retrieve the additional arguments after parameter paramN.

A function that invokes va_start, shall also invoke va_end before it returns.

Parameters

  • ap

Uninitialized object of type va_list. After the call, it carries the information needed to retrieve the additional arguments using va_arg. If ap has already been passed as first argument to a previous call to va_start or va_copy, it shall be passed to va_end before calling this function.

  • paramN

Name of the last named parameter in the function definition. The arguments extracted by subsequent calls to va_arg are those after paramN.

3)关于va_arg()

C 语言参数传递时有自动类型提升,char 会被 cast 成 int 再传过去,所以在拿的时候也得拿一个 int

  什么意思呢?一会看到实例就明白了。简单说,就是提取可变参数时,所提取的变量类型不一定与其实际类型一致,比如char就需要以int类型的方式读取。

4)关于va_end()

参数指针可以在参数列表中随意地来回移动,但必须在va_start()va_end()之内

  还记得我在前面提到过的一个看似无用的实验吗?int sumplus(int count, ..., int m)并不能通过编译。但是我为什么会想到去尝试?理由很简单,既然 va_start()va_end() 是成对出现的,那么是否会存在这么一种参数结构,先有几个强制参数,再是可变参数,最后又是几个强制参数,中间调用可变参数时使用 va_start()va_end() ?实验证明这是不允许的,可变参数就是必须放在最后,这是规定与要求。

  可是既然如此,那va_end()意义何在?可以不调用吗?

  老规矩,先做个实验看看:

#include <stdarg.h>
int sumplus(int count, ...) 
{
    va_list args;
    va_start(args, count);
    int res = 0;
    for (int i = 0; i < count; i++) {
        int val = va_arg(args, int);
        res += val;
    }
//    va_end(args);
    return res;
}

  编译通过:

Compilation results...
--------
- Errors: 0
- Warnings: 0

  运行也没毛病……

  所以,真的可以省?

  几经周折,在网上找到这么一篇文章(这是原作者的文章,CSDN上面的是转载的,好在还注明了原文地址),建议大家去看看。我这里只引用其中的一部分。

1.不调用可能导致程序崩溃

  从一个使用过va_start()的函数中退出之前,必须调用一次va_end()。   这是因为va_start可能以某种方式修改了堆栈,这种修改可能导致返回无法完成,va_end()能将有关的修改复原。       ——《C++程序设计语言》 第3版、特别版, p139

2.不调用可能导致内存泄漏

  我们务必记住,在使用完va_list变量后一定要调用宏va_end。   v在大多数C实现上,调用va_end与否并无区别。   但是,某些版本的va_start宏为了方便对va_list的遍历,就给参数列表动态分配内存。   这样一种C实现很可能利用va_end宏来释放此前动态分配的内存;   如果忘记调用宏va_end,最后得到的程序可能在某些机型上没有问题,而在另一些机型上则发生“内存泄露”。       ——《C陷阱与缺陷》, p161

3.还是不想调用?……

…… 最后,必须在函数返回之前调用va_end,以完成一些必要的清理工作。       ——《C程序设计语言》 第2版, p137

……在所有参数处理完毕后, 且在退出函数f之前必须调用宏va_end一次 ……       ——《C程序设计语言》 第2版, p232

  插句题外话:**按照规矩办事,其实想得比你重要!**看看中国那稀烂的安卓生态吧,不多说了……

四、自己实现printf()(决赛原题)

题目描述

这道题需要你实现一个简单的 printf ,接受一个格式化字符串和若干参数,将结果打印至标准输出。

满足:

  1. 使用 '$' 作为转义字符,在需要输出 '$' 字符的时候重复一次 '$' , 其它情况下视为输出对应类型的参数
  2. 支持 $d 输出 32 位整数(int), $s 输出字符串(char*)直至 '\0'$f 输出双精度浮点数 (double), 保留 6 位小数 , $c 输出单个字符 (char),保证输入合法
  3. 返回其中打印的字符个数
  4. 保证 '$' 后不会出现上述提及情况以外的字符

函数接口定义

int simple_printf(const char* fmt, ...);

其中 fmt 为传入的格式化字符串,... 为传入的其它参数,返回输出的字符串长度

裁判测试程序样例

#include <stdio.h>
#include <stdarg.h>

int simple_printf(const char* fmt, ...);

int main() {
 printf("%d\n", simple_printf("123 "));
 printf("%d\n", simple_printf("$$ "));
 printf("%d\n", simple_printf("$d ", 123));
 printf("%d\n", simple_printf("$c ", '1'));
 printf("%d\n", simple_printf("$s ", "123"));
 printf("%d\n", simple_printf("$f ", 123.4));
 return 0;
}

/**
*	your code here
**/

保证单次调用输出的长度小于 4096,输出的浮点数保留 6 位小数

输入样例

没有输入

输出样例

123 4
$ 2
123 4
1 2
123 4
123.400000 11

  虽然我当时一遍就AC了,但是做复杂了(比赛时没有注意到printf()返回值就是打印的字符的个数,自己还单独实现了这个功能…),而且代码也只是能跑而已(比赛…时间很紧……),可读性很差,所以借这个机会重新整理一下好了。

AC代码(标程改编)

int simple_printf(const char *fmt, ...)
{
	va_list args;
	va_start(args, fmt);
	int counter = 0;
	while (*fmt != '\0')
	{
		if (*fmt != '$')
		{
			putchar(*fmt);
			fmt++;
			counter++;
			continue;
		}
		fmt++;
		if (*fmt == 'd')
		{ // integer
			int int_val = va_arg(args, int);
			counter += printf("%d", int_val);
			fmt++;
		}
		else if (*fmt == 'f')
		{ // double
			double float_val = va_arg(args, double);
			counter += printf("%lf", float_val);
			fmt++;
		}
		else if (*fmt == 's')
		{ // char*
			char *str = va_arg(args, char *);
			counter += printf("%s", str);
			fmt++;
		}
		else if (*fmt == 'c')
		{ // char
			char cha_val = va_arg(args, int);
			counter += 1;
			putchar(cha_val);
			fmt++;
		}
		else if (*fmt == '$')
		{
			fmt++;
			putchar('$');
			counter += 1;
		}
	}
	va_end(args);
	return counter;
}

  有两点需要注意:

  1. C 语言参数传递时有自动类型提升,char 会被 cast 成 int 再传过去,所以在拿的时候也得拿一个 int

  这就是刚才提到的关于va_arg()的实例。注意到这样一段代码:

else if (*fmt == 'c')
{ // char
    char cha_val = va_arg(args, int);
    counter += 1;
    putchar(cha_val);
    fmt++;
}

  在这里,char需要用int来读取。(char cha_val = va_arg(args, int))

  不过这个很容易发现,因为如果你用char来读取:

else if (*fmt == 'c')
{ // char
    char cha_val = va_arg(args, char);
    counter += 1;
    putchar(cha_val);
    fmt++;
}

  编译会有warning…

warning: 'char' is promoted to 'int' when passed through '...'
    char cha_val = va_arg(args, char);
                                ^
note: (so you should pass 'int' not 'char' to 'va_arg')
note: if this code is reached, the program will abort

  运行起来真的abort了…(不同编译器、不同编译选项可能结果不同)

123 4
$ 2
123 4

  2. 这基本上就是printf()的实现原理了,只不过printf()还可以支持更多的格式化字符串的要求。这也应证了我前面说的:编译器和函数完全不知道你给它了多少给参数。它们完全不知道这些,只是按照一定的规则在试图读取这些可变的参数。

  printf()依照的,就是格式化字符串。


  关于C语言中变参函数的介绍差不多就这些了。自认为是网上最翔实的一篇了,主要还是将自己摸索的过程体现出来。过几天我会再写一篇关于C语言宏的文章,主要谈谈可变参数的宏函数以及宏运算符到底有什么用。希望这两篇文章能对读者有所帮助。