0%

《C Primer Plus 第六版》读书笔记 - 第十六章

  • C预处理器和C库

第16章 C预处理器和C库

16.1 翻译程序的第一步

在预处理之前,编译器必须对该程序进行一些翻译处理。首先,编译器把源代码中出现的字符映射到源字符集。该过程处理多字节字符和三字符序列——字符扩展让C更加国际化

第二,编译器定位每个反斜杠后面跟着换行符的实例,并删除它们。也就是说,把下面两个物理行(physical line):

1
2
printf("That's wond\
erful!\n");
转换成一个逻辑行(logical line):
1
printf("That's wonderful\n");
由于预处理表达式的长度必须是一个逻辑行,所以这一步为预处理器做好了准备工作。一个逻辑行可以是多个物理行。

第三,编译器把文本划分成预处理记号序列、空白序列和注释序列(记号是由空格、制表符或换行符分隔的项)。这里要注意的是,编译器将用一个空格字符替换每一条注释。

而且,实现可以用一个空格替换所有的空白字符序列(不包括换行符)。最后,程序已经准备好进入预处理阶段,预处理器査找一行中以#号开始的预处理指令。

16.2 明示常量:#define

define预处理器指令和其他预处理器指令一样,以#号作为一行的开始。ANSI和后来的标准都允许号前面有空格或制表符,而且还允许在#和指令的其余部分之间有空格。

指令可以出现在源文件的任何地方,其定义从指令出现的地方到该文件末尾有效。

预处理器指令从#开始运行,到后面的第1个换行符为止。也就是说,指令的长度仅限于一行。然而,前面提到过,在预处理开始前,编译器会把多行物理行处理为一行逻辑行。

每行#define(逻辑行)都由3部分组成。第1部分是#define指令本身。第二部分是选定的缩写,也称为宏。有些宏代表值(如本例),这些宏被称为类对象宏(object-like macro)

宏的名称中不允许有空格,而且必须遵循C变量的命名规则:只能使用字符、数字和下划线字符,而且首字符不能是数字。第3部分(指令行的其余部分)称为替换列表或替换体。一旦预处理器在程序中找到宏的示实例后,就会用替换体代替该宏(也有例外,稍后解释)。从宏变成最终替换文本的过程称为宏展开(macro expansion)。注意,可以在#define行使用标准C注释。如前所述,每条注释都会被一个空格代替。

由于编译器在编译期对所有的常量表达式(只包含常量的表达式)求值,所以预处理器不会进行实际的乘法运算,这一过程在编译时进行。

注意,宏定义还可以包含其他宏(一些编译器不支持这种嵌套功能)。

一般而言,预处理器发现程序中的宏后,会用宏等价的替换文本进行替换。如果替换的字符串中还包含宏,则继续替换这些宏。唯一例外的是双引号中的宏。

对于绝大部分数字常量,应该使用字符常量。如果在算式中用字符常量代替数字,常量名能更清楚地表达该数字的含义。如果是表示数组大小的数字,用符号常量后更容易改变数组的大小和循环次数。如果数字是系统代码(如,EOF),用符号常量表示的代码更容易移植(只需改变EOF的定义)。助记、易更改、可移植,这些都是符号常量很有价值的特性。

在C中,非自动数组的大小应该是整型常量表达式,这意味着表示数组大小的必须是整型常量的组合(如5)、枚举常量和 sizeof表达式,不包括 const声明的值(这也是C++和C的区别之一,在C++中可以把 const值作为常量表达式的一部分)。

16.2.1 记号

从技术角度来看,可以把宏的替换体看作是记号(token)型字符串,而不是字符型字符串。C预处理器记号是宏定义的替换体中单独的“词”。用空白把这些词分开。

解释为字符型字符串,把空格视为替换体的一部分;解释为记号型字符串,把空格视为替换体中各记号的分隔符。

16.2.2 重定义常量

只有新定义和旧定义完全相同才允许重定义。

具有相同的定义意味着替换体中的记号必须相同,且顺序也相同。

16.3 在#define中使用参数

在#define中使用参数可以创建外形和作用与函数类似的类函数宏。带有参数的宏看上去很像函数,因为这样的宏也使用圆括号。类函数宏定义的圆括号中可以有一个或多个参数,随后这些参数出现在替换体中

1
#define SQUARE(X) X*X
这里,SQUARE是宏标识符,SQUARE(X)中的X是宏参数,X*X是替换列表。

宏定义中的X由宏调用中的符号代替。

一般而言,不要在宏中使用递增或递减运算符。

16.3.1 用宏参数创建字符串:#运算符

C允许在字符串中包含宏参数。在类函数宏的替换体中,#号作为一个预处理运算符,可以把记号转换成字符串。例如,如果x是一个宏形参,那么#x就是转换为字符串"x"的形参名。这个过程称为字符串化(stringizing)

1
#define PSQR(x) printf("The square of " #x " is $d.\n", ((x)*(x)))

16.3.2 预处理器黏合剂:##运算符

与#运算符类似,##运算符可用于类函数宏的替换部分。而且,##还可用于对象宏的替换部分。##运算符把两个记号组合成一个记号。例如,可以这样做:

1
#define XNAME(n) x ## n
然后,宏XNAME(4)将展开为x4。
1
2
3
4
#define PRINT_XN(n) printf("x" #n " = %d\n", x ## n)
...
int XNAME(1) = 14; //变成 int x1 = 14;
PRINT_XN(1); //变成printf("x1 = %d\n", x1);

16.3.3 变参宏:...和__VA_ARGS__

通过把宏参数列表中最后的参数写成省略号(即,3个点...)来实现这一功能。这样,预定义宏__VA_ARGS__可用在替换部分中,表明省略号代表什么。例如,下面的定义:

1
#define PR(...) printf(__VA_ARGS__)
假设稍后调用该宏:
1
2
PR("Howdy");
PR("weight = %d, shipping = $%.2f\n", wt, sp);
对于第1次调用,__VA_ARGS__展开为1个参数:"Howdy"。

对于第2次调用,__VA_ARGS__展开为3个参数:"weight = %d, shipping = $%.2f"、wt、sp。

省略号只能代替最后的宏参数

16.4 宏和函数的选择

使用宏比使用普通函数复杂一些,稍有不慎会产生奇怪的副作用。一些编译器规定宏只能定义成一行。不过,即使编译器没有这个限制,也应该这样做。

宏和函数的选择实际上是时间和空间的权衡。宏生成内联代码,即在程序中生成语句。如果调用20次宏,即在程序中插入20行代码。如果调用函数20次,程序中只有一份函数语句的副本,所以节省了空间。然而另一方面,程序的控制必须跳转至函数内,随后再返回主调程序,这显然比内联代码花费更多的时间。

宏的一个优点是,不用担心变量类型(这是因为宏处理的是字符串,而不是实际的值)。因此,只要能用int或float类型都可以使用SQUARE(x)宏

记住宏名中不允许有空格,但是在替换字符串中可以有空格。ANSI_C允许在参数列表中使用空格。

用圆括号把宏的参数和整个替换体括起来。这样能确保被括起来的部分在下面这样的表达式中正确地展开:

1
forks = 2 * MAX(guests + 3, last);
用大写字母表示宏函数的名称。该惯例不如用大写字母表示宏常量应用广泛。但是,大写字母可以提醒程序员注意,宏可能产生的副作用。

如果打算使用宏来加快程序的运行速度,那么首先要确定使用宏和使用函数是否会导致较大差异。在程序中只使用一次的宏无法明显减少程序的运行时间。在嵌套循环中使用宏更有助于提高效率。

16.5 文件包含:#include

16.5.1 头文件示例

在UNIX系统中,尖括号告诉预处理器在标准系统目录中査找该文件。双引号告诉预处理器首先在当前目录中(或文件名中指定的其他目录)查找该文件,如果未找到再査找标准系统目录

16.5.2 使用头文件

头文件中最常用的形式如下。

明示常量——例如,stdio.h中定义的EOF、NULL和BUFSIZE(标准I/O缓冲区大小)。

宏函数——例如,getc(stdin)通常用getchar()定义,而getc()经常用于定义较复杂的宏,头文件ctype.h通常包含ctype系列函数的宏定义。

函数声明——例如,string.h头文件(一些旧的系统中是strings.h)包含字符串函数系列的函数声明。在ANSI C和后面的标准中,函数声明都是函数原型形式。

结构模版定义——标准I/O函数使用FILE结构,该结构中包含了文件和与文件缓冲区相关的信息。FILE结构在头文件stdio.h中。

类型定义——标准I/O函数使用指向FILE的指针作为参数。通常,stdio.h用#define或typedef把FILE定义为指向结构的指针。类似地,size_t和time_t类型也定义在头文件中。

16.6 其他指令

16.6.1 #undef指令

#undef指令用于“取消”已定义的#define指令。

如果想使用一个名称,又不确定之前是否已经用过,为安全起见,可以用#undef指令取消该名字的定义

16.6.2 从C预处理器角度看已定义

如果宏通过头文件引入,那么#define在文件中的位置取决于#include指令的位置。

16.6.3 条件编译

#ifdef、#else和#endif指令

1
2
3
4
5
6
7
#ifdef MAVIS //如果已经用#define定义了MAVIS,则执行下面的指令
#include "horse.h"
#define STABLES 5
#else //如果没有用#define定义MAVIS,则执行下面的指令
#include "cow.h"
#define STABLES 15
#endif

#ifdef指令说明,如果预处理器已定义了后面的标识符(MAVIS),则执行#else或#endif指令之前的所有指令并编译所有C代码(先出现哪个指令就执行到哪里)。如果预处理器未定义MAVIS,且有#else指令,则执行#else和#endif指令之间的所有代码。

#ifdef、#else很像C的if、else。两者的主要区别是,预处理器不识别用于标记块的花括号,因此它使用#else(如果需要)和#endif(必须存在)来标记指令块。这些指令结构可以嵌套。

#ifndef指令

#ifndef指令与#ifdef指令的用法类似,也可以和#else、#endif一起使用,但是它们的逻辑相反。#ifndef指令判断后面的标识符是否是未定义的,常用于定义之前未定义的常量

通常,包含多个头文件时,其中的文件可能包含了相同宏定义。#ifndef指令可以防止相同的宏被重复定义。在首次定义一个宏的头文件中用#ifndef指令激活定义,随后在其他头文件中的定义都被忽略。

#ifndef指令通常用于防止多次包含一个文件。

#if和#elif指令

if指令很像C语言中的if。#if后面跟整型常量表达式,如果表达式为非零,则表达式为真。可以在指令中使用C的关系运算符和逻辑运算符

可以按照if else的形式使用#elif

较新的编译器提供另一种方法测试名称是否已定义,即用#if defined (VAX)代替#ifdef VAX。

这里,defined是一个预处理运算符,如果它的参数是用#defined定义过,则返回1:否则返回0。这种新方法的优点是,它可以和#elif一起使用。

16.6.4 预定义宏

C99标准提供一个名为__func__的预定义标识符,它展开为一个代表函数名的字符串(该函数包含该标识符)。那么,__func__必须具有函数作用域,而从本质上看宏具有文件作用域。因此,__func__是C语言的预定义标识符,而不是预定义宏。

16.6.5 #line和#error

line指令重置__LINE__和__FILE__宏报告的行号和文件名。

1
2
#line 1000 //把当前行号重置为1000
#line 10 "cool.c" //把行号重置为10,把文件名重置为cool.c
error指令让预处理器发出一条错误消息,该消息包含指令中的文本。如果可能的话,编译过程应该中断

16.6.6 #pragma

#pragma把编译器指令放入源代码中。例如,在开发C99时,标准被称为C9X,可以使用下面的编译指示(pragma)让编译器支持C9X:

1
#pragma c9x on
C99还提供_Pragma预处理器运算符,该运算符把字符串转换成普通的编译指示。例如:
1
_Pragma("nonstandardtreatmenttypeB pn");
等价于下面的指令
1
#pragma nonstandardtreatmenttypeB on
由于该运算符不使用#符号,所以可以把它作为宏展开的一部分

_Pragma运算符完成“解字符串”(destringizing)的工作,即把字符串中的转义序列转换成它所代表的字符。

16.6.7 泛型选择(C11)

1
_Generic(x, int: 0, float: 1, double: 2, default: 3)

_Generic是C11的关键字。_Generic后面的圆括号中包含多个用逗号分隔的项。第1个项是一个表达式,后面的每个项都由一个类型、一个冒号和一个值组成,如float:1。第1个项的类型匹配哪个标签,整个表达式的值是该标签后面的值。例如,假设上面表达式中x是int类型的变量,x的类型匹配int:标签,那么整个表达式的值就是0。如果没有与类型匹配的标签,表达式的值就是default:标签后面的值。泛型选择语句与switch语句类似,只是前者用表达式的类型匹配标签,而后者用表达式的值匹配标签。

对一个泛型选择表达式求值时,程序不会先对第一个项求值,它只确定类型,只有匹配标签的类型后才会对表达式求值。

16.7 内联函数(C99)

C99和C11标准中叙述的是:“把函数变成内联函数建议尽可能快地调用该函数,其具体效果由实现定义”。因此,把函数变成内联函数,编译器可能会用内联代码替换函数调用,并(或)执行一些其他的优化,但是也可能不起作用。

标准规定具有内部链接的函数可以成为内联函数,还规定了内联函数的定义与调用该函数的代码必须在同一个文件中。因此,最简单的方法是使用函数说明符inline和存储类别说明符static。通常,内联函数应定义在首次使用它的文件中,所以内联函数也相当于函数原型。

由于并未给内联函数预留单独的代码块,所以无法获得内联函数的地址(实际上可以获得地址,不过这样做之后,编译器会生成一个非内联函数)。另外,内联函数无法在调试器中显示。

内联函数应该比较短小。把较长的函数变成内联并未节约多少时间,因为执行函数体的时间比调用函数的时间长得多。

编译器优化内联函数必须知道该函数定义的内容。这意味着内联函数定义与函数调用必须在同一个文件中。鉴于此,一般情况下内联函数都具有内部链接。因此,如果程序有多个文件都要使用某个内联函数,那么这些文件中都必须包含该内联函数的定义。最简单的做法是,把内联函数定义放入头文件,并在使用该内联函数的文件中包含该头文件即可。

与C++不同的是,C还允许混合使用内联函数定义和外部函数定义(具有外部链接的函数定义)。

16.8 _Noreturn函数(C11)

C11新增了第2个函数说明符_Noreturn,表明调用完成后函数不返回主调函数。exit()函数是_Noreturn函数的一个示例,一旦调用exit(),它不会再返回主调函数。注意,这与void返回类型不同。void类型的函数在执行完毕后返回主调函数,只是它不提供返回值。

_Noreturn的目的是告诉用户和编译器,这个特殊的函数不会把控制返回主调程序。告诉用户以免滥用该函数,通知编译器可优化一些代码。

16.9 C库

16.9.1 访问C库

库包含

在编译或链接程序的某些阶段,可能需要指定库选项。即使在自动检查标准库的系统中,也会有不常用的函数库。必须通过编译时选项显式指定这些库。注意,这个过程与包含头文件不同。头文件提供函数声明或原型,而库选项告诉系统到哪里査找函数代码。

16.10 数学库

函数中涉及的角度都以弧度为单位(1弧度=180/π=57.296度)。

16.10.1 三角问题

如果编译时出现下面的消息:

1
Undefined: _sqrt
1
'sqrt': unresolved external
或者其他类似的消息,表明编译器链接器没有找到数学库。UNIX系统会要求使用-lm标记(flag)指示链接器搜索数学库

16.10.2 类型变体

为了解决这些潜在的问题,C标准专门为float类型和long double类型提供了标准函数,即在原函数名前加上f或l前缀。因此,sqrtf()是sqrt()的float版本,sqrtl()是sqrt()的long double版本。

利用C11新增的泛型选择表达式定义一个泛型宏,根据参数类型选择最合适的数学函数版本。

1
2
3
4
5
6
7
8
9
10
11
// 泛型平方根函数
#define SQRT(x) _Generic((X),\
long double: sqrtl, \
default: sqrt, \
float: sqrtf)(X)

// 泛型正弦函数,角度的单位为度
#define SIN(X) _Generic((X),\
long double: sinl((X)/RAD_TO_DEG),\
default: sin((X)/RAD_TO_DEG),\
float: sinf((X)/RAD_TO_DEG)\)

SQRT()的定义也许更简洁。_Generic表达式的值就是函数名,如sqrtf。函数的地址可以代替该函数名,所以_Generic表达式的值是一个指向函数的指针。然而,紧随整个_Generic表达式之后的是(X),函数指针(参数)表示函数指针。因此,这是一个带指定的参数的函数指针。

简而言之,对于SIN(),函数调用在泛型选择表达式内部;而对于SQRT(),先对泛型选择表达式求值得一个指针,然后通过该指针调用它所指向的函数。

16.10.3 tgmath.h库(C99)

C99标准提供的tgmath.h头文件中定义了泛型类型宏

如果在math.h中为一个函数定义了3种类型(float、double和long double)的版本,那么tgmath.h文件就创建一个泛型类型宏,与原来double版本的函数名同名。

如果编译器支持复数运算,就会支持complex.h头文件,其中声明了与复数运算相关的函数。例如,声明有csqrtf()、csqrt()和csqrtl(),这些函数分别返回float complex、double complex和long double complex类型的复数平方根。如果提供这些支持,那么tgmath.h中的sgrt()宏也能展开为相应的复数平方根函数。

如果包含了tgmath.h,要调用sqrt()函数而不是sgrt()宏,可以用圆括号把被调用的函数名括起来

1
2
3
4
5
6
#include <tgmath.h>
...
float x = 44.0;
double y;
y = sqrt(x); //调用宏,所以是sqrtf(x)
y = (sqrt)(x); //调用函数sqrt()
这样做没问题,因为类函数宏的名称必须用圆括号括起来。圆括号只会影响操作顺序,不会影响括起来的表达式,所以这样做得到的仍然是函数调用的结果。实际上,在讨论函数指针时提到过,由于C语言奇怪而矛盾的函数指针规则,还也可以使用(*sqrt)()的形式来调用sgrt()函数。

不借助C标准以外的机制,C11新增的_Generic表达式是实现tgmath.h最简单的方式。

16.11 通用工具库

16.11.1 exit()和atexit()函数

可以指定在执行exit()时调用的特定函数。atexit()函数通过退出时注册被调用的函数提供这种功能,atexit()函数接受一个函数指针作为参数

atexit()函数的用法

这个函数使用函数指针。要使用atexit()函数,只需把退出时要调用的函数地址传递给atexit()即可。

atexit()注册函数列表中的函数,当调用exit()时就会执行这些函数。ANSI保证,在这个列表中至少可以放32个函数。最后调用exit()函数时,exit()会执行这些函数(执行顺序与列表中的函数顺序相反,即最后添加的函数最先执行)。

atexit()注册的函数应该不带任何参数且返回类型为void。通常,这些函数会执行一些清理任务,例如更新监视程序的文件或重置环境变量。

main()结東时会隐式调用exit()

exit()函数的用法

exit()执行完atexit()指定的函数后,会完成一些清理工作:刷新所有输出流、关闭所有打开的流和关闭由标准I/O函数tmpfile()创建的临时文件。然后exit()把控制权返回主机环境,如果可能的话向主机环境报告终止状态。通常,UNIX程序使用0表示成功终止,用非零值表示终止失败。UNIX返回的代码并不适用于所有的系统,所以ANSI C为了可移植性的要求,定义了一个名为EXIT_FAILURE的宏表示终止失败。类似地,ANSI C还定义了EXIT_SUCCESS表示成功终止。不过,exit()函数也接受0表示成功终止。在ANSI C中,在非递归的main()中使用exit()函数等价于使用关键字return。尽管如此,在main()以外的函数中使用exit()也会终止整个程序。

16.11.2 qsort()函数

快速排序算法在C实现中的名称是qsort()。qsort()函数排序数组的数据对象,其原型如下:

1
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));
第1个参数是指针,指向待排序数组的首元素。ANSI C允许把指向任何数据类型的指针强制转换成指向void的指针,因此,qsort()的第1个实际参数可以引用任何类型的数组。

第2个参数是待排序项的数量。函数原型把该值转换为size_t类型。前面提到过,size_t定义在标准头文件中,是sizeof运算符返回的整数类型。

由于qsort()把第1个参数转换为void指针,所以qsort()不知道数组中每个元素的大小。为此,函数原型用第3个参数补偿这一信息,显式指明待排序数组中每个元素的大小。例如,如果排序double类型的数组,那么第3个参数应该是sizeof(double)。

最后,qsort()还需要一个指向函数的指针,这个被指针指向的比较函数用于确定排序的顺序。该函数应接受两个参数:分别指向待比较两项的指针。如果第1项的值大于第2项,比较函数则返回正数:如果两项相同,则返回0:如果第1项的值小于第2项,则返回负数。qsort()根据给定的其他信息计算出两个指针的值,然后把它们传递给比较函数。

qsort()原型中的第4个函数确定了比较函数的形式:

这表明qsort()最后一个参数是一个指向函数的指针,该函数返回int类型的值且接受两个指向const void的指针作为参数,这两个指针指向待比较项。

mycomp()的定义

为了比较指针所指向的值,必须解引用指针。因为值是double类型,所以要把指针解引用为double类型的值。然而,qsort()要求指针指向void。要解决这个问题,必须在比较函数的内部声明两个类型正确的指针,并初始化它们分别指向作为参数传入的值

1
2
3
4
5
6
7
8
9
10
11
int mycomp(const void * p1, const void * p2)
{
const double * a1 = (const double *) p1;
const double * a2 = (const double *) p2;
if (*a1 < *a2)
return -1;
else if (*a1 == *a2)
return 0;
else
return 1;
}
C++要求在把void*指针赋给任何类型的指针时必须进行强制类型转换。而C没有这样的要求。

16.12 断言库

16.12.1 assert的用法

assert.h头文件支持的断言库是一个用于辅助调试程序的小型库。它由assert()宏组成,接受个整型表达式作为参数。如果表达式求值为假(非零),assert()宏就在标准错误流(stderr)中写入条错误信息,并调用abort()函数终止程序(abort()函数的原型在stdlib.h头文件中)。assert()宏是为了标识出程序中某些条件为真的关键位置,如果其中的一个具体条件为假,就用assert()语句终止程序。通常,assert()的参数是一个条件表达式或逻辑表达式。如果assert()中止了程序,它首先会显示失败的测试、包含测试的文件名和行号。

16.12.1 assert的用法

但是,使用assert()有几个好处:它不仅能自动标识文件和出问题的行号,还有一种无需更改代码就能开启或关闭assert()的机制。如果认为已经排除了程序的bug,就可以把下面的宏定义写在包含assert.h的位置前面:

1
#define NDEBUG
井重新编译程序,这样编译器就会禁用文件中的所有assert()语句,如果程序出现问题,可以移除这条#define指令(或者把它注释掉),然后重新编译程序这样就重新启用了aasert()语句。

16.12.2 _Static_assert(C11)

C11新增了一个特性:_Static_assert声明,可以在编译时检查assert()表达式。因此,assert()可以导致正在运行的程序中止,而_Static_assert()可以导致程序无法通过编译。_Static_assert()接受两个参数。第1个参数是整型常量表达式,第2个参数是一个字符串。如果第1个表达式求值为0(或False),编译器会显示字符串,而且不编译该程序。

根据语法,_Static_assert()被视为声明。因此,它可以出现在函数中,或者在这种情况下出现在函数的外部。

_Static_assert要求它的第1个参数是整型常量表达式,这保证了能在编译期求值(sizeof表达式被视为整型常量)。

16.13 string.h库中的memcpy()和memmove()

1
2
void *memcpy(void * restrict s1, const void * restrict s2, size_t n);
void *memmove(void *s1, const void *s2, size_t n);

这两个函数都从s2指向的位置拷贝n字节到s1指向的位置,而且都返回s1的值。所不同的是,memcpy()的参数带关键字restrict,即memcpy()假设两个内存区域之间没有重叠:而memmove()不作这样的假设,所以拷贝过程类似于先把所有字节拷贝到一个临时缓冲区,然后再拷贝到最终目的地。如果使用memcpy()时,两区域出现重叠会怎样?其行为是未定义的,这意味着该函数可能正常工作,也可能失败。编译器不会在本不该使用memcpy()时禁止你使用,作为程序员,在使用该函数时有责任确保两个区域不重叠。

这两个函数使用第3个参数指明待拷贝的字节数。

memcpy()函数不知道也不关心数据的类型,它只负责从一个位置把一些字节拷贝到另一个位置

而且,拷贝过程中也不会进行数据转换。如果用循环对数组中的每个元素赋值,double类型的值会在赋值过程被转换为int类型的值。这种情况下,按原样拷贝字节,然后程序把这些位组合解释成int类型

16.14 可变参数:stdarg.h

stdarg.h头文件为函数提供了一个类似的功能,但是用法比较复杂。必须按如下步骤进行:

  1. 提供一个使用省略号的函数原型
  2. 在函数定义中创建一个va_list类型的变量
  3. 用宏把该变量初始化为一个参数列表
  4. 用宏访问参数列表
  5. 用宏完成清理工作。

这种函数的原型应该有一个形参列表,其中至少有一个形参和一个省略号:

1
2
3
4
void f1(int n, ...); //有效
int f2(const char * s, int k, ...); //有效
char f3(char c1, ..., char c2); //无效,省略号不在最后
double f3(...); //无效,没有形参
最右边的形参(即省略号的前一个形参)起着特殊的作用,标准中用parmN这个术语来描述该形参。在上面的例子中,第1行f1()中parmN为n,第2行f2()中parmN为k。传递给该形参的实际参数是省略号部分代表的参数数量。例如,可以这样使用前面声明的f1()函数:
1
2
f1(2, 200, 400); //2个额外的参数
f1(4, 13, 117, 18, 23); //4个额外的参数
接下来,声明在stdarg.h中的va_list类型代表一种用于储存形参对应的形参列表中省略号部分的数据对象。变参函数的定义起始部分类似下面这样:
1
2
3
double sum(int lim, ...)
{
va_list ap; //声明一个储存参数的对象
然后,该函数将使用定义在stdarg.h中的va_start()宏,把参数列表拷贝到va_list类型的变量中。该宏有两个参数:va_list类型的变量和parmN形参。接着上面的例子讨论,va_list类型的变量是ap,parmN形参是lim。所以,应这样调用它
1
va_start(ap, lim); //把ap初始化为参数列表
下一步是访问参数列表的内容,这涉及使用另一个宏va_arg()。该宏接受两个参数:一个va_list类型的变量和一个类型名。第1次调用va_arg()时,它返回参数列表的第1项:第2次调用时返回第2项,以此类推。表示类型的参数指定了返回值的类型。例如,如果参数列表中的第1个参数是double类型,第2个参数是int类型,可以这样做:
1
2
3
4
5
double tic;
int toc;
...
tic = va_arg(ap, double);
toc = va_arg(ap, int);
注意,传入的参数类型必须与宏参数的类型相匹配。如果第1个参数是10.0,上面tic那行代码可以正常工作。但是如果参数是10,这行代码可能会出错。这里不会像赋值那样把double类型自动转换成int类型。

最后,要使用va_end()宏完成清理工作。例如,释放动态分配用于储存参数的内存。该宏接受一个list类型的变量:

1
va_end(ap); //清理工作
调用va_end(ap)后,只有用va_start重新初始化ap后,才能使用变量ap因为va_arg()不提供退回之前参数的方法,所以有必要保存va_list类型变量的副本。C99新增了个宏用于处理这种情况:va_copy()。该宏接受两个valist类型的变量作为参数,它把第2个参数拷贝给第1个参数:
1
2
3
4
5
6
7
8
9
va_list ap;
va_list apcopy;
double tic;
int toc;
...
va_start(ap, lim);
va_copy(apcopy, ap);
tic = va_arg(ap, double);
toc = va_arg(ap, int);
此时,即使删除了ap,也可以从apcopy中检索两个参数。