itpt_TCPL 第四章:函数和程序结构

2016.08.30 – 10.09
个人英文阅读练习笔记(极低水准)。

08.30
第四章:函数和程序结构
函数能够将大型的计算任务分解为多个小型的计算任务,并且程序员还能够利用别人已经编写好的函数(这样,就不必从头设计、编写这样的函数)。一个好的函数会将函数体所操作的细节隐藏起来并独立于其余程序,如此就不必知道函数的细节,这样会使得整体更加清晰,也更易于程序的修改。

用C编写的函数具高效和易用的特点;C程序通常由许多小型函数组成,而非由一些较大的函数组成。一个程序可以由一个文件或多个文件组成。源文件可以被分开编译并一起被加载,同之前编译过的来自库的函数一起。本章不会讨论编译、链接以及记载的过程,因为其细节会因系统不同而不同。

函数声明和函数定义是ANSI标准对C所作的最大改变。正如第一章所示,声明函数时可以声明参数类型。函数定义的语法也有所改变,以让定义和声明相匹配。这样就能够让编译器检测更多的错误。另外,当参数被合理地声明时,相应地类型会被自动执行。

标准说明了命名作用域规则;特别是,每个外部变量只能被定义一次。初始化变得更加通用:自动的数组和结构体可以被初始化了。

C预处理器也被加强了。新的预处理工具包含了一套更完整预编译指令,从宏参数创建引起来的字符串,且能够更好的处理宏展开过程。

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 I and I with Fate conspire
Would not we shatter it to bits -- and then
Re-mould it nearer to the Heart’s Desire!

该过程可以分为三部分:

while (there's another line)
if (the line contains the pattern)
    print it

尽管可以将所有的代码都写在main函数内,但更好的方式是通过将每个独立的部分编写成一个函数以让程序结构化。三个更小的函数比一个更大的函数更易处理,因为对于彼此不相关的细节可以被隐藏在其所在的函数内,并可以让不相关的关联得到减小。并且,这些小型的函数也可能被其它程序所使用。

“while there’s another line”可以用第一章中所编写的getline函数,“print it”可以使用printf,该函数已经由某人(在库中)编写好提供给我们了。这就意味着我们仅需编写判断一行中是否包含模式的程序了。

我们可以编写一个名为strindex(s, t)的函数,它返回t字符串在s字符串中开始的位置或索引,如果s并不包含t则返回-1。因为C的数组是从位置0开始,索引就会是0或者正数,所以像-1这样的数字便可以用来表示字符串s不包含字符串t的信息。若以后需要更为复杂的模式匹配时,我们只需要替换掉strindex;余下的代码仍旧可用。(标准库提供的strstr函数类似这里的strindex,当然,strstr返回一个指针而非返回一个索引)

鉴于给出了这么多的设计工作,填写程序的细节就变得简单了。唯有的一件事就是将这些部分组合起来。这里被搜索的模式是一串字符文本,并不是最通用的机制。本章会简单的讨论下如何初始化字符数组,在第五章将会展示如何将模式作为一个运行程序的参数。这里的getline跟第一章所编写的稍显不同;你可以找出与第一章的getline的不同之处。

#include <stdio.h>
#define MAXLINE 1000    /* 输入行的最大长度 */

int getline(char line[], int max);
int strindex(char source[], char searchfor[]);

char partern[] = "ould";    /* 预搜索的模式 */

/* 寻找匹配模式的所有行 */
main()
{
    char line[MAXLINE];
    int found = 0;

    while (getline(line, MAXLINE) > 0) 
        if (strindex(line, pattern) >= 0) {
            printf("%s", line);
            found++;
        }
    return found;
}

/* getline: 获取一行到s,并返回该行的长度 */
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:返回t在s中的索引,若t不在s中,则返回-1 */
int strindex(char s[], char t[])
{
    int i, j, k;

    i    = 0;
    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;
}

09.04
函数定义的格式如下:

rerurn-type function-name(argument declarations)
{
    declarations and statements
}

各部分可以被省略;一个最小的函数为

dummy() {}

该函数不含任何操作,也不会有任何的返回。像这样什么也不做的函数在开发程序时可以用来在源文件中占一个位置。如果返回类型被省略,那么int类型会被默认为该函数的返回类型

程序就是由函数和变量构成的。各函数之间的交流依靠传参和返回值,也可以通过外部变量来完成交流。函数可以出现在源文件中的任何位置,源程序也可以被写在多个文件中,只要不将单个函数分裂即可。
return语句会从被调用函数中返回一个值给调用该函数者。return后可以是任何类型的表达式:

return expression;

expression将会被转换成该函数的返回值类型,如果该表达式与函数的返回值类型不一致的话。expression可以用括号将其括起来,不过该括号是可选的。

调用函数(父函数)完全可以忽略子函数的返回值。另外,return之后也可以没有表达式;在这种情况下,就没有任何值返回给父函数。当执行到子函数的右结束大括号时,也没有任何值返回给父函数,但控制流就会返回到父函数。这并不是不符合规则,但可能会有点小麻烦:如果一个函数在其中一种情况下会返回一个值,而在另外一种情况下并不返回值

在任何情况下,如果一个函数返回一个值失败,其值要确定被回收。
模式搜索程序会从main函数中返回一个状态,即匹配的数量。该值会被调用该程序的环境所用。

编译及加载由多个文件组成的C程序的机制依具体系统而定。如在UNIX系统上,第一章所提到的cc命令将完成此项工作。假设有三个函数分别在三个文件中,它们是main.c,getline.c,strindex.c。命令

cc  main.c getline.c strindex.c

将会编译这三个文件,将目标代码分别保存在main.o,getline.o和strindex.o中,然后再将这些目标文件载入一个称为a.out的可执行文件中。如果在这个过程中有错误出现,提示说是在main.c中的错误,那么就可以在修改main.c后只重新编译main.c文件并载入之前已经正确的目标代码即可,即
cc main.c getline.o strindex.o
cc命令使用“.c”与“.o”分别对应C源程序文件和目标文件。

练习 4-1。编写函数strrindex(s, t),该函数返回t在s中最靠右的位置,若t不在s中则返回-1。

2 返回非整型的函数

到目前为止,我们函数例子要么返回整型或根本不返回任何值。如果函数必须返回其它的一些类型呢?许多数值型函数像sqrt,sin以及cos会返回double类型;其它的一些特殊的函数还会返回其它类型。欲演示如何处理这些返回值,我们编写并使用函数atof(s),该函数将字符串s转换为等价的双精度浮点型。atof是在第二章和第三种所编写的atoi的一个扩展。它将处理可选的符号和小数点。这里的程序并不是一个高质量转换程序版本;如果追求该质量则会要用更多的空间来编写,将会使得使用该函数变成非重点。标准库中包含一个atof函数,其在stdlib.h中被声明。

首先,atof必须声明它返回的类型,因为它的返回值并不是int类型。类型名写在函数名之前:

#include <ctype.h>
/* atof:将字符串s转换为double类型 */
double atof(char s[])
{
    double val,  power;
    int i, sign;

    for (i = 0; isspace(s[i]); i++) /* 跳过空白符 */
        ;
    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.0;
    }
    return sign * val / power;
}

其次,调用程序必须知道atof返回类型非整型。一种方式是在调用程序中精确的声明atof函数。该声明在原计算器中展示,该程序从每一行中读取一个数字,该数字可以包含符号,并将它们加起来,最后将和打印出来:

#include <stdio.h>
#define MAXLINE 100
/* 简单基本的计算器 */
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的定义和在调用atof的main(跟atof在同一个源文件中)中的声明不一致,编译器将会检测出该错误。但如果atof被独立编译,该错误就不会被检测到,atof其实是返回double类型但在main中将会被认为返回值为int类型,这就会导致无意义的结果

对于刚说提到的函数声明和定义必须保持一致的话题,似乎有些令人惊讶。如果没有函数原型,就有可能发生不匹配的情况,函数在其第一次出现时会被隐式声明,如

sum +=atof(line);

如果一个在之前从未被声明的名字出现在表达式中且其后紧跟括号,该名字就会被当成一个函数,且该函数的返回值会被假设为int类型,并不对其参数作任何假设。另外,如果函数声明没有包含任何参数,如

double atof();

这也意味着不对其参数作任何假设;所有的参数检查都将会被关闭。空参数列表的特殊意义是允许老的C程序能够被新的编译器编译。但在新的程序中使用这种风格就不太恰当了。如果函数确实带了参数,就声明该参数;如果该函数没有带参数,就在参数列表中使用void关键字

通过合适的声明atof函数,我们可以编写出atoi(将字符串转换为整型)函数:

/* atoi:将字符串转换为整型 */
int atoi(char s[])
{
    double atof(char s[]);
    return (int) atof(s);
}

注意声明和return 语句的结构。在

return expression;

中的值在return语句发生前就被转换为与函数返回类型同类型的值。因此,atof返回的double值被转换成了int类型,因为atoi函数的返回值类型为int。然而,将浮点数直接返回给一个返回值类型为整型的函数将会丢失信息,所以一些编译器会对该语句给予警告提示。使用这种强制类型转换后,编译器就不会给出警告了。

练习 4-2。扩展atof函数处理科学计数方式 —— 123.45e-6,即浮点数可以跟一个e或E以及可选的符号指数。

3 外部变量

09.07
C程序由一些列外部对象组成,即变量和函数。形容词外部和内部相反,内部被用来描述函数的参数和在其内定义的变量。外部变量定义在函数外部,因此外部变量对许多函数都可访问。函数自身也是外部的,因为C函数不允许嵌套定义。默认情况下,外部变量和函数有能够被其它地方引用的属性,即使函数被分开编译(存在于独立的文件中)。(标准称该属性为外部链接属性)从这个意义上讲,外部变量跟Fortran的COMMON块或Pascal最外层的变量相似。我们将在后续内容学习如何定义只在单个源文件中可见的变量和函数。

因为外部变量是可被全局访问的,所以它是除了函数参数和返回值之外的可用于函数间数据通信的方式。任何函数都可以通过外部变量的名字而访问它,如果该名在之前被声明过的话。

如果函数之间需要分享很多的变量,那么外部变量相比很长的参数列表来说就更加方便和高效。然而,正如在第一章中指出的那样,应谨慎使用外部变量,因为它对程序结构有不良影响,并会导致程序中的子程序之间有太多的关联。

由于其作用域和生命期,外部变量也是有用的。自动变量位于函数内;当进入函数时,这些自动变量才会出现,当函数退出时,局部变量也相应地消失了。然而,对于外部变量来说,它在程序的结束前会永久存在,所以它的值在被下一次改变之前都会一直保持着。因此,如果两个函数必须共享一些数据,且不调用其它的函数,那么分享数据存在外部变量中而非采用参数的形式来实现数据分享是最方便的方式。

我们以一个更大的程序来深入检查该问题。即编写一个计算器程序,提供+,-,*,/四种运算操作。为了更易实现,计算机将会用波兰表示法代替中缀表示法。(逆波兰表示法被一些小型的计算器使用,常用Forth和Postscript这样的语言编写)
在逆波兰表示法中,每个运算符紧跟其操作数;中缀表达式
(1 – 2) * (4 + 5)
可以表达为波兰表达式
1 2 – 4 5 + *
在波兰表达式中,不再需要括号;只要知道每个运算符有几个操作数,每个符号的意义就清晰了。

09.12
该过程的实现比较简单。每个操作数都会被压入栈中;当遇到一个操作符时,适当数量的操作数(二元运算符需要两个操作数)就会被出栈,且该次的计算结果也会被压入栈中。对于上例,1和2会被相继入栈,然后用它们的差-1代替它俩入栈。接着,4和5会入栈然后被它们在栈中的位置被它们的和所代替。栈顶的值将会被当成结果输出来,当输入完成时。

根据以上描述,程序结构就是一个用来执行每当出现一个操作符所需操作数的循环:

while (next operator or operand is not end-of-file indicator)
    if (number)
        push it
    else if (operator)
        pop operands
        do operation
        push result
    else if (newlien)
        pop and print top of stack
    else
        error

在检测到错误或增加清楚操作前,操作数的出栈和入栈操作是比较频繁的,它们的长度足够被写在一个单独的函数里,免得它们在程序中被反复编写。同时也需要一个来获取下一行操作数或操作符的函数。

主要的设计决策还未被讨论到,那就是程序会直接访问的栈。一种可能的方式是将栈设在main中,并将栈和当前的栈位置传递给子函数使用。但是main并不需要知道控制栈的变量;它只做出栈和入栈操作。所以我们决定将栈和与它关联的信息设置成全局可访问的变量,而不设置在main函数中。

将程序轮廓转换成代码比较简单。如果我们认为程序只存在于一个源文件中,它看起来大概如下:

#includes
#defines
function declarations for main
main() {...}
external variables for push and pop
void push(double f) {...}
double pop(void) {...}
int getop(char s[]) {...}
routines called by getop

稍后我们再讨论如何将它们放在两个或多个源文件中。

main函数中的循环包含一个较大的switch语句,用于判断是操作数还是操作符;switch的典型应用在3.4节会呈现。

#include <stdio.h>
#include <stdlib.h> /* for atof() */
#define MAXOP   100 /* 操作数或操作符的最大个数 */
#define NUMBER  ‘0’ /* 用来得到数字的信号 */
int getop(char []);
void push(double);
double pop(void);

/* 逆波兰计算器 */
main()
{
    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 command %s\n", s);
                break;
            }
    }
    return 0;
}

因为 + 和 * 交换运算符,它们的两个操作数的顺序是可换的,但是对于 – 和 / 来说,左操作数和右操作数的顺序不能颠倒。式子

push(pop() - pop()) /* 错误 */

push函数内的两个pop函数的调用顺序是没有被定义的。为了保证两个操作数的正确顺序,有必要先出栈一个元素出来保存起来。

#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函数共享,所以它们被定义在函数外。但mian函数本身并不涉及栈或栈位置 —— 所以这些变量不用在main函数中声明。

现在来实现getop函数,该函数获取下一行中的操作数或者操作符。该任务比较简单。跳过空格和制表符。如果下一个字符不是数字或十进制点,就返回。否则,收集一串数字字符串(可能会包含十进制点),然后返回NUMBER,用来标识已经收集到了一个数字。

#include <ctype.h>
void ungetch(int);

/* getop: get next operator or numeric operand */
int getop(char s[])
{
    int i, c;
    while ((s[0] = c = getch()) == ' ' || c == '\t')
        ;
    s[1] = '\0';
    if (!isdigit(c) && c != '.')
        return c;   /* not a number */
    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;
}

09.14
getch和ungetch的功能是什么?程序即使在已经读取很多数据了仍旧不能判断是否已经读取足够了。一个例子是收集字符来组成一个数字:直到第一个非数字字符,否则数字的收集都不算完成。但此时,程序又多读了一个字符,该字符是数字所不需要的。

读取到不想要的字符问题需要被解决。程序每次读到一个不需要的字符时,它可以将其重新写到输入流中,后续代码就可以按照原本的功能处理该字符了。幸运地是,模拟将一个不需要的字符写入输入流中还不算太难。getch传送下一个需要被判别的字符;ungetch将不需要的字符重新送回输入流中,后续调用getch时,在读入新输入前能够将这些字符捕捉到。

它们的工作机制很简单。ungetch将回送的字符存到一个能被共享的缓冲区中 —— 一个字符数组。若缓冲区内有字符,getch就从缓冲区内读字符,若缓冲区为空,那么就调用getchar函数。必须要有记录当前素组中所包含字符的索引。

由于缓冲区和索引被getch和ungetch共享且在这些函数之后还要保存他们的值,所以它们可以被定义为全局变量。如此,我们可以编写getch、ungetch以及共享变量如下:

#define BUFSIZE 100
char buf[BUFSIZE]; /* 用于ungetch的缓冲区 */
int bufp = 0; /* 缓冲区中下一个空闲位置 */
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++];
}

标准库中包含ungetc函数,该函数字符回送(到输入流中);我们将在第7章讨论该函数。我们已经使用数组来作为回送的空间。

练习 4-3。根据给定的结构,简单扩展计算器。增加取余(%)运算符并规定负数运算。
练习 4-4。增加打印栈顶元素的命令(栈顶元素不出栈),只是复制栈顶元素,并交换栈顶的两个元素。增加清空栈的命令。
练习 4-5。增加诸如sin,exp以及pow这样子的库函数的访问。见附录B第四节的math.h。
练习 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了。使用该方法重写计算器程序。

4 作用域规则

09.16
组成C程序的函数和外部变量不必在同一时刻得到编译;程序源文件可能保存在多个文件中,之前编译的程序可能是从库中载过来的。以下是我们感兴趣的话题
[1] 如何正确书写变量的声明?
[2] 如何安排声明,在程序被装载时这些声明才会被正确的连接起来?
[3] 如何组织声明才能让变量只有一份拷贝?
[4] 如何初始化外部变量?

09.17
让我们来讨论将计算器程序组织到多个文件中的话题。这里的存在的问题是,这个计算器程序太小而没有必要将其分为几个源文件,但它可以作为大型程序由多个源文件组成的例子。

变量的作用域是在程序中该变量能够被使用的范围。对于声明在函数前的自动变量来讲,该变量的作用域就在该函数中。局部变量的作用域都只在其声明的范围内。函数的参数也是地道的局部变量。

外部变量或函数的作用域从定义开始一直延续到文件被编译的末尾。例如,main、sp、val、push以及pop都在一个文件中被定义,按照刚刚被列举的顺序,即

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

变量sp和val都可以在push和pop函数中被引用;没有必要有多余的声明操作。但这些变量以及push和pop函数对于main来说不可见。

换句话说,如果在定义外部变量之前被引用,或者它的定义和使用不在同一个文件中,使用extern关键字来声明就有必要了。

区分外部变量的定义和声明非常重要。声明是告知一个变量的属性;定义还带该变量会占用多少存储空间的信息。如果行

int sp;
double val[MAXVAL];

出现在任何函数之外,它们就是外部变量sp和val的定义,同时该语句也是文件后续部分的声明。换句话说,行

extern int sp;
extern double val[];

是文件后续部分的声明,表面sp是一个int类型的变量,val是一个double类型的数组(它的大小在文件的其它部分被定义),但该声明语句并不含该变量应该被分配多少存储空间的信息。

在组成程序的所有源文件中,外部变量的定义只能有一个;其它的文件可以用extern来声明后再使用。(也有可能在定义该变量的文件中声明该变量)数组的大小必须在定义时被指定,但对于用extern声明时,数组的大小是可选的。

不能在声明全局变量时初始化全局变量。

尽管这种结构对本程序来说不太必要,push和pop函数可以被编写在一个文件中,变量val和sp定义和被初始化在另外一个文件中。这些定义需要被声明在其它的文件中

在文件1中:

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

在文件2中:

int sp = 0;
double val[MAXVAL];

因为在文件1中的extern声明位于文件开始处并在push和pop函数定义之前,所以它们可以被push个pop函数引用到;要充分的声明文件1中的内容。如果sp和val的定义在某些函数之后,则需要相同方式的声明。

5 头文件

10.01
现在来考虑将计算器程序分割到几个源文件中,因为该程序的每部分都有可能变得更大。将主函数安排在一个叫main.c的文件中;将push、pop以及它们操作的相关变量保存到名为stack.c中;将getop函数放在getop.c文件中;最后,再将getch和ungetch放入getch.c文件中;我们将它们分开放在独自的文件中,这样它们就可以被分开编译。

需要考虑的问题是 —— 如何定义和声明文件中的共享变量。我们应尽量将共享变量的定义和声明集中起来,以让这些变量随着程序的演变而始终保持只有一份拷贝。因此,我们将这些通用的变量放在一个头文件中,如calc.h,它需要被包含在要使用这些变量的源文件中。(#include行在4.11节被描述)该程序各文件看起来类似这样:
这里写图片描述

需要权衡每个文件中需要包含可被访问的内容以及多个头文件的难以管理。对于一些适量大小的程序,最好是只用一个头文件来包含程序所需要的所有的共享变量;这就是我们需要做的决策。对于稍显更大的程序,需要多组织些头文件来供程序所需。

6 静态变量

10.02
变量sp和val在stack.c中,buf和bufp在getch.c中,这些变量都是这些文件私有供其内的函数使用,是不能被其它文件访问的。静态声明,在全局区或函数内,它的生命期存在被编译文件中开始于声明处,结束于文件结束。外部的static提供隐藏文件中诸如buf和bufp这样的变量,它必须是全局的,这样才能被共享,但它对调用getch和ungetch的用户来说是不可见的。

静态存储由声明前缀static指定。如果两个程序和两个变量在一个文件中被编译,如

static char buf[BUFSIZE];   /* 用于ungetch的缓冲区 */
static int bufp = 0;        /* buf中下一个空闲位置 */

int get getch(void) {...};
void ungetch(int c) {...};

其它的程序就不能访问buf和bufp了,在其它文件中声明与它们相同名字的变量时就不会有冲突。同理,通过用static关键字声明,push和pop操作的变量对于其它文件来说也是隐藏的。

全局静态变量需要用static关键字声明,static也可以用来声明一个全局静态函数。通常,函数都是全局,对于整个程序的其它部分都是可访问的。但,如果函数被声明为static,它在除了被声明的文件之外就是不可被访问的。

static声明也可以用于内部变量。内部变量对一个函数来说是局部的,它的作用域仅在一个函数内,但不像自动变量:在进入函数时出现,离开函数时被回收。内部static变量是函数私有的,但其生命周期跟整个程序(文件)一样长。

练习 4-11。修改getop函数,让其不再需要使用ungetch。提示:使用内部静态变量。

7 寄存器变量

10.03
regisgter声明会告之编译器该变量会被经常使用。register变量会被存储到寄存器里,这将会使得程序更小更快。但是编译器完全有自由忽略该声明。

register变量的声明类似以下形式

register int x;
register char c;

register声明只能用于自动变量以及函数的参数。在后一种情形中,它类似以下这样

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

在实际编程中,对寄存器变量有严格的约束,得依据具体的硬件。在一个函数中,只有几个变量能够被保存到寄存器中,而且也只有特定的类型才允许被声明为寄存器型。过多的寄存器声明没有害处,但是,register会因为过量或类型不允许而被忽略。而且,不敢寄存器变量是否真的被保存到了寄存器中,对于寄存器变量来说,是不能取其地址的(这部分内容在第五章会被涉及)。所允许的寄存器变量的数量以及寄存器变量的类型依不同机器而不同。

8 块结构

10.04
从根Pascal或类似语言来说,C并不是基于块结构的编程语言,因为函数不可以被嵌套定义。换句话说,变量可以定义在函数内的某个块结构中。变量(包含初始化部分)的声明不仅可以出现在函数体内,还可以在某左大括号后以形成复合语。在这样块中定义与块外同名的变量时,块外的同名变量会被隐藏,它们的生命期在左大括号和右大括号之间。如下例

if (n > 0) {
    int i; /* 在块内在声明一个名为i的新变量 */
    for (i = 0; i < n; i++)
        ...
}

变量i的作用域在if语句所限定的块内;该i与if块外的i没有关系。在块内的自动变量的声明和初始化都会在进入该块时被进行一次。static类型的变量仅在第一次进入块时被初始化和声明。

包括函数形参这样的自动变量,它隐藏外部相同的变量名和函数名。如以下例子

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

在f函数内,引用x时是引用的f函数的参数x,是double类型;在f函数外,引用的整型的x。这对y来说,也是这样。

作为一种风格,最好是避免使用会隐藏外部变量名的形式(命名);潜在的困惑或错误是比较严重的。

9 初始化

10.05
至此,初始化已经被提及很多次了,但都是通过其他主题而涉及到的。本节将会总结对于我们已讨论的几种存储类型的初始化的相关规则。

如果没有显示的初始化,对于全局和静态变量来讲,它们也会被保证其初始值为0;对自动变量和寄存器变量来讲,它们的初始值是不定的。

标量变量在被定义时通过在变量名后跟随等号和表达式的形式就可以将其初始化:

int x = 1;
char squote = '\'';
long day = 1000L * 60L * 60L * 24L; /* 每天的微秒数 */

对于外部和静态变量,初始化部分必须是一个常量表达式;它们的初始化通常是在程序执行前被完成。对于自动变量和寄存器变量,它们的初始化在每次进入函数或块时被完成。

对自动变量和寄存器变量来讲,初始化部分并不严格要求是一个常量;它可以是包含提前定义值的任何表达式,甚至可以是函数调用。例,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', '\0'};

在这种情形下,数组的大小为5(四个字符和终止符’\0’)。

10 递归

10.06
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,然后再返回到第二次调用的printd中。第二次被调用的printd将会打印2,接着就返回到第一次调用的printd中,该层函数调用打印3后就终止。

10.08
递归的另一个典型例子是quicksort,它是C. A. R. Hoare在1962年发明的一种排序方法。给定一个数组,选择其中一个元素,然后根据该元素将其余元素分成两子集 —— 一部分小于该元素,另一部分大于等于该元素。然后再用相同的方式处理两个子集。当子集小于两个元素时,就不需要再进行排序了;就停止递归操作。

以下版本的递归排序不是最快速的版本,但它是最简单的一个。我们使用中间元素来分割数组序列。

/* qsort: 增序排序v[left]...v[right] */
void qsort(int v[], int left, int right)
{
    int i, last;
    void swap(int v[], int i, int j);
    if (left >= right) /* 如果数组小于两个元素就什么也不做 */
        return ;
    swap(v, left, (left + right) / 2);
    last = left;
    for (i = left + 1; i <= right; i++)
        if (v[i] < v[left]
            swap(v, ++last, i);
    swap(v, left, last);
    qsort(v, left, last - 1);
    qsort(v, last + 1, right);
}

我们将交换操作放到一个单独的函数里,因为这个操作在快速排序中发生了三次。

/* swap: 交换v[i]和v[j] */
void swap(int v[], int i, int j)
{
    int temp;

    temp = v[i];
    v[i] = v[j];
    v[j] = temp;
}

标准库中包含了一个快速排序,它可以拍任何类型的对象。

递归会消耗更多的存储空间,因为它层层调用过程中栈空间必须得维护起。它也不会被执行得更快。但递归代码显得更加紧凑,而且比非递归程序更容易编写和理解。递归对于具有像树这样具有递归属性的数据结构特别适用;我们将在6.5节展示一个例子。

练习 4-12。调整printd,编写一个递归版本的itoa;也就是说,将整数转换为字符串时用递归的思想实现。
练习 4-13。编写递归版本的reverse(s)函数,该函数将字符串s逆序。

11 C预处理

C通过预处理器提供了特定的语言工具,从概念上讲,它是C语言编译的第一个过程。有两个被使用频率最高的命令,一是#include,在编译过程中该命令将其后文件的内容包含到本文件中来;另一个是#define,用任意序列的字符串替换标志。在本节中还会讨论的有条件编译以及带参数的宏。

4.11.1 文件包含

文件包含使得更易处理#define和声明集。任何以

#include "filename"

#include <filename>

的行都会被filename中的内容替换。如果filename被双引号引起,它会先到包含该文件的源文件的目录下寻找filename文件;如果在该目录下没有找到,或filename被小书名号包含,那么他就到编译器指定的目录下寻找filename。filename中也可以使用#include语句包含其它的文件。

在一个源文件的开头处一般都会包含几个#include行,包含几个#define行以及几个extern声明。(严格来说,这些不需是文件;头文件是怎么被访问的细节是基于具体实现的)

对于大型程序来说,#include是联系声明的更好的方式。它保证包含它的所有源文件都被提供相同的定义和变量声明,这可能会解决某种程度上的bug问题。自然地,当包含的文件改变时,所有基于该头文件的源文件都必须被重新编译。

4.11.2 宏替换

10.09
宏定义的格式为

#define name replacement text

这是宏替换最简单的类型 —— 标记name出现的地方会被replacement text代替。#define中的name跟变量所被允许的格式一致;replacement text可以是任意的。通常replacement text只有一行,但一个比较长的定义可以定义几行并用\连接。由#define定义的name从定义处到整个源文件结束都拥有生命。定义可以使用之前的定义。替换只针对标记,并不会发生在字符串内和name同名的序列。例如,假设YES是一个定义,那么它不会对printf(“YES”)和YESMAM中的YES进行替换。

name可以被定义成任何replacement text。如

#define forever for(;;) /* 无限循环 */

定义了一个新名称即forever用来表示无限循环。

也可以定义带参数的宏,如此根据参数不同就可以得到不同的替换文本。例如,定义名为max的宏如下:

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

尽管这看起来似一个函数调用,但max会展开到源文件中。每个正常的参数出现时他就会被相应的参数替换掉,像行

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

将会被行

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

替换。

只要参数能被一致,该宏就能适用于任何类型;没有必要为不同的数据类型定义不同版本的max,这是函数干的事情。

若检查max的展示式,您将会注意到些错误。该表达式将会被求值两次;假设使用像自增操作符这样具有副作用的作为输入或输出就将会产生预期不到的效果。例如

max(i++, j++) /* 错误 */

会将较大的一个数增加两次。同时也需要用括号来让求值的顺序按照期望进行;考虑宏

#define square(x) x * x /* 错误 */

被这样调用

square(z + 1)

然而,宏依旧是有价值的。一个来自stdio.h的实际例子,getchar和putchar两个函数就是宏定义(为了避免函数调用的开销)。ctype.h中的函数也常用宏来实现。

通过使用#undef可以让一个名字不再有定义,#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。

嵌套使用##时,含义比较晦涩;更详细的细节在附录A中被包含。

练习 4-14。定义宏swap(t, x, y)交换类型为t的x和y的值。(可以使用块结构)

4.11.3 条件包含

用在预处理过程中被处理的条件语句可以控制预处理过程。基于在编译时对条件的求值,就提供了一种选择包含具体代码的方式。

语句#if行判断一个常量表达式(不可以包含sizeof、类型转换casts以及枚举常量)的值。如果表达式非0,后续直至#endif或#else的代码将会被包含。(预处理语句#elseif就跟else if一样)。若name已经被定义,那么在#if中的defined(name)语句的值就为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

语句#ifdef和#ifndef是用来测试一个名字被定义与否的特殊方式。第一个例中的#if也可以被写出如下形式:

#ifndef HDR
#define HDR

/* hdr.h 的内容定义在这里 */

#endif

[2016.10.01 - 09:15]

猜你喜欢

转载自blog.csdn.net/misskissc/article/details/52718458
今日推荐