Post

《C程序设计语言》笔记 第4章 函数与程序结构

函数可以把大的计算任务分解成若干个较小的任务,使得程序设计人员可以基于他人已完成的工作编写程序,而不是从零开始。一个设计得当的函数可以把程序中不需要了解的具体操作细节隐藏起来,从而使整个程序结构更加清晰,并降低修改的难度。

C语言程序一般由许多小的函数组成,而不是由少量较大的函数组成。

4.1 函数的基本知识

首先来设计并编写一个程序,它将输入中包含特定“模式”或字符串的行打印出来(这是UNIX程序grep的特例)。该任务可以明确地划分成三部分:

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

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

“还有未处理的行”是getline,该函数已在第1章中写过;“打印该行”是printf,这个函数是现成的。因此只需要编写一个判断“该行包含指定的模式”的函数。函数strindex(s, t)实现该目标,该函数返回字符串t在字符串s中出现的位置(索引),当s不包含t时返回-1。由于C语言数组的下标从0开始,索引只能是0或正数,因此可以用像-1这样的负数表示失败的情况。

打印所有与模式匹配的行

目前的待查找模式是字符串字面值,这不是最通用的机制。第5章将介绍如何在程序运行时将模式作为参数传递给程序。

注:书中代码将getlinestrindexmain函数都写在一起了,这样方便编译,但不利于在其他程序中复用函数(下一节的“简单计算器”程序也用到了getline函数)。更好的做法是:将三个函数的代码分别写在getline.c、strindex.c和grep.c三个源文件中,通过命令

1
gcc -o grep.out -ansi grep.c getline.c strindex.c

编译得到可执行文件。该命令直接将源文件编译成可执行文件,另一种方法是先将每个源文件单独编译成目标(object)文件,最后再链接起来:

1
2
3
4
gcc -c -o grep.o -ansi grep.c
gcc -c -o getline.o -ansi getline.c
gcc -c -o strindex.o -ansi strindex.c
gcc -o grep.out grep.o getline.o strindex.o

这样做的好处是,如果getlinestrindex函数的代码没有修改,则在其他程序中使用该函数时不需要重新编译,直接使用对应的.o文件即可。

其中,getlinestrindex函数的声明可以直接写在grep.c中,也可以分别写在getline.h和strindex.h两个头文件中,并在grep.c中通过#include包含进来(#include指令的作用就是把头文件内容插入到当前位置)。这样做的好处是,在其他程序中使用这两个函数时,不需要重复声明,只需包含对应的头文件即可。

函数的定义形式如下:

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

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

1
dummy() {}

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

程序就是变量和函数定义的集合。函数之间的通信可以通过参数、返回值以及外部变量进行。

被调函数通过return语句向调用者返回值:

1
return 表达式;

在必要时,表达式将被转换为函数的返回值类型。

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

注:这里最后一句话中的“值”指的是函数调用表达式的值。例如:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int f(int x) {
    if (x > 0)
        return x + 1;
}

int main() {
    printf("%d\n", f(-5));
    return 0;
}

函数f在x <= 0时没有返回值,编译能通过,但编译器可能会给出警告。表达式f(-5)的值是垃圾值,具体值取决于编译器。例如,在Visual Studio中其值为1,而在gcc编译器上其值是随机值:

1
2
3
4
5
6
7
$ gcc -o test.out -ansi test.c 
$ ./test.out 
1860462177
$ ./test.out 
-1581459871
$ ./test.out 
515405409

因此要确保函数在每个条件分支都返回了值。

练习4-1 编写函数strrindex(s, t),它返回字符串t在s中最右边出现的位置,如果s中不包含t则返回-1。

4.2 返回非整型值的函数

到目前为止,我们所讨论的函数都是不返回任何值(void)或返回int值的。下面通过函数atof(s)来说明函数返回非整型值的方法,该函数将字符串s转换为相应的双精度浮点数。atof函数需要处理可选的符号和小数点,并要考虑可能缺少整数部分或小数部分的情况(例如:123.45-2.5+3.2)。标准库<stdlib.h>包含类似功能的atof函数。

atof函数

调用函数必须知道atof函数返回的是非整型值。一种方法是在调用函数中显式声明atof函数,如下面的基本计算器程序所示。该程序在每行中读取一个数(前面可能有正负号),并对它们求和,在每次输入后把当前累计总和打印出来:

简单计算器

注:该程序调用了atof函数和之前的getline函数。书中代码将函数声明放在main函数中,也可以通过#include包含头文件来声明。

函数的声明与定义必须一致。如果函数的声明与定义放在同一源文件中,并且不一致,编译器就会检测到该错误。但是,如果函数是单独编译的,这种不匹配的错误就无法检测出来。

发生不匹配现象的一个原因是,如果没有函数原型,则函数将在第一次出现的表达式中被隐式声明,例如sum += atof(line)。如果一个没有声明过的名字出现在某个表达式中,并且其后紧跟一个左圆括号,那么上下文就会认为该名字是一个函数名,该函数的返回值将被假定为int,但上下文并不对其参数作任何假设。另外,如果函数声明中不包含参数,那么编译器也不会该函数的参数作任何假设,并关闭所有的参数检查。这种特殊处理是为了兼容比较老的C语言程序。在新编写的程序中,如果函数带有参数,则要声明它们;如果没有参数,则使用void声明(例如int f(void);)。

练习4-2atof函数进行扩充,使它可以处理形如123.45e-6的科学表示法,其中,浮点数后面可能会紧跟一个e或E以及一个指数(可能有正负号)。

4.3 外部变量

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

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

如果函数之间需要共享大量的变量,使用外部变量要比使用一个很长的参数表更方便、有效。但是,在第1章中已经指出,这样做必须非常谨慎,因为这种方式可能对程序结构产生不良的影响,而且可能会导致程序中各个函数之间有太多的数据联系。

外部变量的用途还表现在它们比内部变量具有更大的作用域和更长的生存期。自动变量只能在函数内部使用,从其所在的函数被调用时开始存在,在函数退出时消失。而外部变量是永久存在的,它们的值在一次函数调用到下一次函数调用之间保持不变。因此,如果两个函数必须共享某些数据,而这两个函数互不调用对方,最方便的方式是把这些数据定义为外部变量,而不是作为函数参数传递。

下面通过一个更复杂的例子来说明这一点。目标是编写一个具有加(+)、减(-)、乘(*)、除(/)四则运算功能的计算器程序。为了更容易实现,使用逆波兰表示法代替中缀表示法。在逆波兰表示法中,运算符跟在操作数的后面。例如,中缀表达式(1 - 2) * (4 + 5)使用逆波兰表示法表示为1 2 - 4 5 + *。逆波兰表示法不需要圆括号,因为只要知道每个运算符需要几个操作数就不会引起歧义。

计算器程序的实现很简单。依次每个操作数压入栈中;当一个运算符到达时,从栈中弹出相应数目的操作数,把该运算符作用于弹出的操作数,并把运算结果再压入到栈中。

注:“栈”(stack)是一种后进先出(last in first out, LIFO)的数据结构,支持“压入”(push)和“弹出”(pop)两种操作。栈可以理解为一摞箱子,只能将箱子放到顶端、从顶端拿起。可以使用数组+栈指针实现。所谓“栈指针”实际上是一个整型变量,用于保存栈顶位置的数组下标。

例如,对于上面的逆波兰表达式,首先把1和2压入栈中,再用两者之差-1取代它们。然后,将4和5压入栈中,再用两者之和9取代它们。最后,使用-1和9的积-9取代它们。计算过程如下图所示。到达输入行的末尾时,把栈顶的值弹出并打印。

逆波兰计算器计算过程

注:从图中可以看出,栈指针始终指向下一个空闲位置(即栈顶)。“压入”操作即s[p++] = x;“弹出”操作仅仅是移动栈指针,即--p,其中s是栈数组,p是栈指针。被“弹出”的元素仍然在数组中,但根据栈指针的性质,栈指针之前的位置才是栈中的元素。

该程序的结构是一个循环:

1
2
3
4
5
6
7
8
9
10
11
while (下一个运算符或操作数不是EOF)
    if (数字)
        压入栈中
    else if (运算符)
        弹出操作数
        执行运算
        将结果压入栈中
    else if (换行符)
        弹出并打印栈顶的值
    else
        出错

栈的压入和弹出操作比较简单。但是,如果把错误检测与恢复都加进来,代码就显得很长了,因此最好把它们设计成独立的函数pushpop。另外还需要一个单独的函数getop来取下一个输入运算符或操作数。

到目前为止,设计中的一个重要问题还没有讨论:把栈放在哪?即哪些函数可以直接访问它?一种可能是放在main函数中,把栈即及其当前位置(栈指针)作为参数传递给对它执行压入或弹出操作的函数。但是,main函数不需要了解控制栈的变量,它只进行压入与弹出操作。因此把栈及相关信息放在外部变量中,只供pushpop函数访问,而不能被main函数访问。

把上面这段话转换成代码很容易。如果把该程序放在一个源文件中,程序类似于下列形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include ...
#define ...

main使用的函数声明
main() { ... }

pushpop使用的外部变量
void push(double f) { ... }
double pop(void) { ... }

int getop(char s[]) { ... }

getop调用的函数

在4.5节将讨论如何把该程序分割为多个源文件。

main函数是一个循环,其中包含一个很大的switch,该switch判断运算符或操作数的类型。

main函数

注意:因为+与*两个运算符满足交换律,因此操作数的弹出次序无关紧要。但是,-与/两个运算符的左右操作数必须加以区分。在push(pop() - pop());中并没有定义两个pop调用的求值次序。为了保证正确的次序,必须像main函数中一样把第一个值弹出到一个临时变量中。

push和pop函数

栈和栈指针必须被pushpop函数共享,因此定义在这两个函数的外部。但是,main函数本身并没有引用栈或栈指针,因此对main函数而言要将它们隐藏起来(意思是并没有在main函数可以访问的地方声明这两个变量)。

getop函数获取下一个运算符或操作数,返回运算符类型(如果是操作数则用宏NUMBER表示)。首先跳过空格与制表符。如果下一个字符不是数字或小数点(即是运算符),则返回该字符;否则,把数字字符串收集起来(其中可能包含小数点),并返回NUMBER,以标识输入的是一个数字。

getop函数

getchungetch是什么呢?程序中经常会出现这样的情况:程序不能确定它已经读入的输入是否足够,除非多读入一些输入。读入一些字符以合成一个数字的情况便是一例:在看到第一个非数字字符之前,已经读入的数的完整性是不能确定的。由于程序多读入了一个字符,这样就导致最后有一个字符不属于当前所要读入的数。

如果能“反读”(un-read)不需要的字符,该问题就可以得到解决。每当程序多读入一个字符时,就把它“放回”到输入中,对代码其余部分而言就好像没有读入该字符一样。可以编写一对相互协作的函数来比较方便地模拟反取字符操作。getch函数用于读入下一个待处理的字符,ungetch函数则用于把字符放回到输入中。 这样,此后在调用getch函数时,在读入新的输入之前先返回ungetch函数放回的那个字符。

这两个函数之间的协同工作也很简单。ungetch函数把要放回的字符放到一个共享缓冲区(字符数组)中。当该缓冲区不为空时,getch函数就从缓冲区中读取字符;当缓冲区为空时,getch函数就调用getchar函数直接从输入中读字符。这里还需要增加一个下标变量来记录缓冲区中当前字符的位置。

由于缓冲区与下标变量是getchungetch函数共享的,且在两次调用之间必须保持值不变,因此它们必须是这两个函数的外部变量。(标准库<stdio.h>提供了函数ungetc,它将一个字符压回到输入流中)

getch和ungetch函数

注:

  • bufbufp的用法可以看出,buf本质上也是一个栈,bufp是栈顶指针
  • 在这个例子中,读取数字时只需要多读至多一个字符即可确定是否读取完成,因此缓冲区(栈)buf中至多只会有一个字符

程序运行示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
123 456 +
        579
123 456 -
        -333
123 456 *
        56088
123 456 /
        0.26973684
1 2 - 4 5 + *
        -9
1 0 /
error: zero divisor
        1
1 2 &
error: unknown command &
        2
abc
error: unknown command a
error: unknown command b
error: unknown command c
        1

练习4-3~4-6

练习4-3 在有了基本框架后,对计算器程序进行扩充就比较简单了。增加取模(%)运算符,并提供对负数的支持。

改动点:getop函数考虑负数的情况,main函数增加1个case

练习4-4 添加几个栈操作命令,分别用于在不弹出栈顶元素的情况下打印栈顶元素,复制栈顶元素,交换栈顶两个元素以及清空栈。

改动点:calc.h和stack.c增加栈操作,main函数增加4个case

运行示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1 ? 2 ? - ? 4 ? 5 ? + ? *
        1
        2
        -1
        4
        5
        9
        -9
5 # *
        25
1 2 ~ - 4 5 ~ / *
        1.25
1 2 ! +
error: stack empty
error: stack empty
        0

练习4-5 增加访问sinexppow等库函数的操作(参见<math.h>)。

改动点:getop函数考虑标识符(函数名),增加一个源文件function.c处理函数调用,main函数增加1个case

运行示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
1.57 sin
        0.99999968
3.14 cos
        -0.99999873
1.57 tan
        1255.7656
1 exp
        2.7182818
2 log 10 log /
        0.30103
-2 exp log
        -2
2 sqrt
        1.4142136
-2 fabs
        2
2 3 pow
        8
3 0.5 pow
        1.7320508

练习4-6 增加处理变量的命令(提供26个具有单个字母名字的变量很容易)。增加一个变量存放最近打印的值。

改动点:增加一个源文件variable.c定义变量数组和处理变量的函数,deal_with_identifier函数当标识符不是函数名时按变量处理,main函数增加2个case

运行示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
a 4 =
        4
a 5 *    
        20
a 3 = b 4 = +
        7
a 2 pow b 2 pow + sqrt          
        5
_ 1 +
        6
A 1 +
error: invalid identifier A
error: stack empty
        1

练习4-7 编写一个函数ungets(s),将整个字符串s压回到输入中。ungets函数应该使用bufbufp,还是仅使用ungetch函数?

仅使用ungetch函数即可

练习4-8 假定最多只压回一个字符,请相应地修改getchungetch这两个函数。

练习4-9 以上介绍的getchungetch函数不能正确地处理压回的EOF。考虑压回EOF时应该如何处理,请实现你的设计方案。

练习4-10 另一种方法是通过getline函数读入整个输入行,这种情况下可以不使用getchungetch函数。请运用这一方法修改计算器程序。

4.4 作用域规则

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

  • 如何进行声明才能确保变量在编译时被正确声明?
  • 如何安排声明的位置才能确保程序在加载时各部分能正确连接?
  • 如何组织程序中的声明才能确保只有一份副本?
  • 如何初始化外部变量?

名字的作用域(scope)是程序中可以使用该名字的部分。在函数中声明的局部变量的作用域是其所在函数。 不同函数中声明的具有相同名字的局部变量之间没有任何关系,函数的参数也是这样(等效于局部变量)。

外部变量或函数的作用域从声明它的地方开始,到其所在的文件末尾结束。 如果要在外部变量的定义之前使用该变量,或者外部变量的定义与使用不在同一个源文件中,则必须使用关键字extern声明该变量。

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

1
2
int sp;
double val[MAXVAL];

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

1
2
extern int sp;
extern double val[];

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

在一个程序的所有源文件中,一个外部变量只能在某个文件中定义一次,但其他文件可以通过extern多次声明。外部数组变量的定义中必须指定数组长度,但extern声明中不一定要指定。

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

例如,假设pushpop函数定义在一个文件中,而变量valsp在另一个文件中定义并初始化(通常不大可能这样组织程序),则需要通过下面这些定义与声明将它们“绑定”在一起:

file1:

1
2
3
4
5
6
extern int sp;
extern double val[];

void push(double f) { ... }

double pop(void) { ... }

file2:

1
2
int sp = 0;
double val[MAXVAL];

4.5 头文件

下面考虑将计算器程序分隔为若干个源文件。如果该程序的各组成部分很长,这么做还是有必要的。可以这样分隔:将main函数单独放在main.c中,将pushpop函数以及它们使用的外部变量放在stack.c中,将getop函数放在getop.c中,将getchungetch函数放在getch.c中,将公共部分放在头文件calc.h中。分隔后程序的形式如下:

分隔后的计算器程序

可以将所有源文件一起编译:

1
gcc -o reverse_polish_calculator.out main.c stack.c getop.c getch.c

也可以单独编译:

1
2
3
4
5
gcc -c -o main.o main.c
gcc -c -o stack.o stack.c
gcc -c -o getop.o getop.c
gcc -c -o getch.o getch.c
gcc -o reverse_polish_calculator.out main.o stack.o getop.o getch.o

单独编译的好处时未修改的源文件不需要重新编译。可以将上述编译规则编写为Makefile

这种分隔方式对下面两个因素进行了折中:一方面是期望每个文件只能访问它完成任务所需的信息,另一方面是维护多个头文件比较困难。

对于中等规模的程序,最好只用一个头文件存放程序中各部分共享的对象。较大的程序需要使用更多的头文件,需要精心地组织它们。

4.6 静态变量

文件stack.c中的变量spval以及文件getch.c中的变量bufbufp仅供其所在的源文件中的函数使用,而不希望被其他函数访问(而外部变量的特性使得所有函数都可以访问)。用static声明限定外部变量或函数,可以将其作用域限定为所在源文件的剩余部分(从声明的位置到文件末尾)。通过static限定外部对象,可以达到隐藏外部对象的目的。

要指定静态存储,在普通声明前加上static关键字即可。例如:

1
2
3
4
5
6
static char buf[BUFSIZE];
static int bufp = 0;

int getch(void) { ... }

void ungetch(int c) { ... }

则其他源文件中的函数不能访问bufbufp,因此这两个名字不会和同一个程序的其他源文件中相同的名字冲突。同样,可以通过将pushpop函数使用的栈操作变量spval声明为static而将其隐藏起来。

注:在这个例子中,由于使用static修饰,即使其他源文件通过extern声明了bufbufp,并与当前源文件一起编译,链接器仍然会报错“找不到符号”。

static声明也可用于局部变量。静态局部变量与普通自动变量的区别是,不管其所在函数是否被调用,静态局部变量一直存在,而不像自动变量那样随着所在函数的被调用和退出而存在和消失。这意味着静态局部变量是一种只能在某个函数内部使用但一直占据存储空间的变量。

练习4-11 修改getop函数,使其不必使用ungetch函数。提示:使用一个静态局部变量。

4.7 寄存器变量

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

register声明只适用于自动变量以及函数的形式参数。例如:

1
2
3
4
5
6
7
register int x;
register char c;

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

4.8 程序块结构

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

1
2
3
4
5
6
if (n > 0) {
    int i;  /* 声明一个新的变量i */

    for (i = 0; i < n; ++i)
        ...
}

变量i的作用域是if语句的“真”分支,这个i与该程序块外声明的i无关。

每次进入程序块时,在程序块内声明和初始化的自动变量都将被初始化一次。静态变量只在第一次进入程序块时被初始化一次。

自动变量(包括形式参数)也可以隐藏同名的外部变量和函数。 例如,给定下面的声明:

1
2
3
4
5
6
7
int x;
int y;

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

在函数f内,x指的是参数,类型为double;而在函数f外,x值的是int类型的外部变量。对于变量y也是如此。

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

4.9 初始化

本节将对前面讨论的各种存储类的初始化规则做一个总结。

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

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

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

对于外部变量和静态变量,初始化表达式必须是常量表达式,且只初始化一次(从概念上讲是在程序开始执行前)。对于自动变量和静态变量,则在每次进入函数或程序块时都将初始化,初始化表达式不限于常量:表达式中可以包含任意已经定义的值,包括函数调用。

实际上,自动变量的初始化就是简写的赋值语句。采用哪种形式取决于个人习惯。考虑到变量声明中的初始化表达式距使用的位置较远、容易被忽略,我们一般使用显式的赋值语句。(注:C98标准规定局部变量必须定义在所有可执行语句之前,但C99及以后则没有这个限制,可以在块的任何位置声明变量)

数组的初始化可以在声明的后面紧跟一个用花括号括起来的、逗号分隔的初始化列表。 例如,使用每个月的天数初始化数组days

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

当省略数组长度时,编译器将使用初始化列表的长度,在该示例中为12。

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

字符数组的初始化比较特殊:可以用一个字符串来代替初始化列表。 例如:

1
char pattern[] = "ould";

等价于

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

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

4.10 递归

C语言中的函数可以递归调用,即函数可以直接或间接调用自身。考虑将数字作为字符串打印,3.6节中讲过,数字是以反序生成的:低位数字先于高位数字生成。

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

printd函数

printd(123)的递归调用过程如下图所示:

递归调用过程

如果整数n有d位,则首先递归地打印前d-1位,之后打印个位数。递归出口是d=1的情况。

函数递归调用自身时,每次调用都会得到一个全新的局部变量集合(包括函数参数)。因此在上面例子的三次调用中,形参n的值分别为123、12和1。这些“环境”的保存和恢复是操作系统通过调用栈实现的。

另一个能较好说明递归的例子是快速排序(quicksort)。对于一个给定的数组,从中选择一个元素,将其余元素划分为两个子集——一个子集中的所有元素都小于该元素,另一个子集中的所有元素都大于等于该元素(因此划分元素一定处于排序后的正确位置)。对这两个子集递归执行这一过程。当某个子集中的元素个数小于2时不需要进行排序,终止递归。

下面版本的快速排序可能不是执行速度最快的,但它是最简单的算法之一。在每次划分子集时,总是选取各子数组的中间元素(注:划分元素的选择是任意的,可以是第一个、最后一个甚至随机选择,只要将其调整至正确的位置、保证与两个子集中元素的大小关系正确即可)。

qsort函数

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

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

递归并不节省存储开销(因为必须在某个地方维护一个处理值的栈),也不会更快。但递归代码比较紧凑,并且比等价的非递归代码更易于编写和理解。对于树等递归定义的数据结构使用递归尤其方便。6.5节将介绍一个比较好的例子。

练习4-12 采用printd函数的思想编写一个递归版本的itoa函数,即通过递归调用把整数转换为字符串。

练习4-13 编写一个递归版本的reverse函数,将字符串s原地反转。

4.11 C预处理器

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

4.11.1 文件包含

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

1
#include "文件名"

1
#include <文件名>

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

源文件的开头通常都会有多个#include指令,用于包含公共的#define语句和extern声明,或从头文件(比如<stdio.h>)访问库函数的函数原型声明。

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

4.11.2 宏替换

宏(macro)定义的形式如下:

1
#define 名字 替换文本

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

宏名称和替换文本都可以是任意的。例如:

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

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

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

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

宏调用看起来很像是函数调用,但宏调用直接将替换文本插入到代码中。因此,语句

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

将被替换为

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

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

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

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

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

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

当用square(z+1)调用时将展开为z+1 * z+1

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

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

1
2
3
#undef getchar

int getchar (void) { ... }

在带引号的字符串中不替换形式参数。例如:

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

则调用dprint(x/y);将打印expr = ...而不是x/y = ...。但是在替换文本中,如果在参数名前加上#,则将被扩展为由实际参数替换该参数的带引号的字符串。例如,可以将它与字符串连接结合起来编写一个调试打印宏:

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

则宏调用

1
dprint(x/y);

将被展开为

1
printf("x/y" " = %g\n", expr);

其中的字符串被连接起来,因此效果等价于

1
printf("x/y = %g\n", expr);

在实际参数中,"将被替换为\"\将被替换为\\,因此结果是合法的字符串常量。

预处理器运算符##提供了一种在宏展开过程中连接实际参数的方法。如果替换文本中的参数与##相邻,则该参数被替换为实际参数后,##和前后的空白符将被删除,并对替换后的结果重新扫描。例如,宏paste用于连接两个参数:

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

因此,int paste(name, 1) = 8;等价于int name1 = 8;

练习4-14 定义宏swap(t, x, y)以交换t类型的两个参数。(使用程序块结构会有所帮助)

4.11.3 条件包含

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

#if语句对其中的常量整型表达式(其中不能包含sizeof、类型转换或enum常量)进行求值。如果表达式的值不为0,则包含其后的各行,直到遇到#endif#elif#else语句为止(预处理器语句#elif类似于else if)。在#if语句中可以使用表达式defined(name),当name已定义时其值为1,否则其值为0。

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

1
2
3
4
5
6
#if !defined(HDR)
#define HDR

/* hdr.h文件的内容放在这里 */

#endif

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

C语言专门定义了两个预处理语句#ifdef#ifndef,用来测试某个名字是否已经定义。#ifdef name等价于#if defined(name)#ifndef name等价于#if !defined(name)

This post is licensed under CC BY 4.0 by the author.