后浪笔记一零二四

C程序设计语言-第1章导言

自从1978年《The C Programming Language》一书出版以来,计算机领域经历了一场革命。大型计算机的功能越来越强大,而个人计算机的性能也可以与十多年前的大型机相媲美。在此期间,C语言也在悄悄地演进,其发展早已超出了它仅仅作为UNIX操作系统的编程语言的初衷。

C语言普及程度的逐渐增加以及该语言本身的发展,加之很多组织开发出了与其设计有所不同的编译器,所有这一切都要求对C语言有一个比本书第一版更准确、更适应其发展的定义。1983年,美国国家标准协会(ANSI)成立了一个委员会,其目标是制定“一个无歧义性的且与具体机器无关的C语言定义”,而同时又要保持C语言原有的“精神”。结果产生了C语言的ANSI标准。

ANSI标准规范了一些在本书第1版中提及但没有具体描述的结构,特别是结构赋值枚举。该标准还提供了一种新的函数声明形式,允许在使用过程中对函数的定义进行交叉检查。标准中还详细说明了一个具有标准输入/输出、内存管理和字符串操作等扩展函数集的标准库。它精准地说明了在C语言原始定义中并不明晰的某些特性的行为,同时还明确了C语言中与具体机器相关的一些特性。

本书第2版介绍的是ANSI标准定义的C语言。尽管我们已经注意到了该语言中已经变化了的地方,但我们还是决定在这里只列出它们的新形式。最重要的原因是,新旧形式之间并没有太大的区别;最明显的变化是函数的声明和定义。目前的编译器已经能够支持该标准的大部分特性。

我们将尽力保持本书第一版的简洁性。C语言并不是一种大型语言,也不需要用一本很厚的书来描述。我们在讲解一些关键特性(比如指针)时做了改进,它是C语言程序设计的核心。我们重新对以前的例子进行了精炼,并在某些章节中增加了一些新例子。例如,我们通过实例程序对复杂的声明进行处理,以将复杂的声明转换为描述性的说明或反之。像前一版中的例子一样,本版中所有例子都以可被机器读取的文本形式直接通过了测试。

附录A只是一个参考手册,而非标准,我们希望通过较少的篇幅概述标准中的要点。该附录的目的是帮助程序员更好地理解语言本身,而不是为编译器的实现者提供一个精确的定义————这正是语言标准所应当扮演的角色。附录B对标准库提供的功能进行了总结,它同样是面向程序员而非编译器实现者的。

我们在第1版中曾说过:“随着使用经验的增加,使用者会越来越感到得心应手”。经过十多年的实践,我们仍然这么认为。我们希望这本书能够帮助读者学好并用好C语言。

非常感谢帮助我们完成本书的朋友们。Jon Bentley、Doug Dwyn、Doug McIlroy、Peter Nelson和Rob Pike几乎对本书手稿的每一页都提出了建议。我们非常感谢Al Aho、Dennis Allison、Joe Campbell、G.R. Emlin、Karen Fortgang、Allen Holub、Andrew Hume、Dave Kristol、John Linderman、Dave Prosser、Gene Spafford和Chris Van Wyk等人,他们仔细阅读了本书。我们也收到了来自Bill Cheswick、Maark Kernighan、Andy Koenig、Robin Lake、Tom Landon、Jim Reeds、Clovis Tondo和Peter Weinberger等人很好的建议。Dave Prosser为我们回答了很多关于ANSI标准的细节问题。我们大量地使用了Bjarne Stroustrup的C++翻译程序进行程序的局部测试。Dave Kristol为我们提供了一个ANSI C编译器以进行最终的测试。Rich Drechsler帮助我们进行了大量的排版工作。

真诚地感谢每个人!

                                             Brian W.Kernighan
                                             Dennis M.Ritchie

引言

C语言是一种通用的程序设计语言。它同UNIX系统之间具有非常密切的联系————C语言是在UNIX系统上开发的,并且,无论是UNIX系统本身还是其上运行的大部分程序,都是用C语言编写的。但是,C语言并不受限于任何一种操作系统或机器。由于它很适合用来编写编译器和操作系统,因此被称为“系统编程语言”,但它同样适用于编写不同领域中的大多数程序。

C语言的很多重要概念来源于由Martin Richards开发的BCPL语言。BCPL对C语言的影响间接地来自于B语言,它是Ken Thompson为第一个UNIX系统而于1970年在DEC PDP-7计算机上开发的。

BCPL和B语言都是“无类型”的语言。相比较而言,C语言提供了很多数据类型。其基本类型包括字符、具有多种长度的整型和浮点数等。另外,还有通过指针、数组、结构和联合派生的各种数据类型。表达式由运算符和操作数组成。任何一个表达式,包括赋值表达式或函数调用表达式,都可以是一个语句。指针提供了与具体机器无关的地址算术运算。

C语言为实现结构良好的程序提供了基本的控制流结构:语句组、条件判断(if-else)、多路选择(switch)、终止测试在顶部的循环(while、for)、终止测试在底部的循环(do)、提前跳出循环(break)等。

函数可以返回基本类型、结构、联合或指针类型的值。任何函数都可以递归调用。局部变量通常是“自动的”,即在每次函数调用时重新创建。一个C语言程序的不同函数可以出现在多个单独编译的不同源文件中。变量可以只在函数内部有效,也可以在函数外部但仅在一个源文件中有效,还可以在整个程序中都有效。

编译的预处理阶段将对程序文本进行宏替换、包含(include)其他源文件以及进行条件编译。

C语言是一种相对“低级”的语言。这种说法并没有什么贬义,它仅仅意味着C语言可以处理大部分计算机能够处理的对象,比如字符、数字和地址。这些对象可以通过具体机器实现的算术运算符和逻辑运算符组合在一起并移动。

C语言不提供直接处理诸如字符串、集合、列表或数组等复杂对象的操作。虽然可以将整个结构作为一个单元进行拷贝,但C语言没有处理整个数组或字符串的操作。除了由函数的局部变量提供的静态定义和堆栈外,C语言没有定义任何存储器分配工具,也不提供堆和无用内存回收工具。最后,C语言本身没有提供输入/输出功能,没有READ或WRITE语句,也没有内置的文件访问方法。所有这些高层的机制必须由显式调用的函数提供。C语言的大部分实现已合理地包含了这些函数的标准集合。

类似地,C语言只提供简单的单线程控制流,即测试、循环、分组(c语言的struct可以将不同数据类型的数据组合起来)和子程序,它不提供多道程序设计、并行操作、同步和协同例程。

尽管缺少其中的某些特性看起来好像是一个严重不足(“这就意味着必须通过调用函数来比较两个字符串嘛?”),但是把语言保持在一个适度的规模会有很多益处。由于C语言相对较小,因此可以用比较小的篇幅将它描述出来,这样也很容易学会。程序员有理由期望了解、理解并真正彻底地使用完整的语言。

很多年来,C语言的定义就是《The C Programming Language》第1版中的参考手册。1983年,美国国家标准协会(ANSI)成立了一个委员会以制定一个现代的、全面的C语言定义。最后的结果就是1988年完成的ANSI标准,即“ANSI C”。该标准的大部分特性已被当前的编译器所支持。

这个标准是基于以前的参考手册制定的。语言本身只做了相对较少的改动。这个标准的目的之一就是确保现有的程序仍然有效,或者当程序无效时,编译器会对新的定义发出警告信息。

对大部分程序员来说,最重要的变化是函数声明和函数定义的新语法。现在,函数声明中可以包含描述函数实际参数的信息;相应地,定义的语法也做了改变。这些附加的信息使编译器很容易检测到因参数不匹配而导致的错误。根据我们的经验,这个扩充对语言非常有用。

新标准还对语言做了一些细微的改进:将广泛使用的结构赋值枚举定义为语言的正式组成部分;可以进行单精度的浮点运算;明确定义了算术运算的属性,特别是无符号(unsigned)类型的运算;对预处理器进行了更详尽的说明。这些改进对大多数程序员的影响比较

该标准的第二个重要贡献是为C语言定义了一个函数库。它描述了诸如访问操作系统(如读写文件)、格式化输入/输出、内存分配和字符串操作等类似的很多函数。该标准还定义了一系列的标准头文件,它们为访问函数声明和数据类型声明提供了统一的方法。这就确保了使用这个函数库和宿主系统进行交互的程序之间具有兼容的行为。该函数库很大程度上与UNIX系统的“标准I/O库”相似。这个函数库已在本书的第1版中进行了描述,很多系统中都使用了它。这一点对大部分程序员来说,不会感觉到有很大的变化。

由于大多数计算机本身就直接支持C语言提供的数据类型和控制结构,因此只需要一个很小的运行时库就可以实现自包含程序。由于程序只能够显式地调用标准库中的函数,因此在不需要的情况下就可以避免对这些函数的调用。除了其中隐藏的一些操作系统细节外,大部分库函数可以用C语言编写,并可以移植。

尽管C语言能够运行在大部分的计算机上,但它同具体的机器结构无关。只要稍加用心就可以编写出可移植的程序,即可以不加修改地运行于多种硬件上。ANSI标准明确地提出了可移植性问题,并预设了一个常量的集合,借以描述运行程序的机器的特性。

C语言不是一种强类型的语言,但随着它的发展,其类型检查机制已经得到了加强。尽管C语言的最初定义不赞成在指针和整型变量之间交换值,但并没有禁止,不过现在已经不允许这种做法了。ANSI标准要求对变量进行正确的声明和显式的强制类型转换,这在某些较完善的编译器中已经得到了实现。新的函数声明方式是另一个得到改进的地方。编译器将对大部分的数据类型错误发出警告,并且不自动执行不兼容数据类型之间的类型转换。不过,C语言保持了其初始的设计思想,即程序员了解他们在做什么,唯一的要求是程序员要明确地表达他们的意图。

同任何其他语言一样,C语言也有很不完美的地方。某些运算符的优先级是不正确的;语法的某些部分可以进一步优化。尽管如此,对于大量的程序设计应用来说,C语言是一种公认的非常高效的、表达能力很强的语言。

本书是按照下列结构编排的:第1章将对C语言的核心部分进行简要介绍。其目的是让读者能尽快开始编写C语言程序,因为我们深信,实际编写程序才是学习一种新语言的好方法。这部分内容的介绍假定读者对程序设计的基本元素有一定的了解。我们在这部分内容中没有解释计算机、编译等概念,也没有解释诸如n=n+1这样的表达式。我们将尽量在合适的地方介绍一些实用的程序设计技术,但是,本书的中心目的并不是介绍数据结构和算法。在篇幅有限的情况下,我们将专注于讲解语言本身。

第2章到第6章将更详细地讨论C语言的各种特性,所采用的方式将比第1章更加形式化一些。其中的重点将放在一些完整的程序例子上,而并不仅仅只是一些孤立的程序段。第2章介绍基本的数据类型、运算符和表达式。第3章介绍控制流,如if-else、switch、while和for等。第4章介绍函数和程序结构————外部变量、作用域规则和多源文件等,同时还会讲述一些预处理器的知识。第5章介绍指针和地址运算。第6章介绍结构和联合。

第7章介绍标准库。标准库提供了一个与操作系统交互的公用接口。这个函数库是由ANSI标准定义的,这就意味着所有支持C语言的机器都会支持它,因此,使用这个库执行输入、输出或其他访问操作系统的操作的程序可以不加修改地运行在不同机器上。

第8章介绍C语言程序和UNIX操作系统之间的接口,我们将把重点放在输入/输出、文件系统和存储分配上。尽管本章中的某些内容是针对UNIX系统所写的,但是使用其他系统的程序员仍然会从中获益,比如深入了解如何实现标准库以及有关可移植性方面的一些建议。

附录A是一个语言参考手册。虽然C语言的语法和语义的官方正式定义是ANSI标准本身,但是,ANSI标准的文档首先是写给编译器的编写者看的,因此,对程序员来说不一定最合适。本书中的参考手册采用了一种不很严格的形式,更简洁地对C语言的定义进行了介绍。附录B是对标准库的一个总结,它同样是为程序员而非编译器实现者准备的。

第1章 导言

在本书的开篇,我们首先概要地介绍C语言,主要是通过实际的程序引入C语言的基本元素,至于其中的具体细节、规则以及一些例外的情况,在此暂时不多做讨论。因此,本章不准备完整、详细地讨论C语言中的一些技术(当然,这里所举的所有例子都是正确的)。我们是希望读者能尽快地编写出有用的程序,为此,本章将重点介绍一些基本概念,比如变量与常量、算术运算、控制流、函数、基本输入/输出等。而对于编写较大型程序所涉及的一些重要特性,比如指针、结构、C语言中十分丰富的运算符集合、部分控制流语言以及标准库等,本章将暂不做讨论。

这种讲解方式也有缺点。应当提请注意的是,在本章的内容中无法找到任一C语言特性的完整说明,并且,由于比较简略,可能会有读者产生一些误解;再者,由于所举的例子并没有用到C语言的所有强大功能,因此,这些例子也许并不简洁、精炼。虽然我们已经尽力将这些问题的影响降到最低,但问题肯定还是存在。另一个不足之处在于,本章所讲的某些内容在后续相关章节还必须再次讲述。我们希望这种重复给读者带来的帮助效果远远超过它的负面影响。

无论是利还是弊,一个经验丰富的程序员应该可以从本章介绍的内容中推知他们自己运行程序设计所需要的一些基本元素。初学者应编写一些类似的小程序作为本章内容的补充练习。无论是经验丰富的程序员还是初学者,都可以把本章作为后续各章详细讲解的内容的框架。

1.1 入门

学习一门新程序设计语言的唯一途径就是使用它编写程序。对于所有语言的初学者来说,编写的第一个程序几乎都是相同的,即:

请打印出下列内容:

hello, world

尽管这个练习很简单,但对于初学语言的人来说,它仍然可能成为一大障碍,因为要实现这个目的,我们首先必须编写程序文本,然后成功地进行编译,并加载、运行,最后输出到某个地方。掌握了这些操作细节以后,其他事情就比较容易了。

在C语言中,我们可以用下列程序打印出“hello, world”:

1
2
3
4
5
#include <stdio.h>

main() {
	printf("hello, world\n");
}

如何运行这个程序取决于所使用的系统。这里举一个特殊的例子。在UNIX操作系统中,首先必须在某个文件中编写这个源程序,并以“.c”作为文件的扩展名,例如hello.c,然后再通过下列命令进行编译:

1
cc hello.c

如果源程序没有什么错误(例如漏掉字符或拼错字符),编译过程将顺利进行,并生成一个可执行文件a.out。然后,我们输入:

1
a.out

即可运行a.out,打印出下列信息:

hello, world

下面对程序本身做些说明。一个C语言程序,无论其大小如何,都是由函数变量组成的。函数中包含一些语句,以指定所要执行的计算操作;变量则用于存储计算过程中使用的值。C语言中的函数类似于Fortran语言中的子程序和函数,与Pascal语言中的过程和函数也很类似。在本例中,函数的名字为main。通常情况下,函数的命名没有限制,但main是一个特殊的函数名————每个程序都从main函数的起点开始执行,这意味着每个程序都必须在某个位置包含一个main函数。

main函数通常会调用其他函数来帮助完成某些工作,被调用的函数可以是程序设计人员自己编写的,也可以来自于函数库。上述程序段中的第一行语句

#include <stdio.h>

用于告诉编译器在本程序中包含标准输入/输出库的信息。许多C语言源程序的开始处都包含这一行语句。我们将在第7章和附录B中对标准库进行详细介绍。

函数之间进行数据交换的一种方法是调用函数向被调用函数提供一个值(称为参数)列表。函数名后面的一对圆括号将参数列表括起来。在本例中,main函数不需要任何参数,因此用空参数表()表示。

函数中的语句用一对花括号{}括起来。本例中的main函数仅包含下面一条语句:

printf("hello , world\n");

调用函数时,只需要使用函数名加上用圆括号括起来的参数表即可。上面这条语句将"hello, world\n"作为参数调用printf函数。printf是一个用于打印输出的库函数,在此处,它打印双引号中间的字符串。

用双引号括起来的字符串序列称为字符串字符串常量,如"hello world\n"就是一个字符串。目前我们仅使用字符串作为printf以及其他函数的参数。

在C语言中,字符序列\n表示换行符,在打印中遇到它时,输出打印将换行,从下一行的左端行首开始。如果去掉字符串中的\n(这是个值得一做的练习),即使输出打印完成后也不会换行。在printf函数的参数中,只能用\n表示换行符。如果用程序的换行代替 \n,例如:

1
2
printf("hello, world
");

C编译器将会产生一条错误信息。

printf函数永远不会自动换行,这样我们可以多次调用该函数以分阶段得到一个长的输出行。上面给出的第一个程序也可以改写成下列形式:

1
2
3
4
5
6
7
#include <stdio.h>

main() {
	printf("hello, ");
	printf("world");
	printf("\n");
}

这段程序与前面的程序的输出相同。

请注意,\n只代表一个字符。类似于\n转义字符序列为表示无法输入的字符或不可见字符提供了一种通用的可扩充的机制。除此之外,C语言提供的转义字符序列还包括:\t表示制表符;\b表示回退符;\"表示 双引号;\\表示反斜杠符本身。2.3节将给出转义字符表的完整列表。

练习1-1 在你自己的系统中运行“hello, world”程序。再有意去掉程序中的部分内容,看看会得到什么出错信息。

练习1-2 做个实验,当printf函数的参数字符串中包含\c(其中c是上面的转义字符序列中未曾列出的某一个字符)时,观察一下会出现什么情况。

1.2 变量与算术表达式

我们来看下一个程序,使用公式 ℃ = (5/9) (℉ -32)打印下列华氏温度与摄氏温度对照表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
0    -17
20   -6
40   4
60   15
80   26
100  37
120  48
140  60
160  71
180  82
200  93
220  104
240  115
260  126
280  137
300  148

此程序中仍然只包含一个名为main的函数定义。它比前面打印“hello, world”的程序长一些,但并不复杂。这个程序中引入了一些新的概念,包括注释、声明、变量、算术表达式、循环以及格式化输出。该程序如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

/*  当fahr= 0, 20, ..., 300时,分别
    打印华氏温度与摄氏温度对照表  */
main() {
	int fahr, celsius;
	int lower, upper, step;

	lower = 0;    // 温度表的下限
	upper = 300;  // 温度表的上限
	step  = 20;   // 步长

	fahr = lower;
	while (fahr <= upper) {
		celsius = 5 * (fahr-32) / 9;
		printf("%d\t%d\n", fahr, celsius);
		fahr = fahr + step;
	}
}

其中的两行:

1
2
/*  当fahr= 0, 20, ..., 300时,分别
    打印华氏温度与摄氏温度对照表  */

称为注释,此处,它简单地解释了该程序是做什么用的。包含在/**/之间的字符序列将被编译器忽略。注释可以自由地运用在程序中,使得程序更易于理解。程序中允许出现空格、制表符或换行符之处,都可以使用注释。

在C语言中,所有变量都必须先声明后使用。声明通常放在函数起始处,在任何可执行语句之前。声明用于说明变量的属性,它由一个类型名和一个变量表组成,例如:

1
2
int fahr, celsius;
int lower, upper, step;

其中,类型int表示其后所列变量为整数,与之相对应的,float表示所列变量为浮点数(即,可以带有小数部分的数)。int和float类型的取值范围取决于具体的机器。对于int类型,通常为16位,其取值范围在 -32768 ~ +32767 之间, 也有用32位表示的int类型。 float类型通常是32位,它至少有6位有效数字,取值范围一般在10^(-38) ~ 10(+38)之间。

除int与float类型之外,C语言还提供了其他一些基本数据类型,例如:

1
2
3
4
char           字符————一个字节
short          短整型
long           长整型
double         双精度浮点型

这些数据类型对象的大小也取决于具体的机器。另外,还存在这些基本数据类型的数组结构联合, 指向这些类型的指针以及返回这些类型值的函数。我们将在后续相应的章节中分别介绍。

在上面的温度转换程序中,最开始执行的计算是下列4个赋值语句

1
2
3
4
lower = 0;    // 温度表的下限
upper = 300;  // 温度表的上限
step  = 20;   // 步长
fahr  = lower;

它们为变量设置初值。各条语句均以分号结束。

温度转换表中的各行计算方式相同,因此可以用循环语句重复输出各行。这是while循环语句的用途:

1
2
3
while (fahr <= upper) {
	...
}

while循环语句的执行方式是这样的:首先测试圆括号中的条件;如果条件为真 (fahr <= upper),则执行循环体(括在花括号中的3条语句);然后再重新测试圆括号中的条件,如果为真,则再次执行循环体;当圆括号中的条件测试结果为假(fahr>upper)时,循环结束,并继续执行跟在while循环语句之后的下一条语句。在本程序中,循环语句后没有其他语句,因此整个程序的执行终止。

while语句的循环体可以是用花括号括起来的一条或多条语句(如上面的温度转换程序),也可以是不用花括号的单条语句,例如:

1
2
while (i < j)
	i = 2 * i;

在这两种情况下,我们总是把由while控制的语句缩进一个制表位,这样就可以很容易地看出循环语句中包含哪些语句。这种缩进方式突出了程序的逻辑结构。尽管C编译器并不关心程序的外观形式,但正确的缩进以及保留适当空格的程序设计风格对程序的易读性非常重要。我们建议每行只书写一条语句,并在运算符两边各加上一个空格字符,这样可以使得运算的结合关系更清楚明了。相比而言,花括号的位置就不那么重要了。我们从比较流行的一些风格中选择了一种。读者可以选择适合自己的一种风格,并养成一直使用这种风格的好习惯。

在该程序中,绝大部分工作都是在循环体中完成的。循环体中的赋值语句

1
celsius = 5 * (fahr-32) / 9;

用于计算与指定华氏温度相对应的摄氏温度值,并将结果赋值给变量celsius。在该语句中,之所以把表达式写成先乘5然后再除以9而不是直接写成5/9,其原因是在C语言及许多其他语言中,整数除法操作将执行舍位,结果中的任何小数部分都会被舍弃。由于5和9都是整数,5/9相除后经截取所得的结果为0,因此这样求得的所有摄氏温度都为0。

从该例子中也可以看出printf函数的一些功能。printf是一个通用输出格式化函数,第7章将对此做详细介绍。该函数的第一个参数是待打印的字符串,其中的每个百分号(%)表示其他的参数(第二个、第三个、……参数)之一进行替换的位置,并指定打印格式。例如,%d指定一个整型参数,因此语句

printf("%d\t%d\n", fahr, celsius);

用于打印两个整数fahr与celsius的值,并在两者之间留一个制表符的空间(\t)。

printf函数的第一个参数中的各个%分别对应于第二个、第三个、……参数,它们在数目和类型上都必须匹配,否则将出现错误的结果。

顺便指出,printf函数并不是C语言本身的一部分,C语言本身并没有定义输入/输出功能。printf仅仅是标准库函数中一个有用的函数而已,这些标准库函数在C语言程序中通常都可以使用。但是,ANSI标准定义了printf函数的行为,因此,对每个符合该标准的编译器和库来说,该函数的属性都是相同的。

为了将重点放到讲述C语言本身上,我们在第7章之前的各章中将不再对输入/输出做更多的介绍,并且,特别将格式化输入推后到第7章讲解。如果读者想了解数据输入,可以先阅读7.4节中对scanf函数的讨论部分。scanf函数类似于printf函数,但它用于读输入数据而不是写输出数据。

上述的温度转换程序存在两个问题。比较简单的问题是,由于输出的数不是右对齐的,所以输出的结果不是很美观。这个问题比较容易解决:如果在printf语句的第一个参数的%d中指明打印宽度,则打印的数字会在打印区域内右对齐。例如,可以用语句

printf("%3d %6d\n", fahr, celsius);

打印fahr与celsius的值,这样,fahr的值占3个数字宽,celsius的值占6个数字宽,输出的结果如下所示:

1
2
3
4
5
6
  0    -17
 20     -6
 40      4
 60     15
 80     26
100     37

另一个较为严重的问题是,以后与我们使用的是整型算术运算,因此经计算得到的摄氏温度值不太精确,例如,与0℉ 对应的精确的摄氏温度应该为 -17.8℃ ,而不是-17℃ 。为了得到更精确的结果,应该用浮点算术运算代替上面的整型算术运算。这就需要对程序做适当修改。下面是该程序的又一种版本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
/*  当fahr=0.20, ..., 300时,打印华氏温度与摄氏温度对照表;
    浮点数版本 */
main() {
	float fahr, celsius;
	int lower, upper, step;

	lower = 0;    // 温度表的下限
	upper = 300;  // 温度表的上限
	step  = 20;   // 步长

	fahr = lower;
	while (fahr <= upper) {
		celsius = (5.0/9.0) * (fahr-32.0);
		printf("%3.0f %6.1f\n",  fahr, celsius);
		fahr = fahr + step;
	}
}

这个程序与前一个程序基本相同,不同的是,它把fahr与celsius声明为float类型,转换公式的表述方式也更自然一些。在前一个程序中,之所以不能使用5/9的形式,是因为按整型除法的计算规则,它们相除并舍位后得到的结果为0。但是,常数中的小数点表明该常数是一个浮点数,因此,5.0/9.0是两个浮点数相除,结果将不被舍位。

如果某个算术运算符的所有操作数均为整型,则执行整型运算。但是,如果某个算术运算符有一个浮点型操作数和一个整型操作数,则在开始运算之前整型操作数将会被转换为浮点型。例如,在表达式fahr-32中,32在运算过程中将被自然转换为浮点数再参与运算。不过,即使浮点常量取的是整型值,在书写时最后还是为它加上一个显式的小数点,这样可以强调其浮点性质,便于阅读。

第2章将详细介绍把整型数转换为浮点型数的规则。在这里需要注意,赋值语句

fahr = lower;

与条件测试语句

while (fahr <= upper)

也都是按照这种方式执行的,即在运算之前先把int类型的操作数转换为float类型的操作数。

printf中的转换说明%3.0f表明待打印的浮点数(即fahr)至少占3个字符宽,且不带小数点和小数部分;%6.1f表明另一个待打印的数(celsius)至少占6个字符宽,且小数点后面有1位数字。其输出如下所示:

    0    -17.8
   20     -6.7
   40      4.4
...

格式说明可以省略宽度与精度,例如,%6f表示待打印的浮点数至少有6个字符宽;%0.2f指定待打印的浮点数的小数点后有两位小数,但宽度没有限制;%f则仅仅要求按照浮点数打印该数。

%d    按照十进制整型数打印
%6d   按照十进制整型数打印,至少6个字符宽
%f    按照浮点数打印
%6f   按照浮点数打印,至少6个字符宽
%.2f  按照浮点数打印,小数点后有两位小数
%6.2f 按照浮点数打印,至少6个字符宽,小数点后有两位小数

此外,printf函数还支持下列格式说明:%o表示八进制数;%x表示十六进制数;%c表示字符;%s表示字符串;%%表示百分号(%)本身。

练习1-3 修改温度转换程序,使之能在转换表的顶部打印一个标题。

练习1-4 编写一个程序打印摄氏温度转换为相应华氏温度的转换表。

1.3 for语句

对于某个特定任务我们可以采用多种方式来编写程序。下面这段代码也可以实现前面的温度转换程序的功能:

1
2
3
4
5
6
7
8
#include <stdio.h>
/*  打印华氏温度--摄氏温度对照表 */
main() {
	int fahr;

	for (fahr = 0; fahr <= 300; fahr = fahr + 20)
		printf("%3d %6.1f\n", fahr, (5.0/9.0)*(fahr-32));
}

这个程序与上节中介绍的程序执行结果相同,但程序本身却有所不同。最主要的改进在于它去掉了大部分变量,而只使用了int类型的变量fahr。在新引入的for语句中,温度的下限、上限和步长都是常量,而计算摄氏温度的表达式现在变成了printf函数的第三个参数,它不再是一个单独的赋值语句。

以上几点改进中的最后一点是C语言中一个通用规则的实例: 在允许使用某种类型变量值的任何场合,都可以使用该类型的更复杂的表达式。因为printf函数的第三个参数必须是与%6.1f匹配的浮点值,所以可以在此处使用任何浮点表达式。

for语句是一种循环语句,它是对while语句的推广。如果将for语句与前面介绍的while语句比较,就会发现for语句的操作更直观一些。圆括号中共包含3个部分,各部分之间用分号隔开。第一部分

fahr = 0

是初始化部分,仅在进入循环前执行一次。第二部分

fahr <= 300

是控制循环的测试或条件部分。循环控制将对该条件求值,如果结果值为真(true),则执行循环体(本例中的循环体仅包含一个printf函数调用语句)。此后将执行第三部分

fahr = fahr + 20

以将循环变量fahr增长一个步长,并再次对条件求值。如果计算得到的条件值为假(false),循环将终止执行。与while语句一样,for循环语句的循环体可以只有一条语句,也可以是用花括号括起来的一组语句。初始化部分(第一部分)、条件部分(第二部分)与增加步长部分(第三部分)都可以是任何表达式。

在实际编程过程中,可以选择while与for中的任意一种循环语句,主要要看使用哪一种更清晰。for语句比较适合初始化和增加步长都是单条语句并且逻辑相关的情形,因为它将循环控制语句集中放在一起,且比while语句更紧凑。

练习1-5 修改温度转换程序,要求以逆序(即按照从300度到0度的顺序)打印温度转换表。

1.4 符号常量

在结束讨论温度转换程序前,我们再来看一下符号常量。在程序中使用300、20等类似的“幻数”并不是一个好习惯,它们几乎无法向以后阅读该程序的人提供什么信息,而且使程序的修改变得更加困难。处理这种幻数的一种方法是赋予它们有意义的名字。#define指令可以把符号名(或称为符号常量)定义为一个特定的字符串:

#define 名字 替换文本

在该定义之后,程序中出现的所有在#define中定义的名字(既没有用引号引起来,也不是其他名字的一部分)都将用相应的替换文本替换。其中,名字与普通变量名的形式相同:它们都是以字母打头的字母和数字序列;替换文本可以是任何字符序列,而不仅限于数字。

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

#define   LOWER  0    // 表的下限
#define   UPPER  300  // 表的上限
#define   STEP   20   // 步长

// 打印华氏温度 - 摄氏温度对照表
main() {
	int fahr;

	for (fahr = LOWER; fahr <= UPPER; fahr = fahr + STEP)
		printf("%3d %6.1f\n", fahr, (5.0/9.0)*(fahr-32));
}

其中,LOWER、UPPER与STEP都是符号常量,而非变量,因此不需要出现在声明中。符号常量名通常用大写字母拼写,这样可以很容易与用小写字母拼写的变量名相区别。注意,#define指令行的末尾没有分号。

1.5 字符输入/输出

接下来我们看一组与字符型数据处理有关的程序。读者将会发现,许多程序只不过是这里所讨论的程序原型的扩充版本而已。

标准库提供的输入/输出模型非常简单。无论文本从何处输入,输出到何处,其输入/输出都是按照字符流的方式处理。文本流是由多行字符构成的字符序列,而每行字符则由0个或多个字符组成,行末是一个换行符。标准库负责使每个输入/输出流都能够遵守这一模型。使用标准的C语言程序员不必关心在程序之外这些行是如何表示的。

标准库提供了一次读/写一个字符的函数,其中最简单的是getchar和putchar两个函数。每次调用时,getchar函数从文本流中读入下一个输入字符,并将其作为结果值返回。也就是说,在执行语句

c = getchar()

之后,变量c中将包含输入流中的下一个字符。这种字符通常是通过键盘输入的。关于从文件输入字符的方法,我们将在第7章中讨论。

每次调用putchar函数时将打印一个字符。例如,语句

putchar(c)

将把整型变量c的内容以字符的形式打印出来,通常是显示在屏幕上。putchar与printf这两个函数可以交替调用,输出的次序与调用的次序一致。

1.5.1 文件复制

借助于getchar与putchar函数,可以在不了解其他输入/输出知识的情况下编写出数量惊人的有用的代码。最简单的例子就是把输入一次一个字符地复制到输出,其基本思路如下:

读一个字符
while (该字符不是文件结束指示符)
	输出刚读入的字符
	读下一个字符

将上述基本思路转换为C语言程序为:

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

// 将输入复制到输出:版本1
main() {
	int c;

	c = getchar();
	while (c != EOF) {
		putchar(c);
		c = getchar();
	}
}

其中,关系运算符 != 表示 “不等于”。

字符在键盘、屏幕或其他的任何地方无论以什么形式表现,它在机器内部都是以位模式存储的。char类型专门用于存储这种字符型数据,当然任何整型(int)也可以用于存储字符型数据。因为某些潜在的重要原因,我们在此使用int类型。

这里需要解决如何区分文件中有效数据与输入结束符的问题。C语言采取的解决办法是:在没有输入时,getchar函数将返回一个特殊值,这个特殊值与任何实际字符都不同。这个值称为EOF(end of file,文件结束。类unix系统用键盘打出EOF: ctl-D。windows系统用键盘打出EOF: ctl-Z 然后按Enter键)。我们在声明变量c的时候,必须让它大到足以存放getchar函数返回的任何值。这里之所以不把c声明成char类型,是因为它必须足够大,除了能存储任何可能的字符外还要能存储文件结束符EOF。因此,我们将c声明成int类型。

EOF定义在头文件<stdio.h>中,是一个整型数。其具体数值是什么并不重要,只要它与任何char类型的值都不相同即可。这里使用符号常量,可以确保程序不需要依赖于其对应的任何特定的数值。

对于经验比较丰富的C语言程序员,可以把这个字符复制程序编写得更精炼一些。在C语言中,类似于

c = getchar()

之类的赋值操作是一个表达式,并且具有一个值,即赋值后左边变量保存的值。也就是说,赋值可以作为更大的表达式的一部分出现。如果将为c赋值的操作放在while循环语句的测试部分中,上述字符复制程序便可以写成下列形式:

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

// 将输入复制到输出: 版本2
main() {
	int c;

	while ((c = getchar()) != EOF)
		putchar(c);
}

在该程序中,while循环语句首先读一个字符并将其赋值给c,然后测试该字符是否为文件结束标志。如果该字符不是文件结束标志,则执行while语句体,并打印该字符。随后重复执行while语句。当到达输入的结尾位置时,while循环语句终止执行,从而整个main函数执行结束。

以上这段程序将输入集中化,getchar函数在程序中只出现了一次,这样就缩短了程序,整个程序看起来更紧凑。习惯这种风格后,读者就会发现按照这种方式编写的程序更易阅读。我们经常会看到这种风格。(不过,如果我们过多地使用这种类型的复杂语句,编写的程序可能会很难理解,应尽量避免这种情况。)

对while语句的条件部分来说,赋值表达式两边的圆括号不能省略。不等于运算符!=的优先级比赋值运算符=的优先级要高,这样,在不使用圆括号的情况下关系测试!=将在赋值=操作之前执行。因此语句

c = getchar() != EOF

等价于语句

c = (getchar() != EOF)

该语句执行后,c的值将被置为0或1(取决于调用getchar函数时是否碰到文件结束标志),这并不是我们所希望的结果(更详细的内容,请参见第2章的相关部分)。

练习1-6 验证表达式getchar()!=EOF的值是0还是1。

练习1-7 编写一个打印EOF值的程序。

1.5.2 字符计数

下列程序用于对字符进行计数,它与上面的复制程序类似。

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

// 统计输入的字符数:版本1
main() {
	long nc;

	nc = 0;
	while (getchar() != EOF)
		++nc;
	printf("%ld\n", nc);
}

其中,语句

++nc;

引入了一个新的运算符++, 其功能是执行加1的操作。可以用语句nc=nc+1代替它,但语句++nc更精炼一些,且通常效率也更高。与该运算符相应的是自减运算符--++--这两个运算符既可以作为前缀运算符(如++nc),也可以作为后缀运算符(如nc++)。我们在第2章中将看到,这两种形式在表达式中具有不同的值,但++ncnc++都使nc的值增加1。目前,我们只使用前缀形式。

该字符计数程序使用long类型的变量存放计数值,而没有使用int类型的变量。long整型数(长整型)至少要占用32位存储单元。在某些机器上int与long类型的长度相同,但在一些机器上,int类型的值可能只有16位存储单元的长度(最大值为32767),这样,相当小的输入都可能使int类型的计数变量溢出。转换说明%ld告诉printf函数其对应的参数是long整型。

使用double(双精度浮点数)类型可以处理更大的数字。我们在这里不使用while循环语句,而用for循环语句来展示编写此循环的另一种方法:

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

// 统计输入的字符数:版本2
main() {
	double nc;

	for (nc = 0; getchar() != EOF; ++nc)
		;
	printf("%.0f\n", nc);
}

对于float与double类型,printf函数都使用%f进行说明。%.0f强制不打印小数点和小数部分,因此小数部分的位数为0。

在该程序段中, for循环语句的循环体是空的,这是因为所有工作都在测试(条件)部分与增加步长部分完成了。但C语言的语法规则要求for循环语句必须有一个循环体,因此用单独的分号代替。单独的分号称为空语句,它正好能满足for语句的这一要求。把它单独放在一行是为了更加醒目。

在结束讨论字符计数程序之前,我们考虑以下情况:如果输入中不包含字符,那么,在第一次调用getchar函数的时候,while语句或for语句中的条件测试从一开始就为假,程序的执行结果将为0, 这也是正确的结果。这一点很重要。while语句与for语句的优点之一就是在执行循环体之前就对条件进行测试。如果条件不满足,则不执行循环体,这就可能出现循环体一次都不执行的情况。在出现0长度的输入时,程序的处理应该灵活一些。在出现边界条件时,while语句与for语句有助于确保程序执行合理的操作。

1.5.3 行计数

接下来的这个程序用于统计输入中的行数。我们在上面提到过,标准库保证输入文本流以行序列的形式出现,每一行均以换行符结束。因此,统计行数等价于统计换行符的个数。

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

// 统计输入中的行数
main() {
	int c, nl;

	nl = 0;
	while ((c = getchar()) != EOF)
		if (c == '\n')
			++nl;
	printf("%d\n", nl);
}

在该程序中,while循环语句的循环体是一个if语句,它控制自增语句++nl。if语句先测试圆括号中的条件,如果该条件为真,则执行其后的语句(或括在花括号中的一组语句)。这里再次用缩进方式表明语句之间的控制关系。

双等于号==是C语言中表示“等于”关系的运算符(类似于Pascal中的单等于号=及Fortran中的.EQ.)。由于C语言将单等于号=作为赋值运算符,因此使用双等于号==表示相等的逻辑关系,以示区分。这里提醒注意,在表示“等于”逻辑关系的时候(应该使用==),C语言初学者有时会错误地写成单等于号=。在第2章我们将看到,即使这样误用了,其结果通常仍然是合法的表达式,因此系统不会给出警告信息。

单引号中的字符表示一个整型数,该值等于此字符在机器字符集中对应的数值,我们称之为字符常量。但是,它只不过是小的整型数的另一种写法而已。例如,'A'是一个字符常量:在ASCII字符集中其值为65(即字符A的内部表示值为65)。当然,用'A'要比用65好,因为'A'的意义更清楚,且与特定的字符集无关。

字符串常量中使用的转义字符序列也是合法的字符常量,比如,'\n'代表换行符的值,在ASCII字符集中其值为10。我们应当注意到,'\n'是单个字符,在表达式中它不过是一个整型数而已;而"\n"是一个仅包含一个字符的字符串常量。有关字符串与字符之间的关系,我们将在第2章进一步讨论。

练习1-8 编写一个统计空格、制表符与换行符个数的程序

练习1-9 编写一个将输入复制到输出的程序,并将其中连续的多个空格用一个空格代替。

练习1-10 编写一个将输入复制到输出的程序,并将其中的制表符替换为\t,把回退符替换为\b,把反斜杠替换为\\。这样可以将制表符和回退符以可见的方式显示出来。

1.5.4 单词计数

我们将介绍的第4个实用程序用于统计行数、单词数与字符数。这里对单词的定义比较宽松,它是任何其中不包含空格、制表符或换行符的字符序列。下面这段程序是UNIX系统中wc程序的骨干部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>

#define IN  1  // 在单词内
#define OUT 0  // 在单词外

// 统计输入的行数、单词数与字符数
main() {
	int c, nl, nw, nc, state;

	state = OUT;
	nl = nw = nc = 0;
	while ((c = getchar()) != EOF) {
		++nc;
		if (c == '\n')
			++nl;
		if (c == ' ' || c == '\n' || c == '\t')
			state = OUT;
		else if (state == OUT) {
			state = IN;
			++nw;
		}
	}
	printf("%d %d %d\n", nl, nw, nc);
}

程序执行时,每当遇到单词的第一个字符,它就作为一个新单词加以统计。state变量记录程序当前是否正位于一个单词之中,它的初值是“不在单词中”,即初值被赋为OUT。我们在这里使用了符号常量IN和OUT,而没有使用其对应的数值1与0,这样程序更易读。在较小的程序中,这种做法也许看不出有什么优势,但在较大的程序中,如果从一开始就这样做,因此而增加的一点工作量与提供程序可读性带来的好处相比是值得的。读者也会发现,如果程序中的幻数都以符号常量的形式出现,对程序进行大量修改就会相对容易得多。

下列语句:

nl = nw = nc = 0;

将把其中的3个变量nl、nw与nc都设置为0。这种用法很常见,但要注意这样一个事实:在兼有值与赋值两种功能的表达式中,赋值结合次序是由右至左。所以上面这条语句等同于

nl = (nw = (nc = 0));

运算符 || 代表OR(逻辑或), 所以下列语句

if (c == ' ' || c == '\n' || c == '\t')

的意义是“如果c是空格,或c是换行符,或c是制表符”(前面讲过,转义字符序列\t是制表符的可见表示形式)。相应地运算符&&代表AND(逻辑与),它仅比||高一个优先级。由&&||连接的表达式由左至右求值,并保证在求值过程中只要能够判断最终的结果为真或假,求值就立刻终止。如果c是空格,则没有必要再测试它是否为换行符或制表符,这样就不必执行后面两个测试。在这里,这一点并不特别重要,但在某些更复杂的情况下这样做就有必要了,不久我们将会看到这种例子。

这段程序中还包含一个else部分,它指定当if语句中的条件部分为假时所要执行的动作。其一般形式为:

if (表达式)
	语句1;
else
	语句2;

其中,if-else中的两条语句有且仅有一条语句被执行。如果表达式的值为真,则执行语句1,否则执行语句2。这两条语句都既可以是单条语句,也可以是括在花括号内的语句序列。在单词计数程序中,else之后的语句仍是一个if语句,该if语句控制了包含在花括号内的两条语句。

练习1-11 你准备如何测试单词计数程序?如果程序中存在某种错误,那么什么样的输入最可能发现这类错误呢?

练习1-12 编写一个程序,以每行一个单词的形式打印其输入。

1.6 数组

在这部分内容中,我们来编写一个程序,以统计各个数字、空白符(包括空格符、制表符及换行符)以及所有其他字符出现的次数。这个程序的实用意义并不大,但我们可以通过该程序讨论C语言各方面的问题。

所有的输入字符可以分为12类,因此可以用一个数组存放各个数字出现的次数,这样比使用10个独立的变量更方便。下面是该程序的一种版本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdio.h>

/* 统计各个数字、空白符及其他字符出现的次数 */
main()
{
	int c, i, nwhite, nother;
	int ndigit[10];

	nwhite = nother = 0;
	for (i = 0; i < 10; ++i)
		ndigit[i] = 0;

	while ((c = getchar()) != EOF)
		if (c >= '0' && c <= '9')
			++ndigit[c-'0'];
		else if (c == ' ' || c == '\n' || c == '\t')
			++nwhite;
		else
			++nother;

	printf("digits =");
	for (i = 0; i < 10; ++i)
		printf(" %d", ndigit[i]);
	printf(", white space = %d, other = %d\n",
		nwhite, nother);
}

当把这段程序本身作为输入时,输出结果为:

digits = 9 3 0 0 0 0 0 0 0 1, white space = 118, other = 376

该程序中的声明语句

int ndigit[10];

将变量ndigit声明为由10个整型数构成的数组。在C语言中,数组下标总是从0开始,因此该数组的10个元素分别为ndigit[0]、ndigit[1]、…、ndigit[9],这可以通过初始化和打印数组的两个for循环语句反映出来。

数组下标可以是任何整型表达式,包括整型变量(如i)以及整型常量。

该程序的执行取决于数字的字符表示属性。例如,测试语句

if (c >= '0' && c <= '9') ...

用于判断c中的字符是否为数字。如果它是数字,那么该数字对应的数值是

c - '0'

只有当'0''1'、…、'9'具有连续递增的值时,这种做法才可行。幸运的是,所有的字符集都是这样的。

由定义可知,char类型的字符是小整型,因此char类型的变量和常量在算术表达式中等价于int类型的变量和常量。这样做既自然又方便,例如,c - '0'是一个整型表达式,如果存储在c中的字符是'0’~‘9’,其值将为0~9,因此可以充当数组ndigit的合法下标。

判断一个字符是数字、空白符还是其他字符的功能可以由下列语句序列完成:

1
2
3
4
5
6
if (c >= '0' && c <= '9')
	++ndigit[c-'0'];
else if (c == ' ' || c == '\n' || c == '\t')
	++nwhite;
else
	++nother;

程序中经常使用下列方式表示多路判定:

1
2
3
4
5
6
7
8
if(条件1)
	语句1
else if (条件2)
	语句2
...
...
else
	语句n

在这种方式中,各条件从前往后依次求值,直到满足某个条件,然后执行对应的语句部分。这部分语句执行完成后,整个语句体执行结束(其中的任何语句都可以是括在花括号中的若干条语句)。如果所有条件都不满足,则执行位于最后一个else之后的语句(如果有的话)。类似于前面的单词计数程序,如果没有最后一个else及对应的语句,该语句体将不执行任何动作。在第一个if与最后一个else之间可以有0个或多个下列形式的语句序列:

else if (条件)
	语句

就程序设计风格而言,我们建议读者采用上面所示的缩进格式以体现该结构的层次关系。否则,如果每个if都比前一个else向里缩进一些距离,那么较长的判定序列就可能超出页面的右边界。

第3章将讨论的switch语句提供了编写多路分支程序的另一种方式,它特别适合于判定某个整型或字符表达式是否与一个常量集合中的某个元素相匹配的情况。我们将在3.4节给出用switch语句编写的该程序的另一个版本,与此进行比较。

练习1-13 编写一个程序,打印输入中单词长度的直方图。水平方向的直方图比较容易绘制,垂直方向的直方图则要困难些。

练习1-14 编写一个程序,打印输入中各个字符出现频度的直方图。

1.7 函数

C语言中的函数等价于Fortran语言中的子程序或函数,也等价于Pascal语言中的过程或函数。函数为计算的封装提供了一种简便的方法,此后使用函数时不需要考虑它是如何实现的。使用设计正确的函数,程序员无需考虑功能是如何实现的,而只需知道它具有哪些功能就够了。在C语言中可以简单、方便、高效地使用函数。我们经常会看到在定义后仅调用了一次的短函数,这样做可以使代码段更清晰易读。

到目前为止,我们所使用的函数(如printf、getchar和putchar等)都是函数库中提供的函数。现在,让我们自己动手来编写一些函数。C语言没有像Fortran语言一样提供类似于**的求幂运算符,我们现在通过编写一个求幂的函数power(m, n)来说明函数定义的方法。power(m, n)函数用于计算整数m的n次幂,其中n是正整数。对函数调用power(2, 5)来说,其结果值为32。该函数并非一个实用的求幂函数,它只能处理较小的整数的正整数次幂,但这对于说明问题已足够了。(标准库中提供了一个计算x^y的函数pow(x, y)。)

下面是函数power(m, n)的定义及调用它的主程序,这样我们可以看到一个完整的程序结构。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

int power(int m, int n);

// 测试power函数
main() {
	int i;

	for (i = 0; i < 10; ++i)
		printf("%d %d %d\n", i, power(2,i), power(-3,i));
	return 0;
}
// power函数: 求底数的n次幂;其中n >= 0
int power(int base, int n) {
	int i, p;

	p = 1;
	for (i = 1; i <= n; ++i)
		p = p * base;
	return p;
}

函数定义的一般形式为:

返回值类型 函数名(0个或多个参数声明) {
	声明部分
	语句序列
}

函数定义可以以任意次序出现在一个源文件或多个源文件中,但同一函数不能分割存放在多个文件中。如果源文件分散在多个文件中,那么,在编译和加载时,就需要做更多的工作,但这是操作系统的原因,并不是语言的属性决定的。我们暂且假定将main和power这两个函数放在同一文件中,这样前面所学的有关允许C语言程序的知识仍然有效。

main函数在下列语句中调用了两次power函数:

printf("%d %d %d\n", i, power(2,i), power(-3,i));

每次调用时,main函数向power函数传递两个参数;在调用执行完成时,power函数向main函数返回一个格式化的整数并打印。在表达式中,power(2,i)的结果同2和i一样都是整数(并不是所有函数的结果都是整型数,我们将在第4章中讨论)。

power函数的第一行语句

int power(int base, int n)

声明参数的类型、名字以及该函数返回结果的类型。power函数的参数使用的名字只在power函数内部有效,对其他任何函数都是不可见的:其他函数可以使用与之相同的参数名字而不会引起冲突。变量i与p也是这样:power函数中的i与main函数中的i无关。

我们通过把函数定义中圆括号内列表中出现的变量称为形式参数,而把函数调用中与形式参数对应的值称为实际参数

power函数计算所得的结果通过return语句返回给main函数。关键字return的后面可以跟任何表达式,形式为:

return 表达式;

函数不一定都有返回值。不带表达式的return语句将把控制权返回给调用者,但不返回有用的值。这等同于在到达函数的右终结花括号时,函数就“到达了尽头”。主调函数也可以忽略函数返回的值。

读者可能已经注意到,main函数的末尾有一个return语句。由于main本身也是函数,因此也可以向其调用者返回一个值,该调用者实际上就是程序的执行环境。一般来说,返回值为0表示正常终止,返回值为非0表示出现异常情况。为简洁起见,前面的main函数都省略了return语句,但我们将在以后的main函数中包含return语句,以提醒大家注意,程序还要向其执行环境返回状态。

出现在main函数之前的声明语句

int power(int m, int n);

表明power函数有两个int类型的参数,并返回一个int类型的值。这种声明称为函数原型,它必须与power函数的定义和用法一致。如果函数的定义、用法与函数原型不一致,将出现错误。

函数原型与函数声明中参数名不要求相同。事实上,函数原型中的参数名是可选的,这样上面的函数原型也可以写成以下形式:

int power(int, int);

但是,合适的参数名能够起到很好的说明性作用,因此我们在函数原型中总是指明参数名。

回顾一下,ANSI C同较早版本C语言之间的最大区别在于函数的声明与定义方式的不同。按照C语言的最初定义,power函数应该写成下列形式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/*  power函数: 求底数的n次幂; n >= 0   */
/*  (早期C语言版本中的实现方法) */
power(base, n)
int base, n;
{
	int i, p;

	p = 1;
	for (i = 1; i <= n; ++i)
		p = p * base;
	return p;
}

其中,参数名在圆括号内指定,参数类型在左花括号之前声明。如果没有声明某个参数的类型,则默认为int类型。函数体与ANSI C中形式相同。

在C语言的最初定义中,可以在程序的开头按照下面这种形式声明power函数:

int power();

函数声明中不允许包含参数列表,这样编译器就无法在此时检查power函数调用的合法性。事实上,power函数在默认情况下将被假定返回int类型的值,因此整个函数的声明可以简写为power();

在ANSI C中定义的函数原型语法中,编译器可以很容易检测出函数调用中参数数目和类型方面的错误。ANSI C仍然支持旧式的函数声明与定义,这样至少可以有一个过渡阶段。但我们还是强烈建议读者:在使用新式的编译器时,最好使用新式的函数原型声明方式。

练习1-15 重新编写1.2节中的温度转换程序,使用函数实现温度转换计算。

1.8 参数————传值调用

习惯其他语言(特别是Fortran语言)的程序员可能会对C语言的函数参数传递方式感到陌生。在C语言中,所有函数都是“通过值”传递的。也就是说,传递给被调用函数的参数值存放在临时变量中,而不是存放在原来的变量中。这与其他某些语言是不同的,比如,Fortran等语言是“通过引用调用”,Pascal是采用var参数的方式,在这些语言中,被调用的函数必须访问原始参数,而不是访问参数的本地副本。

最主要的区别在于,在C语言中,被调用函数不能直接修改主调函数中变量的值,而只能修改其私有的临时副本的值。

传值调用的利大于弊。在被调用函数中,参数可以看做是便于初始化的局部变量,因此额外使用的变量更少,这样程序可以更紧凑简洁。例如,下面的这个power函数利用了这一性质:

1
2
3
4
5
6
7
8
/* power函数: 求底数的n次幂;n>=0;版本2  */
int power(int base, int n) {
	int p;

	for (p = 1; n > 0; --n)
		p = p * base;
	return p;
}

其中,参数n用作临时变量,并通过随后执行的for循环语句递减,直到其值为0,这样就不需要额外引入变量i。power函数外部对n的任何操作不会影响到调用函数中n的原始参数值。

必要时,也可以让函数能够修改主调函数中的变量。这种情况下,调用者需要向被调用函数提供待设置值的变量的地址(从技术角度看,地址就是指向变量的指针),而被调用函数则需要将对应的参数声明为指针类型,并通过它间接访问变量。我们将在第5章中讨论指针。

如果是数组参数,情况就有所不同了。当把数组名用作参数时,传递给函数的值是数组起始元素的位置或地址————它并不复制数组元素本身。在被调用函数中,可以通过数组下标访问或修改数组元素的值。这是下一节将要讨论的问题。

1.9 字符数组

字符数组是C语言中最常用的数组类型。下面我们通过编写一个程序,来说明字符数组以及操作字符数组的函数的用法。该程序读入一组文本行,并把最长的文本行打印出来。该算法的基本框架非常简单:

1
2
3
4
5
while(还有未处理的行)
if(该行比已处理的最长行还要长)
    保存改行
    保存该行的长度
打印最长的行

从上面的框架中很容易看出,程序很自然地分成了若干片断,分别用于读入新行、测试读入的行、保存该行,其余部分则控制这一过程。

因为这种划分方式比较合理,所以可以按照这种方式编写程序。首先,我们编写一个独立的函数getLine,它读取输入的下一行。我们尽量保持该函数在其他场合也有用。至少getLine函数应该在读到文件末尾时返回一个信号;更为有用的设计是它能够在读入文本行时返回该行的长度,而在遇到文件结束符时返回0。出于0不是有效的行长度,因此可以作为标志文件结束的返回值。每一行至少包含一个字符,只包含换行符的行,其长度为1。

当发现某个新读入的行比以前读入的最长行还要长时,就需要把该行保存起来。也就是说,我们需要用另一个函数copy把新行复制到一个安全的位置。

最后,我们需要在主函数main中控制getLine和copy这两个函数。以下便是我们编写的程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <stdio.h>
#define MAXLINE 1000    // 允许的输入行的最大长度

int getLine(char line[], int maxline);
void copy(char to[], char from[]);

// 打印最长的输入行
main() {
    int len;             // 当前行长度
    int max;             // 目前为止发现的最长行的长度
    char line[MAXLINE];  // 当前的输入行
    char longest[MAXLINE]; // 用于保存最长的行

    max = 0;
    while ((len = getLine(line, MAXLINE)) > 0)
        if (len > max) {
            max = len;
            copy(longest, line);
        }
    if (max > 0)   /* 存在这样的行 */
        printf("%s", longest);
    return 0;
}

// getLine函数:将一行读入到s中并返回其长度
int getLine(char s[], int lim) {
    int c, i;

    for (i=0; i<lim-1 && (c=getchar()) !=EOF && c!='\n'; ++i)
        s[i] = c;
    if (c == '\n') {
        s[i] = c;
        ++i;
    }
    s[i] = '\0';
    return i;
}

// copy函数:将from复制到to;这里假定to足够大
void copy(char to[], char from[]) {
    int i;

    i = 0;
    while((to[i] = from[i]) != '\0')
        ++i;
}

程序的开始对getLine和copy这两个函数进行了声明,这里假定它们都存放在同一个文件中。

main与getLine之间通过一对参数及一个返回值进行数据交换。在getLine函数中,两个参数是通过程序行

int getLine(char s[], int lim)

声明的,它把第一个参数s声明为数组,把第二个参数lim声明为整型。声明中提供数组大小的目的是留出存储空间。在getLine函数中没有必要声明数组s的长度,这是因为该数组的大小是在main函数中设置的。如同power函数一样,getLine函数使用了一个return语句将值返回给其调用者。上述程序行也声明了getLine函数的返回值类型为int。由于函数的默认返回值类型为int,因此这里的int可以省略。

有些函数返回有用的值,而有些函数(如copy)仅用于执行一些动作,并不返回值。copy函数的返回值类型为void,它显式说明该函数不返回任何值。

getLine函数把字符'\0'(即空字符,其值为0)插入到它创建的数组的末尾,以标记字符串的结束。这一约定已被C语言采用:当在C语言程序中出现类似于

"hello\n"

的字符串常量时,它将以字符数组的形式存储,数组的各元素分别存储字符串的各个字符,并以'\0'标志字符串的结束。

[h][e][l][l][o][\n][\0]

printf函数中的格式规范%s规定,对应的参数必须是以这种形式表示的字符串。copy函数的实现正是依赖于输入参数由'\0'结束这一事实,它将’\0’拷贝到输出参数中。(也就是说,空字符’\0’不是普通文本的一部分。)

值得一提的是,即使是上述这样很小的程序,在传递参数时也会遇到一些麻烦的设计问题。例如,当读入的行长度大于允许的最大值时,main函数应该如何处理?getLine函数的执行是安全的,无论是否到达换行符字符,当数组满时它将停止读字符。main函数可以通过测试行的长度以及检查返回的最后一个字符来判定当前行是否太长,然后再根据具体的情况处理。为了简化程序,我们在这里不考虑这个问题。

调用getLine函数的程序无法预先知道输入行的长度,因此getLine函数需要检查是否溢出。另一方面,调用copy函数的程序知道(也可以找出)字符串的长度,因此该函数不需要进行错误检查。

练习1-16 修改打印最长文本行的程序的主程序main,使之可以打印任意长度的输入行的长度,并尽可能多地打印文本。

练习1-17 编写一个程序,打印长度大于80个字符的所有输入行。

练习1-18 编写一个程序,删除每个输入行末尾的空格及制表符,并删除完全是空格的行。

练习1-19 编写函数reverse(s),将字符串s中的字符顺序颠倒过来。使用该函数编写一个程序,每次颠倒一个输入行中的字符顺序。

1.10 外部变量与作用域

main函数中的变量(如line、longest等)是main函数的私有变量或局部变量。由于它们是在main函数中声明的,因此其他函数不能直接访问它们。其他函数中声明的变量也同样如此。例如,getLine函数中声明的变量i与copy函数中声明的变量i没有关系。函数中的每个局部变量只在函数被调用时存在,在函数执行完毕退出时消失。这也是其他语言通常把这类变量称为自动变量的原因。以后我们使用“自动变量”代表“局部变量”。(第4章将讨论static存储类,这种类型的局部变量在多次函数调用之间保持值不变。)

由于自动变量只在函数调用执行期间存在,因此,在函数的两次调用之间,自动变量不保留前次调用时的赋值,且在每次进入函数时都要显式为其赋值。如果自动变量没有赋值,则其中存放的是无效值。

除自动变量外,还可以定义位于所有函数外部的变量,也就是说,在所有函数中都可以通过变量名访问这种类型的变量(这一机制同Fortran语言中的COMMON变量或Pascal语言中最外层程序块声明的变量非常类似)。由于外部变量可以在全局范围内访问,因此,函数间可以通过外部变量交换数据,而不必使用参数表。再者,外部变量在程序执行期间一直存在,而不是在函数调用时产生、在函数执行完毕时消失。

外部变量必须定义在所有函数之外,且只能定义一次,定义后编译程序将为它分配存储单元。在每个需要访问外部变量的函数中,必须声明相应的外部变量,此时说明其类型。声明时可以用extern语句显式声明,也可以通过上下文隐式声明。为了更详细地讨论外部变量,我们改写上述打印最长文本行的程序,把line、longest与max声明成外部变量。这需要修改这3个函数的调用、声明与函数体。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <stdio.h>

#define MAXLINE 1000   // 允许的输入行的最大长度

int max;                // 到目前为止发现的最长行的长度
char line[MAXLINE];     // 当前的输入行
char longest[MAXLINE];  // 用于保存最长的行

int getLine(void);
void copy(void);

// 打印最长的输入行:特别版本
main() {
    int len;
    extern int max;
    extern char longest[];

    max = 0;
    while ((len = getLine()) > 0)
        if (len > max) {
            max = len;
            copy();
        }
    if (max > 0)
        printf("%s", longest);
    return 0;
}
// getLine函数:特别版本
int getLine(void) {
    int c, i;
    extern char line[];

    for (i = 0; i < MAXLINE-1
        && (c=getchar()) != EOF && c != '\n'; ++i)
            line[i] = c;
    if (c == '\n') {
        line[i] = c;
        ++i;
    }
    line[i] = '\0';
    return i;
}
// copy函数: 特别版本
void copy(void) {
    int i;
    extern char line[], longest[];

    i = 0;
    while ((longest[i] = line[i]) != '\0')
        ++i;
}

在该例子中,前几行定义了main、getLine与copy函数要使用的几个外部变量,定义了各外部变量的类型,这样编译程序将为它们分配存储单元。从语法角度看,外部变量的定义与局部变量的定义是相同的,但由于它们位于各函数的外部,因此这些变量是外部变量。函数在使用外部变量之前,必须要知道外部变量的名字。要达到该目的,一种方式是在函数中使用extern类型的声明。这种类型的声明除了在前面加了一个关键字extern外,其他方面与普通变量的声明相同。

某些情况下可以省略extern声明。在源文件中,如果外部变量的定义出现在使用它的函数之前,那么在那个函数中就没有必要使用extern声明。因此,main、getLine及copy中的几个extern声明都是多余的。在通常的做法中,所有外部变量的定义都放在源文件的开始处,这样就可以省略extern声明。

如果程序包含在多个源文件中,而某个外部变量在file1文件中定义、在file2和file3文件中使用,那么在文件file2与file3中就需要使用extern声明来建立与外部变量定义的联系。人们通常把外部变量的extern声明放在一个单独的文件中(习惯上称之为头文件),并在每个源文件的开头使用#include语句把所要用的头文件包含进来。后缀名.h约定为头文件名的扩展名。

在上述特别版本中,由于getLine与copy函数都不带参数,因此从逻辑上讲,在源文件开始处它们的原型应该是getLine()与copy()。但为了与老版本的C语言程序兼容,ANSI C语言把空参数看成老版本C语言的声明方式,并且对参数表不再进行任何检查。在ANSI C中,如果要声明空参数表,则必须使用关键字void进行显式声明。第4章将对此进一步讨论。

读者应该注意到,这一节中我们在谈论外部变量时谨慎地使用了定义(define)与声明(declaration)这两个词。“定义”表示创建变量或分配存储单元,而“声明”指的是说明变量的性质,但并不分配存储单元。

顺便提一下,现在越来越多的人把用到的所有东西都作为外部变量使用,因为似乎这样可以简化数据的通信————参数表变短了,且在需要时总可以访问这些变量。但是,即使在不使用外部变量的时候,它们也是存在的。过分依赖外部变量会导致一定的风险,因为它会使程序中的数据关系模糊不清————外部变量的值可能会被意外地或不经意地修改,而程序的修改又变得十分困难。我们前面编写的打印最长文本行的程序的第2个版本就不如第1个版本好,原因有两方面,其一便是使用了外部变量;另一方面,第2个版本中的函数将它们所操纵的变量名直接写入了函数,从而使这两个有用的函数失去了通用性。

到目前为止,我们已经对C语言的传统核心部分进行了介绍。借助于这些少量的语言元素,我们已经能够编写出相当规模的有用的程序。建议读者花一些时间编写程序作为练习。下面的几个练习比本章前面编写的程序要复杂一些。

练习1-20 编写程序detab,将输入中的制表符替换成适当数目的空格,使空格充满到下一个制表符终止位的地方。假设制表符终止位的位置是固定的,比如每隔n列就会出现一个制表符终止位。n应该作为变量还是符号常量呢?

练习1-21 编写程序entab,将空格串替换为最少数量的制表符和空格,但要保持单词之间的间隔不变。假设制表符终止位的位置与练习1-20的detab程序的情况相同。当使用一个制表符或者一个空格都可以到达下一个制表符终止位时,选用哪一种替换字符比较好?

练习1-22 编写一个程序,把较长的输入行“折”成短一些的两行或多行,折行的位置在输入行的第n列之前的最后一个非空格之后。要保证程序能够智能地处理输入行很长以及在指定的列前没有空格或制表符时的情况。

练习1-23 编写一个删除C语言程序中所有的注释语句。要正确处理带引号的字符串与字符常量。在C语言中,注释不允许嵌套。

练习1-24 编写一个程序,查找C语言程序中的基本语法错误,如圆括号、方括号、花括号不配对等。要正确处理引号(包括单引号和双引号)、转义字符序列与注释。(如果读者想把该程序编写成完全通用的程序,难度会比较大。)


专题:

本文发表于 2022-09-13,最后修改于 2022-09-13。

本站永久域名「 jiavvc.top 」,也可搜索「 后浪笔记一零二四 」找到我。


上一篇 « C程序设计语言-第2章类型、运算符与表达式 下一篇 » react

赞赏支持

请我吃鸡腿 =^_^=

i ysf

云闪付

i wechat

微信

推荐阅读

Big Image