裘宗燕 从问题到程序(2003 年修订),第五章
第五章 C 程序结构
本章讨论一些与 C 程序整体结构有关的问题,它们对于正确理解 C 语言,正确书写 C
程序都非常重要。有些人使用 C 语言许多年,但仍常犯一些错误,自己也常弄不清楚错在
哪里,其原因往往就是一些基本问题不够清楚。本章的讨论方式是希望读者在学习 C 语言
编写程序的过程中,也能了解一些更深的原由。在安排这章的材料时,希望能把问题讲得比
较透,也能多举出一些例子,帮助读者理解。还有些更有意思的例子出现在后面章节里。本
章有些内容比较深入,读者第一次阅读时可能遇到一点困难,未必能完全理解。这也没有关
系,建议读者在学了后面章节后,重新回来读读这章的内容。
5.1 数值类型
第二章里已介绍了几种常用数值类型。本节对 C 的所有数值类型做一个全面介绍。
实数类型和整数类型
实数类型共有三个,类型名分别是:
float, double, long double
这些类型都已在第二章节介绍了。请注意各种实数文字量的书写形式,其中必须包含小数点
或指数部分。不加后缀的是 double 类型文字量,float 类型的文字量在数值表示后面加
后缀 f 或 F,long double 类型文字量加后缀 l 或 L(建议用大写 L,小写 l 容易与数字
1 混淆)。实数类型内部编码一般采用有关的国际标准(IEEE 标准)。例如:
float f1, f2;
double x1, x2;
long double l1, l2;
除了实数类型之外的数值类型都是整数类型。C 语言将字符类型也看作整数类型,可以
作为整数参加运算。各种整数类型都分为带符号与无符号的两种,带符号类型表示一定范围
内的正数和负数,无符号类型的值都不小于 0。在类型名前加 signed 或 unsigned,说明
一个整数类型是带符号的或是无符号的。其中 signed 都可以省略,也就是说,不加特别
说明的都是带符号类型。字符类型在这方面的情况比较特殊。
字符类型
写简单程序时通常只用字符类型 char。在一般 C 语言系统里,一个字符占一个字节,
其中存着字符的编码。字符类型主要用于存储文字信息和输入输出。如果将字符的使用限制
在这一范围里,我们将不会遇到任何复杂情况。
如果把字符当作整数参加运算,所用的就是字符的编码(这是一个整数)。由此可见,
一个字符与其他整数运算时起什么作用,要看所用的计算机系统里字符的编码方式。在目前
使用最广泛的编码系统(如 ASCII 或 EBCDIC 编码系统)里,数字字符和英文字母字符的
编码都是顺序地连续排列的。常常可以看到一些 C 程序利用了这一特征。例如,我们可能
看到程序里有下面片段:
if (c >= 'a' && c <= 'z') ...... /* 判断c中存储的字符是否小写字母 */
n = d - '0'; /* 将变量d里保存的数字字符的“数值”赋给整型变量n */
这里假定 c、d 保存着字符的编码,而 d 中存的是某个数字字符。因为数字字符的编码连续
排列,假设变量 d 的值为 '3',d - '0'(字符 0 的编码)的结果正好是 3。
实际上,C 语言还有 signed char 和 unsigned char 两个字符类型,普通 char
类型等价于这两者之一,等价于哪一个要看具体的 C 系统。如果程序里只用普通的可打印
1
裘宗燕 从问题到程序(2003 年修订),第五章
字符(字母、数字、各种标点符号、空白字符等),这方面的情况不会产生任何影响。
把字符区分为“有符号字符”和“无符号字符”,这一点不太好理解,似乎也没有什么
道理。问题只出现在用字符类型的数据与其他整数运算时:如何看待字符所表示的数?是把
字符数据看成有符号的整数呢,还是看成无符号的整数?这方面的问题在写初级程序的时候
完全没有必要去考虑。现在只需要知道这种情况。
整数类型
基本的整数类型有三个:
int, short int, long int
其中 short int 可以简写为 short,而 long int 可以简写为 long。这三个类型都有
对应的无符号类型,因此整数类型实际上有六个:
int short long
unsigned int unsigned short unsigned long
这里的 unsigned int 还可以简写为 unsigned。上表里许多类型名已经是简写形式,例
如,unsigned long 的完整形式是 unsigned long int。
C 语言标准没有规定 int、short int、long int 的具体实现方式(二进制编码长
度),只规定了一些原则,主要有:short 类型的表示范围不大于 int 类型的表示范围,
long 类型的表示范围不小于 int 的表示范围。并规定 short 至少为 16 位,long 至少为
32 位;各 unsigned 类型总采用与对应 signed 类型同样长度的表示。每个类型的具体表
示(用多少位表示,用什么编码方式等)由具体 C 语言系统规定。
这里最基本的类型是 int。C 系统里的 int 类型一般采用相应计算机的字长。例如,
16 位计算机的 C 语言系统的 int 类型通常采用 16 位表示方式;而在 32 位计算机的 C 语言
系统中 int 类型通常用 32 位表示。
PC 机上 C 系统的情况比较复杂。一些老的 C 系统(DOS,Windows 3.1 上的 C 系统)
通常采用 16 位的 int 类型,因为 16 位是 8086/8088 CPU 的字长。这时 int 类型的表示范
围是 -32768~32767,即 − ~ 2215 115 − 。unsigned int 用 16 位,表示范围是 0 ~ 65535,
即 0 ~ 2 。这些系统里的 long 类型通常用 32 位表示,short 类型通常也用 16 位表
示。一些新的 C 系统(如运行在 Windows NT、Windows95/98/2000 等系统上 C 系统)则采
用 32 位的 int 类型,long 类型用与 int 一样的表示方式,short 用 16 位表示。
116 −
有关具体 C 系统里各种类型的情况,使用前应该查阅系统的有关材料。如语言手册、
联机帮助信息或有关书籍。C 标准库里有两个名字分别为“limit.h”和“float.h”的
文件,其中列出了与本系统所有数据类型表示有关的信息,读者可以查阅所用 C 系统里这
的这两个文件。这方面的进一步细节请参看第 11 章里对标准库情况的介绍。
整数类型的文字量用连续数字序列表示。前面已讲过整型字面量的十进制、八进制和十
六进制表示问题。如果需要特别表示写的是长整数,就应加上后缀 l 或 L,short 类型没
有字面量写法。无符号整数的后缀是 u 和 U,无符号长整型加后缀 UL 或者 LU 均可。下面
是一些无符号整数的字面量的例子:
123U,2987654LU,327LU,32014U
无符号整数类型的另一个特点是算术运算以对应类型的表示范围为模进行。当计算结果超出
类型的表示范围时,以取模后的余数作为计算结果。假定 unsigned 用 16 位表示,表示范
围是 0~65535。如果计算结果超出这个范围,就以得到的结果除以 65536 的余数作为结果。
例如 234+65500 的结果将是 198。其他无符号类型的情况也一样。
由于类型问题,计算中可能出现隐含的类型转换动作。C 语言规定,当各种小整数类型
(short、unsigned short 类型,各种 char 类型)的数据出现在表达式之中,计算之
2
裘宗燕 从问题到程序(2003 年修订),第五章
前先将它们转换为 int 类型的值后再参与运算,这一过程称为整数提升。如果某类型的一
个值超出了 int 的表示范围(例如 unsigned short 类型的提升时就可能出现这种情况),
那么在整数提升中将其提升为无符号整数类型。
前面讲过,两个不同类型的数值对象进行运算前,要把小数据类型的值转换到大数据类
型。现在还要补充一点:在基本类型相同时,C 语言认为无符号类型是比同样有符号类型更
大的类型。举例说,如果要做下面计算:
2365U + 18764
首先要从整型值 18764 转换生成一个无符号整数的对应值,然后用这个新值参与计算。如
果需要转换的有符号整数的值为负,转换结果依赖于具体的系统。
如果要求将无符号数转换到有符号数(通过强制转换或者赋值、
参数
转速和进给参数表a氧化沟运行参数高温蒸汽处理医疗废物pid参数自整定算法口腔医院集中消毒供应
传递等),那么也
按前面所说的规则处理:如果原类型的值能在转换的目标类型中表示,那么转换后的值不变,
否则转换结果依赖于具体的系统。
基本数据类型的选择
C 语言提供了多个浮点数类型和多个整数类型,目的是使编程者有较多选择机会,满足
复杂的系统程序
设计
领导形象设计圆作业设计ao工艺污水处理厂设计附属工程施工组织设计清扫机器人结构设计
中的各种需要。C 语言应用广泛,不同程序或软件中对数值的表示范围
和精度的要求会有很大差异。对于某些应用问题而言,选择合适的数值类型可能很重要,在
写那些程序时,人们就需要更仔细地考虑,确定每一个变量应该采用哪个数值类型。这是专
业 C 程序员的写重要程序时的一项工作。
然而,对于一般程序,特别是对于学习 C 程序设计而言,这种选择就不那么重要了。
现在提出如下的类型选择原则,这也是在大多数 C 程序里的最合理选择:
1. 如果没有特殊需要,浮点数总采用 double 类型,因为它的精度和表示范围能满足一
般要求(float 的精度常常不够,long double 可能降低效率)。
2. 如果没有特殊需要,整数总采用 int 类型,因为它是每个 C 系统里的最基本类型,必
定能得到硬件的基本支持,其效率不会低于任何其他整数类型。
3. 如果没有特殊需要,字符总采用 char 类型。
4. 尽量少用各种 unsigned 类型,除非服务于某些特殊目的。
5.2 函数和标准库函数
随着要处理的问题越来越复杂,程序也会变得越来越长。程序长带来许多问题:长的程
序开发困难,牵涉的情况更复杂,写程序的人更难把握。长程序的阅读和理解也更困难,这
又影响到程序的开发和维护。如果要修改程序,就必须先理解一项改动对整个程序的影响,
防止其破坏了程序的内在一致性。另外,随着程序变大,程序中也常出现一些相同或类似的
代码片段,这使程序变得更长,也增加了程序里不同部分间的互相联系。
处理复杂问题的基本方式就是设法把它分解为一些相对简单的部分,分别处理这些部
分,然后用各个部分的解去构造整个问题的解。为支持复杂计算过程的描述和程序设计,就
需要程序语言提供分解复杂描述的手段,需要有把代码段抽象出来作为整体使用和处理的手
段。随着人们对程序设计实践的
总结
初级经济法重点总结下载党员个人总结TXt高中句型全总结.doc高中句型全总结.doc理论力学知识点总结pdf
,许多抽象机制被引进了程序语言。这些机制极为重要,
人只有借助于它们才可能把握复杂的计算过程,完成复杂的程序或软件系统。C 是 70 年代
初研制开发的语言,那时人们在这方面的认识还比较粗浅,所以这里只提供了对计算过程片
段的抽象机制,这就是前面已初步介绍过的函数机制。
函数的作用是使人可以把一段计算抽象出来,封装(包装)起来,使之成为程序中的一
个独立实体。还有为这样封装起的代码取一个名字,做成一个函数定义。当程序中需要做这
段计算时,可以通过一种简洁的形式要求执行这段计算,这种片段称为函数调用。
3
裘宗燕 从问题到程序(2003 年修订),第五章
函数抽象机制带来了许多益处:
1. 重复出现的程序片段被一个唯一的函数定义和一些形式简单的函数调用所取代,这样有
可能使程序变得更简短而清晰。
2. 由于整个程序里同样的计算片段仅描述一次,需要改造这部分计算时,就只要修改一个
地方:改变函数的定义。程序的其他地方可能完全不需要修改。
3. 函数定义和使用形成对程序复杂性的一种分解,使人在程序设计中可以孤立地考虑函数
本身的定义与函数的使用问题,有可能提高程序开发的效率。
4. 把具有独立逻辑意义的适当计算片段定义为函数后,函数可以看成是在更高层次上的程
序基本操作。一层一层的函数定义可以使人可以站在一个个抽象层次上去看待和把握程
序的意义,这对于开发大的软件系统是非常重要的。
在前面章节里,已经讨论了许多有关的实例。
5.2.1 C语言的库函数
C 语言是一种比较简洁的语言,其基本部分较小,例如,语言本身甚至没有提供输入输
出功能的结构。C 程序所需要的许多东西都是通过函数方式提供的。
每个 C 系统都带有一个相当大的函数库,其中以函数方式提供了许多程序中常用的功
能。ANSI C 标准对函数库做了
规范
编程规范下载gsp规范下载钢格栅规范下载警徽规范下载建设厅规范下载
化,总结出一批最常用的功能,定义了标准库。今天的
每个 C 系统都提供了标准库函数,供人们开发 C 程序时使用。标准库的功能通过一批头文
件描述,如果要使用标准库的功能,就需要用 #include 命令引进相应头文件。
此外,具体 C 系统通常还根据其运行环境的情况提供了扩充库,使采用这个 C 系统开
发的程序可利用特定硬件或操作系统的功能等。例如,运行在微机 DOS 系统上的 C 系统将
提供一批利用 DOS 系统特定功能的函数;运行在 Windows 上的 C 语言系统都提供了一批与
Windows环境有关的函数;运行在UNIX上C的系统必定提供一批与UNIX系统接口的函数。
扩充库的功能也是通过一批头文件描述的,使用它们使也需引入相应头文件。
无论是标准库函数还是扩充库函数,都可看作常用计算过程的抽象。如果写程序时需要,
就可以按规定方式直接调用这些函数,不必自己写程序实现这些功能,也不必关心这些函数
是如何实现的。这样,开发 C 系统的人只做了一次工作,就使所有使用该系统编程序的人
都节省了大量时间和精力。由此可以明显看到函数的意义和作用。
C 标准库函数完成一些最常用的基本功能,包括基本输入和输出、文件操作、存储管理,
以及其他一些常用功能函数,如数学函数、数据值的类型转换函数等。对这些函数的介绍散
布在本中各个章节里。第 11 章包含对标准库其他重要函数的介绍。至于具体 C 系统的扩充
函数库,就需要查阅系统联机帮助材料、系统手册或其他参考书籍。学习本课程时也应学会
使用手册和联机帮助材料,学会如何阅读它们。
下面介绍两组简单函数。
5.2.2 字符分类函数
首先介绍标准库文件 ctype.h 描述的各种字符分类函数。这些函数很简单,它们对满
足条件的字符返回非 0 值,否则返回 0 值。下面是有关函数:
isalpha(c) c 是字母字符
isdigit(c) c 是数字字符
isalnum(c) c 是字母或数字字符
isspace(c) c 是空格、制表符、换行符
isupper(c) c 是大写字母
islower(c) c 是小写字母
iscntrl(c) c 是控制字符
isprint(c) c 是可打印字符,包括空格
4
裘宗燕 从问题到程序(2003 年修订),第五章
isgraph(c) c 是可打印字符,不包括空格
isxdigit(c) c 是十六进制数字字符
ispunct(c) c 是标点符号
要使用这些函数,应当在程序前部用#include 命令包含系统头文件 ctype.h。在这个头
文件里还说明了两个字母大小写转换函数:
int tolower(int c) 当 c 是大写字母时返回对应小写字母,否则返回 c 本身
int toupper(int c) 当 c 是小写字母时返回对应大写字母,否则返回 c 本身
例如,下面程序统计文件中数字、小写字母和大写字母的个数,其中使用了标准库的几个字
符分类函数。采用标准库函数的做法比自己写条件判断更合适,值得提倡。
#include
#include
int main()
{
int c, cd = 0, cu = 0, cl = 0;
while ((c = getchar()) != EOF) {
if (isdigit(c)) ++cd;
if (isupper(c)) ++cu;
if (islower(c)) ++cl;
}
printf ("digits: %d\n", cd);
printf ("uppers: %d\n", cu);
printf ("lowers: %d\n", cl);
return 0;
}
5.2.3 随机数生成函数
计算机程序实现的都是确定性的计算:给一个或者一组初始数据,它总计算出一批确定
的结果。然而计算机应用中有时也需要带有随机性的计算。
一个例子是程序调试。在程序调试时,人们需要用各种数据进行程序运行试验,看能否
得到预期结果,有时用随机性数据作为试验数据是很合适的。另一个应用领域是计算机模拟,
也就是用计算机模拟某种实际情况或者过程,以帮助人认识其中的规律性。客观事物的变化
中总有一些随机因素,如果用确定性数据进行模拟,多次模拟得到的结果完全一样,将无法
很好地反映客观过程的实际情况。
由于这些情况,人们希望能用计算机生成随机数。实际上,计算机无法生成真正的随机
数,通过计算只能生成所谓的伪随机数。如何用计算机生成随机性比较好的随机数仍是人们
研究的一个问题。最常用的随机数生成方法是定义一种递推关系,通过这个递推关系生成一
个数的序列,还要设法使这个序列中的数看起来比较具有随机性。
最常用的简单递推关系是通过除余法定义的关系:
α0 = A, α αn nB C D= × +−( ) mod1 对 n > 0
这里 A B C D, , , 都是整常数,0 ≤
#include
int main()
{
int i;
for (i = 0; i < 10; ++i) printf ("%d ", rand());
putchar('\n');
srand(11);
for (i = 0; i < 10; ++i) printf ("%d ", rand());
putchar('\n');
return 0;
}
程序里的 putchar('\n');使输出换一行。在某个系统里这个程序输出:
41 18467 6334 26500 19169 15724 11478 29358 26962 24464
74 27648 21136 4989 24011 22223 9834 85 28238 28519
5.3 函数定义和程序的函数分解
无论系统提供多少库函数,其数量终归有限,编程时总要考虑定义自己的函数。一个 C
程序主要由一系列函数定义组成。每个函数定义包含一段程序代码,执行时将完成一定工作。
定义函数时给定了一个名字,供调用这个函数时使用。函数定义的基本形式是:
返回值类型 函数名 参数表 函数体
返回值类型描述函数执行结束时返回的值类型;函数名用标识符表示,主要用于调用这个函
数;参数表描述函数的参数个数和各参数的类型;函数体是一个复合语句,描述被这个函数
所封装的计算过程。函数体之前的部分称为函数头部,它描述了函数外部与函数内部的联系,
下面要着重讨论这部分的问题。例如,下面是一个我们熟悉的函数:
long fact (int n) {
int fac, i;
for (fac = 1, i = 1; i < n; ++i)
fac *= i;
return fac;
}
返回 long 值,函数名为 fact,参数表里只有一对参数描述(一个类型名和一个参数名)。
定义函数时可以不写返回值类型,这表示返回 int 类型的值。这种做法不应提倡,因
为它容易引起错误(未来的 C 系统将禁止不写类型的形式)。我们也可以定义不返回值的函
数,这时用关键字 void 说明“返回值类型”。这种写法很别扭,是 C 语言把两类东西(有
返回值和无返回值的函数)用同一形式写出而带来的副作用。
一个函数可以有任意多个参数,各参数描述用逗号分隔。每个参数描述包括一个类型名
和一个参数名。函数定义的参数表里给出的参数名称为函数的形式参数,简称形参。定义无
参函数时参数表应写成()或者(void)。后一写法很不自然,是早期 C 语言的遗留问题对新
ANSI C 标准的影响,我们只能接受。没参数的函数又称无参函数。
5.3.1 主函数
每个 C 程序里总有一个名为 main 的特殊函数,常称为主函数。主函数规定了整个程
序执行的起点,专业术语是程序入口。程序执行从这个函数开始,一旦它执行结束,整个程
序就完成了。程序里不能调用主函数,它将在程序开始执行时被自动调用。
C 语言规定主函数的返回值必须是 int 类型。有些书里的程序示例没描述 main 的返
6
裘宗燕 从问题到程序(2003 年修订),第五章
回值类型,按上面所说,这正说明其返回值为 int。主函数的返回值不会在本程序内部使用
(因为 main 不能在程序内调用),这个值将在程序结束时提供给操作系统。在程序外部,
例如操作系统,可以检查和使用程序的这个返回值。
写 C 程序时应为主函数定义返回值,一般用返回值 0 表示程序正常结束,用其他值表
示执行中出现了非正常情况。按语言规定,在主函数结束时如果没有提供返回值,程序将自
动产生一个表示成功完成的返回值(通常就是 0)。一些 C 系统会对主函数返回值的情况产
生警告,这是不对的,但我们也不必戒意。这样 main 函数的样子就是:
int main () {
... ...
return 0;
}
除了主函数外,程序里的其他函数只有在明确调用时才能进入执行状态。所以,一个函
数要在程序执行过程中起作用,那么它或是被主函数直接调用的,或是被另外一个能被调用
执行的函数调用的。没有被调用的函数在程序执行中不会起任何作用。
5.3.2 程序的函数分解
在编写大些的程序时,应该特别注意程序的功能分解,在这里也就是函数分解。也就是
说,应该把程序写成一组较小的函数,通过这些函数的互相调用完成所需要的工作。初学者
往往不注意函数分解,一些教学材料或书籍中对这个问题强调得也很不够,给出的程序例子
经常是一大片,没有结构性,初学者不良编程习惯的形成往往与此有关。实际上,在学习程
序设计的过程中强调函数分解是绝对必要的,没有合理的函数分解,完成规模较大的程序将
更困难,要花费更多时间,写出的程序通常也更难理解,出现了错误更难发现和改正。这一
点值得读者特别注意。 #include ...
int f(...) {
... f(...) ...
}
int g(...) {
... f(...) ...
}
void h(...) {
... f(...) ...
... g(...) ...
}
int main(void) {
... h(...) ...
... g(...) ...
}
main
h g
f
这里的箭头表示函数调用关系
图 5.1 源程序及其确定的函数调用关系示意图
一般说,一个 C 程序
由一组函数构成,图 5.1
显示了一个 C 程序的结
构和执行中的调用关系。
左边是程序的概貌,其中
除主函数外还定义了三
个函数,这些函数互相调
用。右边图形显示了调用
关系,矩形表示函数,箭
头表示函数调用。递归调
用(函数 f 调用自己)表
现为到自身的箭头。
问题是:什么样的程
序片段应当定义成函数呢?这并没有万能的准则,程序设计者需要自己分析问题,总结经验。
这里提出两条线索,供读者学习时参考:
1. 程序中可能有重复出现的相同或相似的计算片段。可以考虑从中抽取出共同的东西,定
义为函数。这将使一项工作只有一个定义,需要时可以多次使用。这样做不但可能缩短
程序,也将提高程序的可读性和易修改性。
2. 程序中具有逻辑独立性的片段。即使这种片段只出现一次,也有可以考虑把它们定义为
独立的函数,在原来需要这段程序的地方写函数调用。这种做法的主要作用是分解程序
复杂性,使之更容易理解和把握。例如,许多程序可以分解为三个主要工作阶段:正式
工作前的准备阶段,主要工作阶段(通常这里有复杂的循环等),完成前的结束阶段。
7
裘宗燕 从问题到程序(2003 年修订),第五章
把程序分解为相应三个部分,设计好它们之间的信息联系方式后,就可以用独立的函数
分别实现了。显然,与整个程序相比,各部分的复杂性都更低了。
很难说什么是一个程序的最佳分解。对一个程序可能有许多种可行分解方式,寻找比较合理
或有效的分解方式是需要学习的东西。熟
悉程序设计的人们提出的经验准则是:如
果一段计算可以定义为函数,那么就应该
将它定义为函数。
5.3.4 对函数的两种观点
一个实例:字符图形
假定要做出一些字符拼出的几何图
形,如图 5.2 中那样的菱形、六边形和矩形,应该如何写程序呢?当然可以直接写许多输出
语句打印菱形,另写一个程序打印六边形,等等。但如果要输出其他图形,或者要改变图形
的大小,原来写的程序几乎就没用了。在实际工作中,程序的需求经常改变和扩充,因此,
写程序时必须关心程序的修改和扩充问题。函数在这里能扮演重要角色。另外,重复描述许
多类似的输出语句也很烦,既没有意思也容易出错。
* * * * *** * * ***** *********** * * ******* * * * * ******* * * * * ******* * * * * ******* * * * * ******* * * * * ***** *********** * * *** * *
图 5.2 字符图形
现在考虑定义几个函数实现画这类图形的基本功能,而后通过调用这些函数画出所需要
的具体图形。为了考虑这些函数,需要首先分析这类图形的性质。这里提出对问题的一种分
析(完全可以有其他合理分析,下面另有说明):图形中每一行有两种情况,一种是从某个
位置开始的一段连续字符;另一种是在两个特殊位置输出字符。将这两种情况看着基本操作,
可以考虑定义两个函数,其头部分别为:
void line(int begin, int end)
void points(int first, int second)
第一个函数从位置 begin 到 end 输出一系列星号,第二个函数在 first 和 second 两处
输出星号。考虑到字符图形未必总用星号,我们也可以推广定义,引进一个字符参数:
void line(char c, int begin, int end)
void points(char c, int first, int second)
虽然这两个函数还没有定义,但由于它们的功能已经很清楚,现在就可以利用它们写画图形
的函数了。例如,下面语句将画出一个三角形(假定所用变量已有定义):
for (i = 10, j = 10; i > 5; --i, ++j)
points('*', i, j);
line('*', 5, 15);
画其他空心或者实心的规范图形也不困难,留给读者作为练习。由这些可以看出,如果作为
函数的使用者,我们只需考虑函数的使用形式和函数的功能,考虑如何基于此完成所需工作。
有关功能的具体实现则不是使用函数时需要考虑的问题(也不应该考虑)。
下面转到函数实现者的角度,考虑如何定义这两个函数。这些函数的定义不困难,只是
输出适当的空格并在适当位置输出字符 c。下面是这两个函数的定义:
void line(char c, int begin, int end) {
int i;
for (i = 0; i < begin; ++i) putchar(' ');
for ( ; i <= end; ++i) putchar(c);
putchar('\n');
}
void points(char c, int first, int second) {
int i;
for (i = 0; i < first; ++i) putchar(' ');
putchar(c);
for (++i ; i < second; ++i) putchar(' ');
if (first < second) putchar(c);
8
裘宗燕 从问题到程序(2003 年修订),第五章
putchar('\n');
}
函数里就是一些简单输出语句。这里按照习惯将屏幕的首列作为第 0 列。函数 points 的
定义里有一个检查,只在 first 小于 second 时才输出第二个字符。
有了这两个基本函数,我们就很容易做出各种图形了。如果已经做出了一个图形,在必
要时也不难修改其大小或者形状。进一步说,我们也可以基于它们定义出一组描画各种基本
几何图形的函数。在设计这些函数时,需要选定几个合适的参数。例如,可以定义如下两个
画矩形的函数(这里只给出函数头部):
void rect(char c, int begin, int len, int high) { ... ... }
void rect_fill(char c, int begin, int len, int high) { ... ... }
也可以通过引入一个附加参数的方式将两个函数合而为一:
void rect(char c, int begin, int len, int high, int fill) { ... ... }
在参数 fill 为 0 值时画出空心矩形,非 0 时画实心矩形。
不难看出,这些函数又提供了另一层次的功能分解。定义好这样一组函数之后,就可以
在另一个层次上写绘制字符图形的程序了。显然,上述分解只是一种可行设计,它有优点也
有缺点。我们完全可以考虑其他函数分解。例如,将基本作图函数定义为:
void syms(char c, int n) { ... ... }
其基本功能就是输出 n 个字符 c。基于这一简单函数同样可以实现各种图形绘制函数。
函数封装和两种观点
函数是封装起来并给以命名的一段程序代码,是程序中具有逻辑独立性的实体。函数需
要定义,又能作为整体在程序中调用执行,完成其代码所描述的工作。这些情况引出了对函
数的两种观点(两种观察角度):从函数之外(从函数使用者的角度)看函数,以及在函数
内部(以定义者的角度)看函数。看到两者的差异对于认识函数,考虑与函数有关的问题都
非常重要。图 5.3 直观地反映这里的问题。
一个函数封装把函
数里面和外面分开,形成
了两个分离的世界——
函数的内部和外部,站在
不同的世界里看问题,就
形成了对函数“内部观
点”和“外部观点”。函
数头部的描述规定了函
数内外间的交流方式和
通道,定义了内部和外部
都需要遵守的共同规范。
函数
头部
的
说明
函数封装
函数内部
关心的是函数应当如何实现
—采用什么计算方法
—采用什么程序结构
—怎样得到计算结果
… …
图 5.3 对函数的两种观点
函数外部
关心的是函数如何使用:
—函数实现什么功能
—函数的名字是什么
—函数有几个参数,类型是什么
—函数返回什么值
… …
从外部看,一个函数实现了某种功能。只需知道它的名字和类型特征等。调用函数时遵
从这些规定,提供数目和类型适当的实参,正确接受返回值,就能得到预期的计算结果。
在函数之外不应该关心函数功能的实现问题。这种超脱很重要,不掌握这种思想方法就
无法摆脱琐碎细节的干扰,不能学会处理复杂问题。初学者常犯的一个毛病是事事都想弄清。
这种考虑不但常常不必要,有时甚至不可能。例如,对标准库函数,我们不知道它们是用什
么语言写的,但这并不妨碍在程序中使用它们。
内部观点所关心的问题当然不同。这时的重要问题包括:函数调用时外部将提供哪些数
据,各为什么类型(由参数表规定);如何用这些参数完成所需计算,得到所需结果(算法
问题);函数应在什么情况下结束?如何产生返回值(返回语句如何写)?在考虑函数实现
时,不应去关心那里将调用这个函数等。
9
裘宗燕 从问题到程序(2003 年修订),第五章
函数头部的重要性就在于它构成了函数内部和外部之间的联系界面,函数外部和内部通
过这个界面交换信息,达到函数定义和使用之间的沟通。在定义函数之前首先应有全面考虑,
据此定义好函数头部,规定好公共规范。此后人的角色就分裂了,应根据是定义函数还是使
用函数来观察和解决问题。实际上,一旦弄清了函数功能,描述好函数头部后,函数的定义
和使用完全可以由两个或两批人做。只要他们遵循共同规范,对函数功能有共同理解,就不
会有问题。大型软件的开发中经常需要做这类功能分解。注意,上面两句话很重要:“遵循
共同规范”,“对于函数的功能有共同理解”,人经常在这里出现偏差。我们自己写程序时也
必须注意,应保证对同一函数的两种观点间的内在一致性。
函数定义
C 程序中,函数的形式参数也是函数的局部变量,在其他局部变量定义之前就有了定义,
它们的初值由函数调用时的实参(表达式)取得。形参在函数体内的使用方式与其他局部变
量完全一样,也可以重新给它们赋值。
函数被调用执行后,顺序执行函数体内的语句序列。return 语句在函数体中起着特殊
的作用,任何 return 语句的执行将导致本函数的本次执行结束。如果本函数由另一函数
调用,函数结束将使执行过程返回到那个函数里的调用点,计算从该调用点后面继续下去。
如果这个函数就是主函数,函数结束就是程序执行的结束。
return 语句有两种不同形式:
return;
return 表达式;
分别用在无返回值和有返回值的函数里。无表达式的 return 语句简单导致函数结束。有
返回值的函数必须用带表达式的 return 语句,执行到这里时先求出表达式的值,并将这
个值转换到函数的返回值类型后作为函数返回值。显然,这就要求表达式的类型能转换到函
数定义的返回值类型。一个函数里可以有多个 return 语句。所有 return 语句在带不带
表达式,所带表达式的类型方面都应当与函数头部一致。
函数结束的一种情况是执行到达函数体的最后。函数以这种方式结束时返回值无定义,
所以这种结束方式只能出现在无返回值的函数中。例如前面的 pc_area 就是这种情况。
函数调用
函数调用的形式是函数名后跟圆括号括起、逗号分隔的若干表达式,这些表达式称为实
际参数,简称实参。调用函数时必须提供一组个数正确、类型合适的实参。调用无参函数时
需要写一对空括号。无返回值的函数通常用在单独的函数调用语句里,如:
pc_area(x + 3);
有返回值的函数一般出现在表达式里,用其返回值参加计算。C 语言允许不用函数的返回值
(即使它有)。例如,前面多次使用的 printf 实际有一个 int 返回值,工作正常完成时,
其返回值是执行中实际输出的字符个数;函数执行中出错时返回负值。在前面例子里从来未
用过这个返回值,这时返回值就被简单地丢掉了。
C 语言的参数机制称为值参数(简称值参)。函数调用时计算各实参表达式的值并分别
送给对应形参,而后执行函数体。函数内对形参的赋值与实参无关。即使实参是变量,对形
参的赋值也不会改变实参的值。图 5.3 显示了实参与形参的关系,f(m,n)执行时,实参 m
和 n 的值分别送给形参 a 和 b,f 内部对 a 和 b 的操作与 m、n 再也没有关系了。
函数调用中还有一个重要问题必须引起重视。对于多个参数的函数,C 语言没有规定调
用时实参的求值顺序,任何依赖实参求值顺序的调用都得不到任何保证(这与对二元算术的
运算对象的情况一样)。例如,下面是一个错误的函数调用:
n = ...;
m = gcd(n += 15, n);
10
裘宗燕 从问题到程序(2003 年修订),第五章
因为在这个调用中,对第一个参数的求值将影响第二个参数的值。下面是另一个错误调用:
printf("%d, %d", n++, n);
请不要在程序里写这种语句。
另外,实参表达式求值后需要传入函数,这时可能产生隐含的的类型转换动作。为使实
参到形参的值传递能进行,就要求所需转换合法,否则编译时将出现类型错误。
5.3.5 函数原型
在 C 程序里,每个
有名字的程序对象(变
量、函数都是程序对象)
都有定义点和使用点。
一般说,一个对象只应
有一个定义点,可以有
多处使用点。为保证使
用与定义的一致性,通行的规则是应当“先定义后使用”。以函数的局部变量为例,要求变
量定义出现使用变量的语句之前,这就保证了它们的先定义后使用。
n
m
调用时进行
参数值的复制
函数 f的调用环境
函数 f
的内部
图 5.3 函数调用与参数值的传递
a
b
函数定义:
int f(int a,
int b) {
... ...
}
函数调用:
f(m, n);
规定“先定义后使用”,是因为对象的使用方式依赖于它们的性质。如果没有定义在先,
就难以知道使用是否正确。为保证语言系统能正确处理程序,基本原则是:保证从每个对象
的每个使用点向前看,都能得到与正确使用该对象有关的完备信息。
在函数的使用点,需要信息就是函数的类型特征,包括函数名,参数个数和类型,函数
返回值类型。调用处需要检查参数个数是否正确,各参数的类型是否与函数定义一致,如果
不一致能否转换(必要时插入转换动作)等。由于返回值可能参加进一步计算,也要做类似
处理。看不到函数的类型特征,就无法正确完成这些检查和处理。
函数原型
前面许多程序实例里都定义了函数。我们一直把主函数写在最后,就是为保证调用点与
使用点之间有合适的相对位置。在函数的递归定义中,这一要求仍能满足。例如:
long fact(long n) {
return n <= 0 ? 1 : n * fact(n - 1);
}
在函数体里的递归调用点能看到函数头部描述的类型特征。
也存在一些情况,其中无法通过安排函数定义位置的方法解决调用点与使用点间的信息
交流关系。典型例子是两个互相调用的函数定义,例如需要写下面两个函数定义:
double h(double x) {
....
... g(...) ...
....
}
double g(double y) {
....
... h(...) ...
....
}
无论怎样安排顺序,都无法保证函数的每个使用点都能出现在相应函数的定义点之后。这类
情况的存在是 C 语言引进函数原型说明的一个重要原因。另外的原因是人们希望能自由地
安排函数定义的位置,支持大规模程序的开发,有关情况在后面章节介绍。
函数原型说明(有时也简单地说原型或函数原型)在形式上与函数头部类似,最后加一
个分号。原型说明中参数表里的参数名可缺(可以只写类型)。即使在这里写参数名,所用
名字也不必与函数定义用的名字一致。原型说明里的参数名可以起提示作用,也提倡给出有
意义的名字,这将有利于函数的正确使用。另外,如果希望写注释来说明函数的作用,有了
参数名也更容易描述。下面是前面定义的两个函数的原型说明:
11
裘宗燕 从问题到程序(2003 年修订),第五章
void line(char c, int begin, int end);
void points(char c, int first, int second);
函数原型可以出现在任何可以写定义的地方。目前人们认为最合理的方式,是把原型说
明都放在源程序文件最前面。这样,本程序文件中所有的函数使用点都可以“看到”这里的
原型说明。更重要的是,这样做能保证文件里的各处看到的是同一个原型说明,有利于保证
同一函数的所有调用之间的一致性。另一方面,这使函数的定义点也能看到这个原型说明,
编译程序会检查两者间的一致性,出现不一致时会产生明确的编译错误。这正是我们所希望
的:以函数原型作为媒介,保证函数定义和使用之间的一致性。
现在就可以处理上面互相递归定义的例子了。例如可以如下安排(反过来也行):
double h(double);
....
double g(double y) {
.... h(...) ....
}
double h(double x) {
.... g(...) ....
}
应特别强调:为了保证函数原型真正能起作用,写原型时必须给出完整的类型特征描述。
不注意这个忠告可能吃大亏,可能使错误遗留在程序里,到调试和试验运行时遇到更大麻烦。
许多材料和书籍里没有这样做,那是很不合适的,希望大家不要效仿。
过时的函数定义形式与原型形式
C 语言是发展的产物,其标准化不得不保留一些过时东西,以保证过去的程序仍能由符
合标准的编译系统加工。虽然如此,今天学习 C 语言时还是应该避免使用过时描述方式。
ANSI C 标准指出,过时描述方法将逐步淘汰,我们完全没理由去用过时形式。希望读者能
始终坚持正确的新的程序书写形式,这也是本书始终强调的。这里介绍过时描述形式只是为
了完整。因为读者在参考其他书籍、阅读已有程序时,难免会遇到过时的 C 语言描述方式,
因此需要了解这方面的情况。但希望读者绝不要使用这些形式*。
过时的函数定义形式把参数的类型说明另外给出在参数表后面。例如:
double f1(x, y, n)
double x, y;
int n;
{
... ... /* 函数体 */
}
这种方式的重要缺点是参数表和类型说明分离,增加了维护一致性的负担。这种方式在一些
早期程序语言中采用,新的程序语言都已抛弃了这种过时形式。
过时原型说明形式的危害更大。过时原型说明在形式上是不描述参数类型,只写函数名
和一对括号。有人还常把这种说明与变量定义等写在一起。实际上,这种“简写”原型说明
是许多 C 程序错误的根源。请读者务必不要使用这种形式,不要到吃了大亏,自己有了切
身体会后才幡然醒悟。
下面是一个例子。假设现在有两个函数,其完整原型分别是:
double f(double, int);
in