一个C程序由一系列的外部对象组成,这些外部对象不是变量就是函数。“外部”这个形容词用于区别于“内部”,后者描述的是函数参数及其内部定义的变量。外部变量在所有函数之外定义,这样就可能会被很多函数使用。函数本身总是外部的,因为C不允许在函数内部定义函数。默认情况下,外部变量和函数有这样的属性:对同一个名称的所有引用(即使这个引用来自于独立编译的函数)全都指向相同的对象。(标准将这个属性称为外部链接。)在这个意义上来说,C 的外部变量类似于 FORTRAN 的 COMMON 块,或是 Pascal 最外层块里的变量。后面章节我们将会看到如何定义只在单个源文件内可见的外部变量和函数。
由于外部变量是全局可访问的,因此它们能作为函数参数及返回值的替代品,用来在函数之间交流数据。如果某个外部变量的名字以某种方式声明过,则所有函数都能通过引用该外部变量的名称来访问它。
如果有大量的变量必须在函数之间共享,外部变量会比长长的参数列表更加方便且高效。然而,正如第一章中所指出的,这个做法要谨慎使用,因为它对程序结构有害,并且会导致我们写出函数间存在过多数据关联(耦合)的程序。
外部变量的用处还体现在它们有更大的作用域(scope)和生命周期。自动变量是在函数内部的;它们在函数进入时出现,在函数退出时消失。另一方面,外部变量是永久的,所以在从一个函数调用到其他函数时,它们的值仍然保留着。这样的话,若两个函数必须共享一些数据,不管是谁调谁,如果将共享数据保存在外部变量中,总是会比通过参数来传入传出更方便。
我们来通过一个大些的例子来进一步审视下这个问题。现在要写个支持加减乘除操作符( + - * / )的计算器程序。计算器使用逆波兰表示法而不是中缀表示法,因为前者更容易实现。(逆波兰表示法用于某些便携式计算器,并且用在一些计算机语言中,如Forth 和 Postscript。)
在逆波兰表示法中,每个操作符后面跟着它的操作数;中缀表达式如:
(1 - 2) * (4 + 5)
要以下面的方式输入:
1 2 - 4 5 + *
括号是不需要的;只要我们知道每个操作符期望接收多少个操作数,这种表示法就不会产生歧义。
代码实现很简单。每个操作数都被推到一个栈上;当操作符到来时,正确数量的操作数(对二元操作符来说是2个)被出栈,并对它们应用这个操作符,然后再将结果推回到栈上。比如,在上面的例子中,首先推入栈的是 1 和 2,然后栈上内容被替换为它们的差 -1。接着,4 和 5 被推入栈,然后被它们的和 9 替换。 -1 和 9 又被它们的积,即 -9 所替换。当输入行到达末尾时,栈顶的值被出栈并打印出来。
因此,程序的结构就是一个循环,在每个操作符和操作数出现时,进行合适的处理:
while (下一个操作符或操作数不是EOF标识)
if (是数)
入栈
else if (是操作符)
对操作数出栈
进行操作
将结果入栈
else if (是换行符)
栈顶内容出栈并打印
else
错误
入栈和出栈操作是简单的,但当加入错误检测和恢复之后,这些代码就会变得很长,所以最好是把每个操作都放在单独的函数中,而不是在程序里到处重复相同的代码。另外还要有个函数,用来获取下一个操作符或操作数。
就还剩一个主要的设计决策没有讨论,就是把栈放在哪里,即哪些例程(函数)能直接访问它。可以设计成把它放在 main 里面,并将栈及其当前位置传给需要入栈和出栈的例程。但 main 不需要知道控制栈的变量;它只是做入栈和出栈操作。所以我们决定不将栈及其关联信息存入main, 而是将它们存入外部变量中,让 push 和 pop 函数能够访问,但 main 不能。
将这个概要设计翻译成代码十分简单。如果现在我们想象整个程序放在一个源文件中,看起来如下:
#include //多个
#define //多个
main里面用到的函数的声明
main() { ... }
push 和 poo 用到的外部变量
void push(double f) { ... }
double pop(void) { ... }
int getop(char s[]) { ... }
被getop调用的例程
后面章节我们会讨论怎么将它拆分到两个或多个源文件中。
这里的 main 函数就是个大循环,里面包含了一个以操作符或操作数类型为分支条件的大switch;这种用法比3.4节所展示的例子更为典型。
#include
#include /* 用了atof() */
#define MAXOP 100 /* 操作符或操作数的最大长度 */
#define NUMBER '0' /* 表示遇到了数字 */
int getop(char []);
void push(double);
double pop(void);
/* 逆波兰计算器 */
main()
{
int type;
double op2;
char s[MAXOP];
while ((type = getop(s)) != EOF) {
switch (type) {
case NUMBER:
push(atof(s));
break;
case '+':
push(pop() + pop());
break;
case '*':
push(pop() * pop());
break;
case '-':
op2 = pop();
push(pop() - op2);
break;
case '/':
op2 = pop();
if (op2 != 0.0)
push(pop() / op2);
else
printf("error: zero divisor\n");
break;
case '\n':
printf("\t%.8g\n", pop());
break;
default:
printf("error: unknown coomand %s\n", s);
break;
}
}
return 0;
}
因为 + 和 * 的操作数是可交换的,因此操作数出栈和结合的顺序无关紧要,但对 - 和 / 来说,两个操作数必须区分谁左谁右。若写成
push(pop() - pop()); /* 错误 */
则 pop 两次调用的顺序是未定义的。为了保证顺序正确,有必要将第一个值出栈并保存到临时变量值中,正如main中代码所示。
#define MAXVAL 100 /* 数值栈的最大深度 */
int sp = 0; /* 下个一个空闲的栈位置 */
double val[MAXVAL]; /* 数值栈 */
/* push:将f 推入数值栈 */
void push(double f)
{
if (sp < MAXVAL)
val[sp++] = f;
else
printf("error: stack full, can't push %g\n", f);
}
/* pop: 从数值栈的栈顶出栈并返回其值 */
double pop(void)
{
if (sp > 0)
return val[--sp];
else {
printf("error: stack empty\n");
return 0.0;
}
}
如果变量定义在所有函数的外面,则变量是外部的。栈和栈索引必须被 push 和 pop 共享,因此就被定义在这些函数外面。但 main 本身不引用栈或栈索引——它们可以被隐藏。
现在让我们转到 getop 的实现上来,它用于获取下一个操作符或操作数。任务很简单。首先跳过空白字符和制表符。如果下一个字符不是数字或小数点,则将它返回。否则,收集数位字符串(可能包含小数点),并返回 NUMBER,用来标识收集到了一个数。
#include
int getch(void)
void ungetch(int)
/* getop: 获取下一个操作符或者数字操作数 */
int getop(char s[])
{
int i, c;
while ((s[0] = c = getch()) == ' ' || c == '\t')
;
s[1] = '\0';
if (!isdigit(c) && c != '.')
return c; /* 非数字 */
i = 0;
if (isdigit(c)) /* 收集整数部分 */
while (isdigit(s[++i] = c = getch()))
;
if (c == '.') /* 收集小数部分 */
while (isdigit(s[++i] = c = getch()))
;
s[i] = '\0';
if (c != EOF)
ungetch(c);
return NUMBER;
}
getch 和 ungetch 是什么?通常情况下,程序无法确定它是否读入了足够的输入,直到它读过头了。以收集一个数所包含的字符为例:除非读到一个非数字字符,否则这个数就是不完整的。但这个时候程序已经多读了一个字符,一个不归他处理的字符。
如果能把不想要的字符 “取消读” ,就能解决这个问题。此后,每当程序多读了一个字符,它就能将该字符推回给输入,因此对后面的代码来说,就像该字符没被读过一样【即后面的代码再次能读到这个字符,而不会丢失】。幸运的是,通过写一对互相协作的函数,很容易模拟对一个字符的“取消读”。getch 负责分发下一个待处理的输入字符; ungetch 记住要推回给输入的字符,这样后续调用 getch 时会先返回这些字符,而不是直接读取新的输入。
让它们协作起来也简单。ungetch 把要推回的字符放到一个共享缓冲区中——一个字符数组。getch 在被调用时,如果缓冲区有内容,就从中读取,如果缓冲区是空的,则 getch 调用 getchar。还必须有个索引变量,记录缓存区中当前字符的位置。
由于缓冲区和索引被 getch 和 ungetch 共享,而且它们的值必须在函数调用间保持,故它们对两个例程而言必须都是外部的。这样我们可以按如下方式来编写 getch,ungetch 和它们的共享变量:
#define BUFSIZE 100
char buf[BUFSIZE]; /* ungetch 的缓冲 */
int bufp = 0; /* buf的下一个空闲位置 */
int getch(void) /* 获取一个(可能是被推回的)字符 */
{
return (bufp > 0) ? buf[--bufp] : getchar();
}
void ungetch(int c) /* 推回一个字符给输入 */
{
if (bufp >= BUFSIZE)
printf("ungetch: too many characters\n");
else
buf[bufp++] = c;
}
标准库包含了一个支持推回一个字符的函数 ungetc ,我们将在第七章讨论它。我们这里使用一个数组来做推回,是比一个字符更加通用的做法。
练习4-3、给出了基本框架后,扩展这个计算器就很简单了。增加取模(%)操作符以及对负数的支持。
练习4-4、增加打印栈顶元素但不出栈的命令,复制栈顶元素的命令,以及交换栈顶两个元素的命令。增加清除栈的命令。
练习4-5、增加对库函数如 sin,exp 和 pow 的访问。 见附录B第四节的
练习4-6、增加处理变量的命令。(以单个字母作为名称,可以很容易提供26个变量)。增加最近打印值的变量。
练习4-7、写个例程 ungets(s),将整个字符串推回给输入。ungets应当知道 buf 和 bufp吗,或者它只知道 ungetch ?
练习4-8、假定永远不会推回超过1个字符。据此修改 getch 和 ungetch。
练习4-9、我们的 getch 和 ungetch 不能正确地推回 EOF。如果EOF需要被推回,先确定它们属性应当如何,然后实现你的设计。
练习4-10、还有一种方案是使用 getline 来读取整个输入行,这样就不需要 getch 和 ungetch 了。按这个方式来修订计算器。