C语言系列:4、函数与程序结构

C语言系列:4、函数与程序结构


1. 函数的基础知识

首先我们来设计并编写一个程序,它将输入中包含特定“模式”或字符串的各行打印出来(这是UNIX 程序grep的特例)例如,在下列一组文本行中查找包含字符串“ould”的行:

Ah Love! could you and I with Fate conspire
To grasp this sorry Scheme of Things entire,
Would not we shatter it to bits -- and then
Re-mould it nearer to the Heart's Desire!

程序执行后输出下列结果:

Ah Love! could you and I with Fate conspire
Would not we shatter it to bits -- and then
Re-mould it nearer to the Heart's Desire!

该任务可以明确地划分成下列3部分:

while (还有未处理的行)
	if (该行包含指定的模式)
		打印该行

尽管我们可以把所有的代码都放在主程序main中,但更好的做法是,利用其结构把每一部分设计成一个独立的函数。分别处理3 个小的部分比处理一个大的整体更容易,因为这样可以把不相关的细节隐藏在函数中,从而减少了不必要的相互影响的机会,并且,这些函数也可以在其它程序中使用。

函数的定义形式如下:

返回值类型 函数名(参数声明表)
{
	声明和语句
}

函数定义中的各构成部分都可以省略。最简单的函数如下所示:

dummy() {}

该函数不执行任何操作也不返回任何值。这种不执行任何操作的函数有时很有用,它可以在程序开发期间用以保留位置(留待以后填充代码)。如果函数定义中省略了返回值类型,则默认为int类型。

程序可以看成是变量定义和函数定义的集台。函数之间的通信可以通过参数、函数返回值以及外部变量进行。函数在源文件中出现的次序可以是任意的。只要保证每一个函数不被分离到多个文件中,源程序就可以分成多个文件。

被调用函数通过return语句向调用者返回值,return语句的后面可以跟任何表达式:

return 表达式;

在必要时,表达式将被转换为函数的返回值类型。表达式两边通常加一对圆括号,此处的括号是可选的。

我们用函数getline实现“还有未处理的行”,该函数已在第1章中介绍过;用printf
函数实现“打印该行”,这个函数是现成的,别人已经提供了。也就是说,我们只需要编写一个判定“该行包含指定的模式”的函数。

我们编写函数strindex(s, t)实现该目标。该函数返回字符串t在字符串s中出现的
起始位置或索引。当s 不包含t 时,返回值为-1。由于C 语言数组的下标从0 开始,下标的值只可能为0 或正数,因此可以用像-1 这样的负数表示失败的情况。如果以后需要进行更复杂的模式匹配,只需替换strindex函数即可,程序的其余部分可保持不变。(标准库中提供的库函数strstr的功能类似于strindex函数,但该库函数返回的是指针而不是下标值。)

完成这样的设计后,编写整个程序的细节就直截了当了。下面列出的就是一个完整的程序,读者可以查看各部分是怎样组合在一起的。我们现在查找的模式是字符串字面值,它不是一种最通用的机制。我们在这里只简单讨论字符数组的初始化方法,第5 章将介绍如何在程序运行时将模式作为参数传递给函数。其中,getline 函数较前面的版本也稍有不同,读者可将它与第1 章中的版本进行比较,或许会得到一些启发。

#include <stdio.h>
#define MAXLINE 1000 /* maximum input line length */

int getline(char line[], int max)
int strindex(char source[], char searchfor[]);
char pattern[] = "ould"; /* pattern to search for */

/* find all lines matching pattern */
main()
{
	char line[MAXLINE];
	int found = 0;
	while (getline(line, MAXLINE) > 0)
		if (strindex(line, pattern) >= 0) {
			printf("%s", line);
			found++;
		}
	return found;
}

/* getline: get line into s, return length */
int getline(char s[], int lim)
{
	int c, i;
	i = 0;
	while (--lim > 0 && (c=getchar()) != EOF && c != '\n')
		s[i++] = c;
	if (c == '\n')
		s[i++] = c;
	s[i] = '\0';
	return i;
}

/* strindex: return index of t in s, -1 if none */
int strindex(char s[], char t[])
{
	int i, j, k;
	for (i = 0; s[i] != '\0'; i++) {
		for (j=i, k=0; t[k]!='\0' && s[j]==t[k]; j++, k++)
			;
		if (k > 0 && t[k] == '\0')
			return i;
	}
	return -1;
}

调用函数可以忽略返回值。并且,return语句的后面也不一定需要表达式。当return语句的后面没有表达式时,函数将不向调用者返回值。当被调用函数执行到最后的右花括号而结束执行时,控制同样也会返回给调用者(不返回值)。如果某个函数从一个地方返回时有返回值,而从另一个地方返回时没有返回值,该函数并不非法,但可能是一种出问题的征兆。在任何情况下,如果函数没有成功地返回一个值,则它的“值”肯定是无用的。

在上面的模式查找程序中,主程序main返回了一个状态,即匹配的数目。该返回值可以在调用该程序的环境中使用。

在不同的系统中,保存在多个源义件中的C语言程序的编译与加载机制是不同的。例如,在UNIX 系统中,可以使用第1章中提到过的cc命令执行这一任务。假定有3 个函数分别存放在名为main.c、getline.c与strindex.c的3 个文件中,则可以使用命令cc main.c getline.c strindex.c来编译这3 个文件,并把生成的目标代码分别存放在文件main.o、getline.o 与strindex.o中,然后再把这3 个文件一起加载可执行文件a.out中。如果源程序中存在错误(比如文件main.c中存在错误),则可以通过命令cc main.c getline.o strindex.o对main.c 文件重新编译,并将编译的结果与以前已编译过的目标文件getline.o 和strindex.o 一起加载到可执行文件中。cc 命令使用“.c”与“.o”这两种扩展名来区分源文件目标文件

2. 返回非整型值的函数

到目前为止,我们所讨论的函数都是不返回任何值(void)或只返回int类型值的函数。假如某个函数必须返回其它类型的值,该怎么办呢?许多数值函数(如sqrt、sin 与cos等函数)返回的是double 类型的值,某些专用函数则返回其它类型的值。我们通过函数atof(s)来说明函数返回非整型值的方法。该函数把字符串s 转换为相应的双精度浮点数。atof 函数是atoi 函数的扩展,第2 章与第3 章已讨论了atoi 函数的几个版本。atof 函数需要处理可选的符号和小数点,并要考虑可能缺少整数部分或小数部分的情况。我们这里编写的版本并不是一个高质量的输入转换函数,它占用了过多的空间。标准库中包含类似功能的atof函数,在头文件<stdlib.h>中声明。

首先,由于atof函数的返回值类型不是int,因此该函数必须声明返回值的类型。返回值的类型名应放在函数名字之前,如下所示:

#include <ctype.h>
/* atof: convert string s to double */
double atof(char s[])
{
	double val, power;
	int i, sign;
	for (i = 0; isspace(s[i]); i++) /* skip white space */
		;
	sign = (s[i] == '-') ? -1 : 1;
	if (s[i] == '+' || s[i] == '-')
		i++;
	for (val = 0.0; isdigit(s[i]); i++)
		val = 10.0 * val + (s[i] - '0');
	if (s[i] == '.')
		i++;
	for (power = 1.0; isdigit(s[i]); i++) {
		val = 10.0 * val + (s[i] - '0');
		power *= 10;
	}
	return sign * val / power;
}

其次,调用函数必须知道atof函数返回的是非整型值,这一点也是很重要的。为了达到该目的,一种方法是在调用函数中显式声明atof函数。下面所示的基本计算器程序(仅适用于支票簿计算)中有类似的声明。该程序在每行中读取一个数(数的前面可能有正负号),并对它们求和,在每次输入完成后把这些数的累计总和打印出来:

#include <stdio.h>
#define MAXLINE 100
/* rudimentary calculator */
main()
{
	double sum, atof(char []);
	char line[MAXLINE];
	int getline(char line[], int max);
	sum = 0;
	while (getline(line, MAXLINE) > 0)
		printf("\t%g\n", sum += atof(line));
	return 0;
}

其中,声明语句

double sum, atof(char []);

表明sum 是一个double 类型的变量,atof 函数带有个char[]类型的参数,且返回一个double类型的值。

函数 atof的声明与定义必须一致。如果atof函数与调用它的主函数main放在同一源
文件中,并且类型不一致,编译器就会检测到该错误。但是,如果atof函数是单独编译的(这种可能性更大),这种不匹配的错误就无法检测出来,atof 函数将返回double 类型的值,而main函数却将返回值按照int类型处理,最后的结果值毫无意义。

根据前面有关函数的声明如何与定义保持一致的讨论,发生不匹配现象似乎很令人吃惊。其中的一个原因是,如果没有函数原型,则函数将在第一次出现的表达式中被隐式声明,例如:

sum += atof(line)

如果先前没有声明过的一个名字出现在某个表达式中,并且其后紧跟一个左圆括号,那么上下文就会认为该名字是一个函数名字,该函数的返回值将被假定为int 类型,但上下文并不对其参数作任何假设。并且,如果函数声明中不包含参数,例如:

double atof();

那么编译程序也不会对函数atof的参数作任何假设,并会关闭所有的参数检查。对空参数表的这种特殊处理是为了使新的编译器能编译比较老的C 语言程序。不过,在新编写的程序中这么做是不提倡的。如果函数带有参数,则要声明它们;如果没有参数,则使用void进行声明。

在正确进行声明的函数atof的基础上,我们可以利用它编写出函数atoi(将字符串转换为int类型):

/* atoi: convert string s to integer using atof */
int atoi(char s[])
{
	double atof(char s[]);
	return (int) atof(s);
}

请注意其中的声明和return语句的结构。在下列形式的return语句中:

return(表达式);

其中,表达式的值在返回之前将被转换为函数的类型。因为函数atoi的返回值为int类型,所以,return 语句中的atof 函数的double 类型值将被自动转换为int 类型值。但是,这种操作可能会丢失信息,某些编译器可能会对此给出警告信息。在该函数中,由于采用了类型转换的方法显式表明了所要执行的转换操作,因此可以防止有关的警告信息。

3. 外部变量

C语言程序可以看成由一系列的外部对象构成,这些外部对象可能是变量或函数。形容词external 与internal 相对的,internal 用于描述定义在函数内部的函数参数及变量。外部变量定义在函数之外,因此可以在许多函数中使用。由于C语言不允许在一个函数中定义其它函数,因此函数本身是“外部的”。默认情况下,外部变量与函数具有下列性质:通过同一个名字对外部变量的所有引用(即使这种引用来自于单独编译的不同函数)实际上都是引用同一个对象(标准中把这一性质称为外部链接)。在这个意义上,外部变量类似于Fortran 语言的COMMON块或Pascal 语言中在最外层程序块中声明的变量。我们将在后面介绍如何定义只能在某一个源文件中使用的外部变量与函数。

因为外部变量可以在全局范围内访问,这就为函数之间的数据交换提供了一种可以代替函数参数与返回值的方式。任何函数都可以通过名字访问一个外部变量,当然这个名字需要通过某种方式进行声明。

如果函数之间需要其享大量的变量,使用外部变量要比使用一个很长的参数表更方便、有效。但是,我们在第1 章中已经指出,这样做必须非常谨慎,因为这种方式可能对程序结构产生不良的影响,而且可能会导致程序中各个函数之间具有太多的数据联系。外部变量的用途还表现在它们与内部变量相比具有更大的作用域和更长的生存期。自动变量只能在函数内部使用,从其所在的函数被调用时变量开始存在,在函数退出时变量也将消失。而外部变量是永久存在的,它们的值在一次函数调用到下一次函数调用之间保持不变。因此,如果两个函数必须共享某些数据,而这两个函数互不调用对方,这种情况下最方便的方式便是把这些共享数据定义为外部变量,而不是作为函数参数传递。

4. 作用域规则

构成C语言程序的函数与外部变量可以分开进行编译。一个程序可以存放在几个文件中,原先已编译过的函数可以从库中进行加载。这里我们感兴趣的问题有:

  • 如何进行声明才能确保变量在编译时被正确声明?

  • 如何安排声明的位置才能确保程序在加载时各部分能正确连接?

  • 如何组织程序中的声明才能确保只有一份副本?

  • 如何初始化外部变量?

为了讨论这些问题,我们重新组织前面的计算器程序,将它分散到多个文件中。从实践的角度来看,计算器程序比较小,不值得分成几个文件存放,但通过它可以很好地说明较大的程序中遇到的类似问题。

名字的作用域指的是程序中可以使用该名字的部分。对于在函数开头声明的自动变量来说,其作用域是声明该变量名的函数。不同函数中声明的具有相同名字的各个局部变量之间没有任何关系。函数的参数也是这样的,实际上可以将它看作是局部变量。

外部变量或函数的作用域从声明它的地方开始,到其所在的(待编译的)文件的末尾结束。例如,如果main、sp、val、push与pop是依次定义在某个文件中的5个函数或外部变量,如下所示:

main() { ... }
int sp = 0;
double val[MAXVAL];
void push(double f) { ... }
double pop(void) { ... }

那么,在push与pop这两个函数中不需进行任何声明就可以通过名字访问变量sp与val,但是,这两个变量名不能用在main函数中,push与pop函数也不能用在main函数中。

另一方面,如果要在外部变量的定义之前使用该变量,或者外部变量的定义与变量的使用不在同一个源文件中,则必须在相应的变量声明中强制性地使用关键字extern

将外部变量的声明与定义严格区分开来很重要。变量声明用于说明变量的属性(主要是变量的类型),而变量定义除此以外还将引起存储器的分配。如果将下列语句放在所有函数的外部:

int sp;
double val[MAXVAL];

那么这两条语句将定义外部变量sp与val,并为之分配存储单元,同时这两条语句还可以作为该源文件中其余部分的声明。而下面的两行语句:

extern int sp;
extern double val[];

为源文件的其余部分声明了一个int 类型的外部变量sp 以及一个double 数组类型的外部变量val(该数组的长度在其它地方确定),但这两个声明并没有建立变量或为它们分配存储单元。

在一个源程序的所有源文件中,一个外部变量只能在某个文件中定义一次,而其它文件可以通过extern 声明来访问它(定义外部变量的源文件中也可以包含对该外部变量的extern 声明)。外部变量的定义中必须指定数组的长度,但extern 声明则不一定要指定数组的长度。

外部变量的初始化只能出现在其定义中。

假定函数push 与pop 定义在一个文件中,而变量val 与sp 在另一个文件中定义并被
初始化(通常不大可能这样组织程序),则需要通过下面这些定义与声明把这些函数和变量“绑定”在一起:

在文件file1 中:

extern int sp;
extern double val[];
void push(double f) { ... }
double pop(void) { ... }

在文件file2 中:

int sp = 0;
double val[MAXVAL];

由于文件file1 中的extern 声明不仅放在函数定义的外面,而且还放在它们的前面,因此它们适用于该文件中的所有函数。对于file1,这样一组声明就够了。如果要在同一个文件中先使用、后定义变量sp与val,也需要按照这种方式来组织文件。

5. 头文件

下面我们来考虑把上述的计算器程序分割到若干个源文件中的情况。如果该程序的各组成部分很长,这么做还是有必要的。我们这样分割:将主函数main 单独放在文件main.c中;将push 与pop 函数以及它们使用的外部变量放在第二个文件stack.c 中;将getop函数放在第三个文件getop.c中;将getch与ungetch函数放在第四个文件getch.c中。之所以分割成多个文件,主要是考虑在实际的程序中,它们分别来自于单独编译的库。

此外,还必须考虑定义和声明在这些文件之间的共享问题。我们尽可能把共享的部分集中在一起,这样就只需要一个副本,改进程序时也容易保证程序的正确性。我们把这些公共部分放在头文件calc.h 中,在需要使用该头文件时通过#include 指令将它包含进来(#include指令将在4.11 节中介绍)。这样分割后,程序的形式如下所示:

在这里插入图片描述

我们对下面两个因素进行了折衷:一方面是我们期望每个文件只能访问它完成任务所需的信息;另一方面是现实中维护较多的头文件比较困难。我们可以得出这样一个结论:对于某些中等规模的程序,最好只用一个头文件存放程序中各部分共享的对象。较大的程序需要使用更多的头文件,我们需要精心地组织它们。

6. 静态变量

6.1 修饰外部变量

某些变量,比如文件stack.c中定义的变量sp与val以及文件getch.c中定义的变
量buf 与bufp,它们仅供其所在的源文件中的函数使用,其它函数不能访问。用static声明限定外部变量与函数,可以将其后声明的对象的作用域限定为被编译源文件的剩余部分。通过static 限定外部对象,可以达到隐藏外部对象的目的,比如,getch-ungetch 复合结构需要共享buf与bufp两个变量,这样buf与bufp必须是外部变量,但这两个对象不应该被getch与ungetch函数的调用者所访问。

要将对象指定为静态存储,可以在正常的对象声明之前加上关键字static 作为前缀。

如果把上述两个函数和两个变量放在一个文件中编译,如下所示:

static char buf[BUFSIZE]; /* buffer for ungetch */
static int bufp = 0; /* next free position in buf */
int getch(void) { ... }
void ungetch(int c) { ... }

那么其它函数就不能访问变量buf与bufp,因此这两个名字不会和同一程序中的其它文件中的相同的名字相冲突。同样,可以通过把变量sp 与val 声明为静态类型隐藏这两个由执行栈操作的push与pop函数使用的变量。

6.2 修饰函数

外部的static 声明通常多用于变量,当然,它也可用于声明函数。通常情况下,函数名字是全局可访问的,对整个程序的各个部分而言都可见。但是,如果把函数声明为static类型,则该函数名除了对该函数声明所在的文件可见外,其它文件都无法访问。

6.3 修饰内部变量

static也可用于声明内部变量。static类型的内部变量同自动变量一样,是某个特定
函数的局部变量,只能在该函数中使用,但它与自动变量不同的是,不管其所在函数是否被调用,它一直存在,而不像自动变量那样,随着所在函数的被调用和退出而存在和消失。换句话说,static类型的内部变量是一种只能在某个特定函数中使用但一直占据存储空间的变量。

7. 寄存器变量

register 声明告诉编译器,它所声明的变量在程序中使用频率较高。其思想是,将
register 变量放在机器的寄存器中,这样可以使程序更小、执行速度更快。但编译器可以忽略此选项。

register声明的形式如下所示:

register int x;
register char c;

register声明只适用于自动变量以及函数的形式参数。下面是后一种情况的例子:

f(register unsigned m, register long n)
{
	register int i;
...
}

实际使用时,底层硬件环境的实际情况对寄存器变量的使用会有一些限制。每个函数中只有很少的变量可以保存在寄存器中,且只允许某些类型的变量。但是,过量的寄存器声明并没有什么害处,这是因为编译器可以忽略过量的或不支持的寄存器变量声明。另外,无论寄存器变量实际上是不是存放在寄存器中,它的地址都是不能访问的(有关这一点更详细的信息,我们将在第5章中讨论)。在不同的机器中,对寄存器变量的数目和类型的具体限制也是不同的。

8. 程序块结构

C语言并不是Pascal等语言意义上的程序块结构的语言,它不允许在函数中定义函数。但是,在函数中可以以程序块结构的形式定义变量。变量的声明(包括初始化)除了可以紧跟在函数开始的花括号之后,还可以紧跟在任何其它标识复合语句开始的左花括号之后。以这种方式声明的变量可以隐藏程序块外与之同名的变量,它们之间没有任何关系,并在与左花括号匹配的右花括号出现之前一直存在。例如,在下面的程序段中:

if (n > 0) {
	int i; /* declare a new i */
	for (i = 0; i < n; i++)
		...
}

变量i 的作用域是if 语句的“真”分支,这个i 与该程序块外声明的i 无关。每次进入程序块时,在程序块内声明以及初始化的自动变量都将被初始化。静态变量只在第一次进入程序块时被初始化一次。

自动变量(包括形式参数)也可以隐藏同名的外部变量与函数。在下面的声明中:

int x;
int y;
f(double x)
{
	double y;
}

函数f 内的变量x 引用的是函数的参数,类型为double;面在函数f 外,x是int 类型的外部变量。这段代码中的变量y也是如此。

在一个好的程序设计风格中,应该避免出现变量名隐藏外部作用域中相同名字的情况,否则,很可能引起混乱和错误。

9. 初始化

前面我们多次提到过初始化的概念,不过始终没有详细讨论。本节将对前面讨论的各种存储类的初始化规则做一个总结。

在不进行显式初始化的情况下,外部变量和静态变量都将被初始化为0,而自动变量和寄存器变量的初值则没有定义(即初值为无用的信息)。

定义标量变量时,可以在变量名后紧跟一个等号和一个表达式来初始化变量:

int x = 1;
char squota = '\'';
long day = 1000L * 60L * 60L * 24L; /* milliseconds/day */

对于外部变量与静态变量来说,初始化表达式必须是常量表达式,且只初始化一次(从概念上讲是在程序开始执行前进行初始化)。对于自动变量与寄存器变量,则在每次进入函数或程序块时都将被初始化。

对于自动变量与寄存器变量来说,初始化表达式可以不是常量表达式:表达式中可以包含任意在此表达式之前已经定义的值,包括函数调用,我们在3.3 节中介绍的折半查找程序的初始化可以采用下列形式:

int binsearch(int x, int v[], int n)
{
    int low = 0;
    int high = n - 1;
    int mid;
    ...
}

代替原来的形式:

int low, high, mid;
low = 0;
high = n - 1;

实际上,自动变量的初始化等效于简写的赋值语句。究竟采用哪一种形式,还得看个人的习惯。考虑到变量声明中的初始化表达式容易被人忽略,且距使用的位置较远,我们一般使用显式的赋值语句。

数组的初始化可以在声明的后面紧跟一个初始化表达式列表,初始化表达式列表用花括号括起来,各初始化表达式之间通过逗号分隔。例如,如果要用一年中各月的天数初始化数组days,其变量的定义如下:

int days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};

当省略数组的长度时,编译器将把花括号中初始化表达式的个数作为数组的长度,在本例中数组的长度为12。

如果初始化表达式的个数比数组元索数少,则对外部变量、静态变量和自动变量来说,没有初始化表达式的元素将被初始化为0,如果初始化表达式的个数比数组元素数多,则是错误的。不能一次将一个初始化表达式指定给多个数组元素,也不能跳过前面的数组元素而直接初始化后面的数组元素。

字符数组的初始化比较特殊:可以用一个字符串来代替用花括号括起来并用逗号分隔的初始化表达式序列。例如:

char pattern[] = "ould ";

它同下面的声明是等价的:

char pattern[] = { 'o', 'u', 'l', 'd'};

这种情况下,数组的长度是5(4 个字符加上一个字符串结束符’\0’)。

10. 递归

C语言中的函数可以递归调用,即函数可以直接或间接调用自身。我们考虑一下将一个数作为字符串打印的情况。前面讲过,数字是以反序生成的:低位数字先于高位数字生成,但它们必须以与此相反的次序打印。

解决该问题有两种方法。一种方法是将生成的各个数字依次存储到一个数组中,然后再以相反的次序打印它们,这种方式与3.6 节中itoa函数的处理方式相似。另一种方法则是使用递归,函数printd 首先调用它自身打印前面的(高位)数字,然后再打印后面的数字。这里编写的函数不能处理最大的负数。

#include <stdio.h>
/* printd: print n in decimal */
void printd(int n)
{
	if (n < 0) {
		putchar('-');
		n = -n;
	}
	if (n / 10)
		printd(n / 10);
	putchar(n % 10 + '0');
}

函数递归调用自身时,每次调用都会得到一个与以前的自动变量集合不同的新的自动变量集合。因此,调用printd(123)时,第一次调用printd的参数n=123。它把12传递给printd 的第二次调用,后者又把1 传递结printd 的第三次调用。第三次调用printd 时首先将打印1,然后再返回到第二次调用。从第三次调用返回后的第二次调用同样也将先打印2,然后再返回到第一次调用。返回到第一次调用时将打3,随之结束函数的执行。

另外一个能较好说明递归的例子是快速排序。快速排序算法是C. A. R. Hoare于1962 年发明的。对于一个给定的数组,从中选择一个元素,以该元素为界将其余元素划分为两个子集,一个子集中的所有元素都小于该元索,另一个子集中的所有元素都大于或等于该元素。对这样两个子集递归执行这一过程,当某个子集中的元素数小于2 时,这个子集就不需要再次排序,终止递归。

从执行速度来讲,下列版本的快速排序函数可能不是最快的,但它是最简单的算法之一。在每次划分子集时,该算法总是选取各个子数组的中间元素。

/* qsort: sort v[left]...v[right] into increasing order */
void qsort(int v[], int left, int right)
{
	int i, last;
	void swap(int v[], int i, int j);
	if (left >= right) /* do nothing if array contains */
		return; /* fewer than two elements */
	swap(v, left, (left + right)/2); /* move partition elem */
	last = left; /* to v[0] */
	for (i = left + 1; i <= right; i++) /* partition */
		if (v[i] < v[left])
			swap(v, ++last, i);
	swap(v, left, last); /* restore partition elem */
	qsort(v, left, last-1);
	qsort(v, last+1, right);
}

这里之所以将数组元素交换操作放在一个单独的函数swap 中,是因为它在qsort 函数中要使用3 次。

/* swap: interchange v[i] and v[j] */
void swap(int v[], int i, int j)
{
    int temp;
    temp = v[i];
    v[i] = v[j];
    v[j] = temp;
}

标准库中提供了一个qsort函数,它可用于对任何类型的对象排序。

递归并不节省存储器的开销,因为递归调用过程中必须在某个地方维护一个存储处理值的栈。递归的执行速度并不快,但递归代码比较紧凑,并且比相应的非递归代码更易于编写与理解。在描述树等递归定义的数据结构时使用递归尤其方便。我们将在6.5节中介绍一个比较好的例子。

11. C预处理器

C语言通过预处理器提供了一些语言功能。从概念上讲,预处理器是编译过程中单独执行的第一个步骤。两个最常用的预处理器指令是:#include 指令(用于在编译期间把指定文件的内容包含进当前文件中)和#define指令(用任意字符序列替代一个标记)。本节还将介绍预处理器的其它一些特性,如条件编译与带参数的宏。

11.1 文件包含

文件包含指令(即#include指令)使得处理大量的#define指令以及声明更加方便。
在源文件中,任何形如:

#include "文件名"

#include <文件名>

的行都将被替换为由文件名指定的文件的内容。如果文件名用引号引起来,则在源文件所在位置查找该文件;如果在该位置没有找到文件,或者如果文件名是用尖括号<与>括起来的,则将根据相应的规则查找该文件,这个规则同具体的实现有关。被包含的文件本身也可包含#include指令。

源文件的开始处通常都会有多个#include指令,它们用以包含常见的#define语句和
extern声明,或从头文件中访问库函数的函数原型声明,比如<stdio.h>。(严格地说,这些内容没有必要单独存放在文件中;访问头文件的细节同具体的实现有关。)

在大的程序中,#include 指令是将所有声明捆绑在一起的较好的方法。它保证所有的源文件都具有相同的定义与变量声明,这样可以避免出现一些不必要的错误。很自然,如果某个包含文件的内容发生了变化,那么所有依赖于该包含文件的源文件都必须重新编译。

11.2 宏替换

宏定义的形式如下:

#define 名字 替换文本

这是一种最简单的宏替换——后续所有出现名字记号的地方都将被替换为替换文本。#define指令中的名字与变量名的命名方式相同,替换文本可以是任意字符串。通常情况下,#define指令占一行,替换文本是#define指令行尾部的所有剩余部分内容,但也可以把一个较长的宏定义分成若干行,这时需要在待续的行末尾加上一个反斜杠符\。#define 指令定义的名字的作用域从其定义点开始,到被编译的源文件的末尾处结束。宏定义中也可以使用前面出现的宏定义。替换只对记号进行,对括在引号中的字符串不起作用。例如,如果YES是一个通过#define指令定义过的名字,则在printf(“YES”)或YESMAN中将不执行替换。

替换文本可以是任意的,例如:

#define forever for (;;) /* infinite loop */

该语句为无限循环定义了一个新名字forever。

宏定义也可以带参数,这样可以对不同的宏调用使用不同的替换文本。例如,下列宏定义定义了一个宏max:

#define max(A, B) ((A) > (B) ? (A) : (B))

使用宏max 看起来很像是函数词用,但宏调用直接将替换文本插入到代码中。形式参数(在此为A或B)的每次出现都将被替换成对应的实际参数。因此,语句:

x = max(p+q, r+s);

将被替换为下列形式:

x = ((p+q) > (r+s) ? (p+q) : (r+s));

如果对各种类型的参数的处理是一致的,则可以将同一个宏定义应用于任何数据类型,而无需针对不同的数据类型需要定义不同的max函数。

仔细考虑一下max 的展开式,就会发现它存在一些缺陷。其中,作为参数的表达式要重复计算两次,如果表达式存在副作用(比如含有自增运算符或输入/输出),则会出现不正确的情况。例如:

max(i++, j++) /* WRONG */

它将对每个参数执行两次自增操作。同时还必须注意,要适当使用圆括号以保证计算次序的正确性。考虑下列宏定义:

#define square(x) x * x /* WRONG */

当用squrare(z+1)调用该宏定义时会出现什么情况呢?

但是,宏还是很有价值的。<stdio.h>头文件中有一个很实用的例子:getchar 与
putchar 函数在实际中常常被定义为宏,这样可以避免处理字符时调用函数所需的运行时开销。<ctype.h>头文件中定义的函数也常常是通过宏实现的。

可以通过#undef指令取消名字的宏定义,这样做可以保证后续的调用是函数调用,而不是宏调用:

#undef getchar
int getchar(void) { ... }

形式参数不能用带引号的字符串替换。但是,如果在替换文本中,参数名以#作为前缀则结果将被扩展为由实际参数替换该参数的带引号的字符串。例如,可以将它与字符串连接运算结合起来编写一个调试打印宏:

#define dprint(expr) printf(#expr " = %g\n", expr)

使用语句

dprint(x/y)

调用该宏时,该宏将被扩展为:

printf("x/y" " = &g\n", x/y);

其中的字符串被连接起来了,这样,该宏调用的效果等价于

printf("x/y = &g\n", x/y);

在实际参数中,每个双引号"将被替换为",反斜杠\将被替换为\,因此替换后的字符串是合法的字符串常量。

预处理器运算符##为宏扩展提供了一种连接实际参数的手段。如果替换文本中的参数与##相邻,则该参数将被实际参数替换,##与前后的空白符将被删除,并对替换后的结果重新扫描。例如,下面定义的宏paste用于连接两个参数

#define paste(front, back) front ## back

因此,宏调用paste(name, 1)的结果将建立记号name1。

11.3 条件包含

还可以使用条件语句对预处理本身进行控制,这种条件语句的值是在预处理执行的过程中进行计算。这种方式为在编译过程中根据计算所得的条件值选择性地包含不同代码提供了一种手段。

#if语句对其中的常量整型表达式(其中不能包含sizeof、类型转换运算符或enum常量)进行求值,若该表达式的值不等于0,则包含其后的各行,直到遇#endif、#elif或#else 语句为止(预处理器语句#elif 类似于else if)。在#if 语句中可以使用表达式defined(名字),该表达式的值遵循下列规则:当名字已经定义时,其值为1;否则,其值为0。

例如,为了保证hdr.h文件的内容只被包含一次,可以将该文件的内容包含在下列形式的条件语句中:

#if !defined(HDR)
#define HDR
/* hdr.h文件的内容放在这里*/
#endif

第一次包含头文件hdr.h时,将定义名字HDR;此后再次包含该头文件时,会发现该名字已经定义,这样将直接跳转到#endif处。类似的方式也可以用来避免多次重复包含同一文件。如果多个头文件能够一致地使用这种方式,那么,每个头文件都可以将它所依赖的任何头文件包含进来,用户不必考虑和处理头文件之间的各种依赖关系。

下面的这段预处理代码首先测试系统变量SYSTEM,然后根据该变量的值确定包含哪个版本的头文件:

#if SYSTEM == SYSV
	#define HDR "sysv.h"
#elif SYSTEM == BSD
	#define HDR "bsd.h"
#elif SYSTEM == MSDOS
	#define HDR "msdos.h"
#else
	#define HDR "default.h"
#endif
#include HDR

C语言专门定义了两个预处理语句#ifdef与#ifndef,它们用来测试某个名字是否已经定义。上面有关#if的第一个例子可以改写为下列形式:

#ifndef HDR
#define EDR
/* hdr.h文件的内容放在这里*/
#endif
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

昵称系统有问题

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值