9.12 参数不确定的函数

到此为止,至少有一类函数的实现方式和工作原理我们尚未提到,这就是最常用到的printf()函数和scanf()函数。

这两个函数的特点是,它们的定义(甚至编译)都是在被调用之前完成的,但是这两个函数的作者并不清楚调用这两个函数的人究竟要用几个什么样的实参。然而这两个函数竟然被写出来了,而且编译后确实能够很好地工作。

还可以提出这样类似的问题:在不清楚数量和类型的情况下,如何写一个求几个数(可能是整数也可能是小数)的平均值的函数。

为此,首先剖析一下实现printf()函数的技术手段,研究一下它的工作原理,然后再试写一个求若干个数的平均值的函数。

9.12.1 printf()的函数原型

由于经常使用printf()函数,在源代码中几乎总要写一行编译预处理命令。

9.12 参数不确定的函数 - 图1

这是因为在文件“stdio.h”,中描述了“printf”,这个标识符的含义,也就是函数原型。用记事本打开这个文件会发现这个函数原型是这个样子的:

9.12 参数不确定的函数 - 图2

这里只关注这个函数原型所描述的形参的类型,我们发现第一个参数的类型是“const char*”,这很容易理解,而后面的参数的类型描述全然没有,只写了一个“…”。看来,这个“…”是解决任意个参数问题的一个要点。事实的确如此。

9.12.2 “…”是什么

从第一章中可以看到,“…”也是C语言的一个标点符号。其他的标点符号主要作为运算符或类型说明符,“{}”还可以作为很多情况下某种语言元素开始和结束的标记。但“…”这个标点符号只用于函数声明和定义(此外还用于宏),它的作用是让编译器对出现在这部分的实参与形参不做类型与个数的检查。

此外在函数声明和定义中使用“…”时有一个限制,只能指定后面的参数,且它的前面必须有确定类型的参数。比如

9.12 参数不确定的函数 - 图3

是合法的。但

9.12 参数不确定的函数 - 图4

都不合法。至于理由,后面将会看到。

9.12.3 实现原理

首先考察一个简单的函数调用过程。

程序代码9-31

9.12 参数不确定的函数 - 图5

在第6章中曾经提到,在进行函数调用运算时,计算机首先要求出各个实参的值,然后被调用函数的形参将把这些值作为自己的初始值。

这就是说,在程序代码9-31中,在进行qh(m,n)函数调用时,形参“i”、“j”用到的只是“m”、“n”的(右)值而不是“m”、“n”本身,这一点首先应该十分清醒。换句话说,函数调用时,“m”、“n”,的值被复制到了其他地方,而这个地方恰恰就是形参占据的内存。如图9-31所示,显示了形参与实参之间的这种关系。

9.12 参数不确定的函数 - 图6

图9-31 函数调用之初

函数的形参一旦获得了初值就可以进行运算了。

特别要注意的是,在图9-14中的两个形参,也就是“i”、“j”,是排在一起的,这是不确定参数实现的关键。

毫无疑问,在qh()函数中通过“&”,运算可以求得指向“i”的指针“&i”,而一旦两个形参排列在一起的话,那么在数值上“&i+1”和指向“j”的指针“&j”是相等的,这个值就是“(void)(&i+1)”。如果事先知道了第二个参数“j”的类型,那么就可以求出指向第二个参数“j”的指针。现在假定qh()函数的作者知道“j”的类型为“int”,那么他就完全可以根据第一个参数的信息和“j”的类型得到指向第二个参数的指针“(int )(&i+1)”,而一旦他知道了这个指针,也就意味着他知道了第二个参数的一切。

因此qh()函数中的“return i+j;”语句也可以这样写:

9.12 参数不确定的函数 - 图7

这个return语句只用到了第二个参数“j”的类型“int”,而没有使用“j”这个参数。

结论就是,在形参相邻及知道第二个参数类型的前提下,从第一个实参也就是第一个形参的初值可以得到第二个实参也就是第二个形参的初值,这样第二个形参就完全没有必要了。代码也可以写成:

程序代码9-32

9.12 参数不确定的函数 - 图8

9.12 参数不确定的函数 - 图9

对于参数个数不确定的情形是类似的,比如编写一个求若干(>0)个“double”,数据平均值的函数,可以通过函数的第一个实参传入“double”数据的个数。代码可以写成:

程序代码9-33

9.12 参数不确定的函数 - 图10

输出为:

9.12 参数不确定的函数 - 图11

这就是不确定参数函数实现的基本原理,前提条件是形参在内存中的排列遵守一定的规则,且“…”所代表的各个参数的类型和个数都已知。一般情况下,“…”所代表的各个参数的类型和个数是通过前面确定参数传入的。例如:

9.12 参数不确定的函数 - 图12

在“%d,%c,%f”中就包含有后面参数个数为3,类型分别为“int”,“int”,“double”,的信息。

此外要说明的是,形参在内存中的次序规律在不同的环境下是不同的,所以求未定参数的方法也不同。本小节代码中的写法并不具有一般性,只是原理性的示意代码,换句话说没有可移植性。如果希望写出具备可移植性的代码,则需要采用下一小节中的方法。

9.12.4 标准形式

为了保证不确定参数函数代码的可移植性,C语言标准库提供了一套宏。尽管这套宏具有很好的可移植性,但使用起来非常笨拙且程式化,含义非常抽象难解,因此本书在每个步骤后都提供了一个不严格的非正式注解,以帮助读者理解。

这套宏的定义写在stdarg.h文件中,因此需要首先写编译预处理命令。

(1)#include <stdarg.h>

(2)va_list ap;/这个“ap”用于遍历各个“…”中的参数。“va_list”,是什么类型?是“…”类型。“…”是什么类型?不清楚。实际上这应该是个“void”,但这是我猜的。*/

(3)va_start(ap,一个确定参数的类型)/这是让“ap”获得初始值,也就是指向第一个可变参数。应该是“ap=(void)(&最后一个确定参数+1)”,这也是我猜的。/

(4)va_arg(ap,可变参数的类型)/这句的含义是求当前可变参数的值并把“ap”移至下一个可变参数。大体上应该是“((可变参数的类型)ap)++”,然而“((可变参数的类型)ap)++”并不合法,所以这里很可能还需要其他编译手段,比如借助临时变量等。*/

(5)va_copy(dst,src)/这是C99新增加的内容,可以复制一个“ap”的副本,在“src”被改变的情况下,一旦需要,还可以从前面重新读取参数。/

(6)va_end(ap)/这是在读完参数后对前面可能用到的临时变量等进行清理。/

从前面几条可以看出,C语言已经把不确定参数的使用完全程式化地包装起来,并把实现细节完全留给了编译器。如果不是针对具体的环境,很难琢磨其中具体的技术实现细节。下面代码是前面小节中例题的标准化写法。

程序代码9-34

9.12 参数不确定的函数 - 图13

输出为:

9.12 参数不确定的函数 - 图14