9.1 复习函数
首先,什么是函数?函数(function)是完成特定任务的独立程序代码单元。语法规则定义了函数的结构和使用方式。虽然 C 中的函数和其他语言中的函数、子程序、过程作用相同,但是细节上略有不同。一些函数执行某些动作,如 printf() 把数据打印到屏幕上:一些函数找出一个值供程序使用,如,strlen() 把指定字符串的长度返回给程序。一般而言,函数可以同时具备以上两种功能。
为什么要使用函数?首先,使用函数可以省去编写重复代码的苦差。如果程序要多次完成某项任务,那么只需编写一个合适的函数,就可以在需要时使用这个函数,或者在不同的程序使用该函数,就像许多程序中使用 putchar() 一个。其次,即使程序只完成某项任务一次,也值得使用函数。因为函数让程序更加模块化,从而提高了程序代码的可读性,更方便后期修改、完善。
9.1.1 创建并使用简单函数
我们的第 1 个目标是创建一个在一行打印 40 个星号的函数,并在一个打印表头的程序中使用该函数。
#include <stdio.h>
#define NAME "GIGATHINK, INC."
#define ADDRESS "101 Megabuck Plaza"
#define PLACE "Megapolis, CA94904"
#define WIDTH 40
void starbar(void); /* 函数原型 */
int main(void)
{
starbar();
printf("%s\n", NAME);
printf("%s\n", ADDRESS);
printf("%s\n", PLACE);
starbar(); /* 使用函数 */
return 0;
}
void starbar(void) /* 定义函数 */
{
int count;
for(count = 1; count <= WIDTH; count++)
putchar('*');
putchar('\n');
}
该程序的输出如下:
****************************************
GIGATHINK, INC.
101 Megabuck Plaza
Megapolis, CA94904
****************************************
9.1.2 分析程序
该程序要注意以下几点。
- 程序在 3 处使用了 starbar 标识符:函数原型(function prototype)告诉编译器函数 startbar() 的类型:函数调用(function call)表明在此处执行函数:函数定义(function definition)明确地指定了函数要做什么。
- 函数和变量一样,有多种类型。任何程序在使用函数之前都要声明该函数的类型。因此,在 main() 函数定义的前面出现了下面的 ANSI C 风格的函数原型:
void starbar(void);
圆括号表明 starbar 是一个函数名。第 1 个 void 是函数类型,void 类型表明函数没有返回值。第 2 个 void(在圆括号中)表明该函数不带参数。分号表明这是在声明函数,不是定义函数。也就是说,这行声明了程序将使用一个名为 starbar()、没有返回值、没有参数的函数,并告诉编译器在别处查找该函数的定义。对于不识别 ANSI C 风格原型的编译器,只需声明函数的类型,如下所示:void starbar(void);
注意,一些老版本的编译器甚至连 void 都识别不了。如果使用这种编译器,就要把没有返回值的函数声明为 int 类型。当然,最好还是换一个新的编译器。 - 一般而言,函数原型指明了函数的返回值类型和函数接收的参数类型。这些信息称为该函数的签名(signature)。对于 starbar() 函数而言,其签名是该函数没有返回值,没有参数。
- 程序把 starbar() 原型置于 main() 的前面。当然,也可以放在 main() 里面的声明变量处。放在哪个位置都可以。
- 在 main() 中,执行到下面的语句时调用了 starbar() 函数:starbar(); 这是调用 void 类型函数的一种形式。当计算机执行到 starbar(); 语句时,会找到该函数的定义并执行其中的内容。执行完 starbar() 中的代码后,计算机返回主调函数(calling function)继续执行下一行。
- 程序中 starbar() 和 main() 的定义形式相同。首先函数头包含函数类型、函数名和圆括号,接着是左花括号、变量声明、函数表达式语句,最后以右花括号结束。注意,函数头中的 starbar() 后面没有分号,告诉编译器这是定义 starbar(),而不是调用函数或声明函数原型。
- 程序把 starbar() 和 main() 放在一个文件中。当然,也可以把它们分别放在两个文件中。把函数都放在一个文件中的单文件形式比较容易编译,而使用多个文件方便在不同的程序中使用同一个函数。如果把函数放在一个单独的文件中,要把 #define 和 #include 指令也放入该文件。我们稍后会讨论使用多个文件的情况。现在,先把所有的函数都放在一个文件中。main() 的右花括号告诉编译器该函数结束的位置,后面的 starbar() 函数头告诉编译器 starbar() 是一个函数。
- starbar() 函数中的变量 count 是局部变量(local variable),意思是该变量只属于 starbar() 函数。可以在程序中的其他地方。
如果把 starbar() 看做是一个黑盒,那么它的行为是打印一行星号。不用给该函数提供任何输入,因为刁颖它不需要其他信息。而且,它没有返回值,所有也不给 main() 提供(或返回)任何信息。简而言之,starbar() 不需要与主调函数通信。
9.1.3 定义带形式参数的函数
函数定义从下面的 ANSI C 风格的函数头开始:void show_n_char(char ch, int num);
该行告知编译器 void show_n_char() 使用两个参数 ch 和 num,ch 是char 类型,num 是 int 类型。这两个变量被称为形式参数(formal parameter),简称形参。和定义在函数中变量一样,形式参数也是局部变量,属该函数私有。这意味着在其他函数中使用同名变量不会引起名称冲突。每次调用函数,就会给这些变量赋值。
注意,ANSI C 要求在每个变量前都声明其类型。也就是说,不能像普通变量声明那样使用同一类型的变量列表:
void dibs(int x, y, z) /* 无效的函数头 */
void dubs(int x, int y, int z) /* 有效的函数头 */
ANSI C 也接受 ANSI C 之前的形式,但是将其视为废弃不用的形式:
void show_n_char(ch, num)
char ch;
int num;
这里,圆括号只有参数名列表,而参数的类型在后面声明。注意,普通的局部变量在左花括号之后声明,而上面的变量在函数左花括号之前声明。如果变量是同一类型,这种形式可以用逗号分隔变量名列表,如下所示:
void dibs(x, y, z)
int x, y, z; /* 有效 */
当前的标准正逐渐淘汰 ANSI 之前的形式。读者对应此有所了解,以便能看懂以前编写的程序,但是自己编写程序时应使用现在的标准形式。
虽然 void show_n_char() 接受来自 main() 的值,但是它没有返回值。因此,void show_n_char() 的类型是 void。
9.1.4 声明带形式参数函数的原型
在使用函数之前,要用 ANSI C 形式声明函数原型:void show_n_char(char ch, int num);
当函数接收参数时,函数原型用逗号分割的列表指明参数的数量和类型。根据个人喜好,你也可以省略变量名:void show_n_char(char, int);
。在原型中欧使用变量名并没有实际创建变量,char 仅代表一个 char 类型的变量,以此类推。
再次提醒读者注意,ANSI C 也接受过去的声明函数形式,即圆括号内没有参数列表:void show_n_char();
这种形式最终会从标准中剔除。即使没有别剔除,现在函数原型的设计也更有优势。了解这种形式的写法是为了以后读得懂以前的代码。
9.1.5 调用带实际参数的函数
在函数调用中,实际参数(actual argument,简称实参)提供了 ch 和 num 的值。形式参数时被调函数(called function)中的变量,实际参数时主调函数(calling function)赋给被调函数的具体值。实际参数可以是常量、变量,或甚至是更复杂的表达式。无论实际参数是何种形式都被要求值,然后该值被拷贝给被调函数相应的形式参数。
注意 时间参数和形式参数
实际参数是出现在函数用圆括号中的表达式。形式参数是函数定义的函数头中声明的变量。调用函数时,创建了声明为形式参数的变量并初始化为实际参数的求值结果。
9.1.6 黑盒视角
从黑盒的视角看 void show_n_char(),待显示的字符和显示的次数是输入。执行后的结果是打印指定数量的字符。输入以参数的形式被传递给函数。这些信息清楚地表明了如何在 main() 中使用该函数。而且,这也可以作为编写该函数的设计说明。
黑盒方法的核心部分是:ch、num 和 count 都是 void show_n_char() 私有的局部变量。如果在 main() 中使用同名变量,那么它们相互独立,互不影响。也就是说,如果 main() 有一个 count 变量,那么改变他的值不会改变 void show_n_char() 中的 count,反之亦然。黑盒里发生了什么对主调函数是不可见的。
9.1.7 使用 return 从函数中返回值
前面介绍了如何把信息从主调函数传递给被调函数。反过来,函数的返回值可以把信息从被调函数传回主调函数。为进一步说明,我们将创建一个返回两个参数中较小值的函数。由于函数被设计用来处理 int 类型的值,所以被命名为 imin()。另外,还有创建一个简单的 main(),用于检查 imin() 是否正常工作。这种被设计用于测试函数的程序有时被称为驱动程序(driver),该驱动程序调用一个函数。如果函数成功通过了测试,就可以安装在一个更重要的程序中使用。程序清单演示了这个驱动程序和返回最小值的函数。
/* 找出两个整数中较小的一个 */
#include <stdio.h>
int imin(int,int);
int main(void)
{
int evil1,evil2;
printf("Enter a pair of integers (q to quit):\n");
while(scanf("%d %d",&evil1,&evil2) == 2)
{
printf("The lesser of %d and %d is %d.\n",evil1,evil2,imin(evil1,evil2));
printf("Enter a pair of integers (q to quit):\n");
}
printf("Bye.\n");
return 0;
}
int imin(int n,int m)
{
int min;
if(n < m)
min = n;
else
min = m;
return min;
}
程序运行示例:
Enter a pair of integers (q to quit):
509 333
The lesser of 509 and 333 is 333.
Enter a pair of integers (q to quit):
-9393 6
The lesser of -9393 and 6 is -9393.
Enter a pair of integers (q to quit):
q
Bye.
关键字 return 后面的表达式的值就是函数的返回值。在该例中,该函数返回的值就是变量 min 的值。变量 min 属于 imin() 函数私有,但是 return 语句把 min 的值传回了主调函数。
许多 C 程序员都认为只在函数末尾使用一次 return 语句比较好,因为这样做更方便浏览程序的人理解函数的控制流。但是,在函数中使用多个 return 语句也没有错。
另外,还可以这样使用 return:return;
这条语句会导致终止函数,并把控制返回给主调函数。因为 return 后面没有任何表达式,所以没有返回值,只有在 void 函数中才会用到这种形式。
9.1.8 函数类型
声明函数时必须声明函数的类型。带返回值的函数类型应该与其返回值类型相同,而没有返回值的函数应声明为 void 类型。类型声明是函数定义的一部分。要记住,函数类型指的是返回值的类型,不是函数参数的类型。
ANSI C 标准库中,函数被分成多个系列,每一系列都有各自的头文件。这些头文件中除了其他内容,还包含了本系列所有函数的声明。
9.2 递归
C 允许函数调用它自己,这种调用过程称为递归(recursion)。递归有时难以捉摸,有时却很方便实用。结束递归是使用递归的难点,因为如果递归代码中没有终止递归的条件测试部分,一个调用自己的函数会无限递归。
可以使用循环的地方通常都可以使用递归。有时用循环解决问题比较好,但有时用递归更好。递归方案更简洁,但效率却没有循环高。
9.2.1 演示递归
我们通过一个程序示例,来学习什么是递归。程序清单中的 main() 函数调用 up_and_down() 函数,这次调用称为“第 1 级递归”。然后 up_and_down() 调用自己,这次调用称为“第 2 级递归”。接着第 2 级递归调用第 3 级递归,以此类推。该程序示例共有 4 级递归。为了进一步深入研究递归时发生了什么,程序不仅显示了变量 n 的值,还显示了储存 n 的内存地址 &n(本章稍后会详细讨论 & 运算符,printf() 函数使用 %p 转换说明打印地址,如果你得系统不支持这种格式,请使用 %u 或 %lu 代替 %p)。
/* 递归演示 */
#include <stdio.h>
void up_and_down(int);
int main(void)
{
up_and_down(1);
return 0;
}
void up_and_down(int n)
{
printf("Level %d: n location %p\n", n, &n); // #1
if(n < 4)
up_and_down(n + 1);
printf("Level %d: n location %p\n", n, &n); // #2
}
下面是在我们系统中的输出:
Level 1: n location 0060FEF0
Level 2: n location 0060FED0
Level 3: n location 0060FEB0
Level 4: n location 0060FE90
Level 4: n location 0060FE90
Level 3: n location 0060FEB0
Level 2: n location 0060FED0
Level 1: n location 0060FEF0
9.2.2 递归的基本原理
初次接触递归会觉得较难理解。为了帮助读者理解递归过程,下面以程序清单为例讲解几个要点。
第 1,每级函数调用都有自己的变量。也就是说,第 1 级的 n 和第 2 级的 n 不同,所以程序创建了 4 个单独的变量,每个变量名都是 n,但是它们的值各不形同。当程序最终返回 up_and_down() 的第 1 级调用时,最初的 n 仍然是它的初值 1。
第 2,每次函数调用都会返回一次。当函数执行完毕后,控制权将被传回上一级递归。程序必须按顺序逐级返回递归,从某级 up_and_down() 返回上一级的 up_and_down(),不能跳级回到 main() 中的第 1 级调用。
第 3,递归函数中位于递归调用之前的语句,均按被调函数的顺序执行。例如,程序清单中的打印语句 #1 位于递归调用之前,它按照递归的顺序:第 1 级、第 2 级、第 3 级和第 4 级,被执行了 4次。
第 4,递归函数中位于递归调用之后的语句,均按被调函数相反的顺序执行。例如,打印语句 #2 位于递归调用之后,其执行的顺序是第 4 级、第 3 级、第 2 级、第 1 级。递归调用的这种特性在解决涉及相反顺序的编程问题时很有用。
第 5,虽然每级递归都有自己的变量,但是并没有拷贝函数的代码。程序按顺序执行函数中的代码,而递归调用就相当于又从头开始执行函数的代码。除了为每次递归调用创建变量外,递归调用非常类似于一个循环语句。实际上,递归有时可用循环来代替,循环有时也能用递归来代替。
最后,递归函数必须包含能让递归调用停止的语句。通常,递归函数都使用 if 或其他等价的测试条件在函数形参等于某特定值时终止递归。为此,每次递归调用的形参都要使用不同的值。例如,程序清单中的 up_and_down(n) 调用 up_and_down(n + 1)。最终,实际参数等于 4 时,if 的测试条件(n < 4)为假。
9.2.3 尾递归
最简单的递归形式是把递归调用置于函数的末尾,即正好在 return 语句之前。这种形式的递归被称为尾递归(tail recursion),因为递归调用在函数的末尾。尾递归是最简单的递归形式,因为它相当于循环。
下面要介绍的程序示例中,分别用循环和尾递归计算阶乘。一个正整数的阶乘(factorial)是从 1 到该整数的所有整数的乘积。如下程序清单,第 1 个函数使用 for 循环计算阶乘,第 2 个函数使用递归计算阶乘。
/* 使用循环和递归计算阶乘 */
#include <stdio.h>
long fact(int n);
long rfact(int n);
int main(void)
{
int num;
printf("This program calculates factorials.\n");
printf("Enter a value in the range 0 - 12 (q to quit):\n");
while(scanf("%d",&num) == 1)
{
if(num < 0)
printf("No negative numbers, please.\n");
else if(num > 12)
printf("Keep input under 13.\n");
else
{
printf("loop: %d factorial = %ld\n", num, fact(num));
printf("recursion: %d factorial = %ld\n", num, rfact(num));
}
printf("Enter a value in the range 0 - 12 (q to quit):\n");
}
printf("Bye.\n");
return 0;
}
long fact(int n) // 使用循环的函数
{
long ans;
for(ans = 1; n > 1; n++)
ans *= n;
return ans;
}
long rfact(int n) // 使用递归的函数
{
long ans;
if(n > 0)
ans = n * rfact(n - 1);
else
ans = 1;
return ans;
}
测试驱动程序把输入限制在 0 ~ 12。因为 12! 已快接近 5 亿,而 13! 比 62亿还大,已超过我们系统中 long 类型能表示的范围。要计算超过 12 的阶乘,必须使用能表示更大范围的类型,如 double 或 long long。
下面是该程序的运行示例:
This program calculates factorials.
Enter a value in the range 0 - 12 (q to quit):
5
loop: 5 factorial = 0
recursion: 5 factorial = 120
Enter a value in the range 0 - 12 (q to quit):
10
loop: 10 factorial = 0
recursion: 10 factorial = 3628800
Enter a value in the range 0 - 12 (q to quit):
q
Bye.
使用循环的函数把 ans 初始化为 1,然后把 ans 与从 n ~ 2 的所有递减整数相乘。根据阶乘的公式,还应该乘以 1,但是这并不会改变结果。
现在考虑使用递归的函数。该函数的关键是 n! = n x (n - 1)!。可以这样做是因为 (n - 1)! 是 n - 1 ~ 1的所有正整数的乘积。因此,n 乘以 n - 1 就得到 n 的阶乘。阶乘的这一特性很适合使用递归。如果调用函数 rfact(),rfact(n) 是 n * rfact(n - 1)。因此,通过调用 rfact(n - 1) 来计算 rfact(n),如程序清单。当然,必须要在满足某条件是结束递归,可以在 n 等于 0 时把返回值设为 1。
程序清单中使用递归的输出和使用循环的输出相同。注意,虽然 rfact() 的递归调用不是函数的最后一行,但是当 n > 0 时,它是该函数执行的最后一条语句,因此它也是尾递归。
既然用递归和循环来计算都没问题,那么到底应该使用哪一个?一般而言,选择循环比较好。首先,每次递归都会创建一组变量,所以递归使用的内存更多,而且每次递归调用都会创建的一组新变量放在栈中。递归调用的数量受限于内存空间。其次,由于每次函数调用要花费一定的时间,所以递归的执行速度较慢。那么,演示这个程序示例的目的是什么?因为尾递归是递归中最简单的形式,比较容易理解。在某些情况下,不能用简单的循环代替递归,因此读者还是要好好理解递归。
9.2.4 递归的优缺点
递归既有优点也有缺点。优点是递归为某些编程问题提供了最简单的解决方案。缺点是一些递归算法会快速消耗计算机的内存资源。另外,递归不方便阅读和维护。我们用一个例子来说明递归的优缺点。我们要创建一个函数,接受正整数 n,返回相应的斐波那锲数值。
首先,来看递归。递归提供一个简单的定义。如果把函数命名为 Fibonacci(),那么如果 n 是 1 或 2,Fibonacci(n) 应返回 1:对于其他数值,则应返回 Fibonacci(n - 1) + Fibonacci(n - 2):
unsigned long Fibonacci(unsigned n)
{
if(n > 2)
return Fibonacci(n - 1) + Fibonacci(n - 2);
else
return 1;
}
这个递归函数只是重述了数学定义的递归。该函数使用了双递归(double recursion),即函数每一级递归都要调用本身两次。这暴露了一个问题。
为了说明这个问题,假设调用 Fibonacci(40)。这是第 1 级递归调用,将创建一个变量 n。然后在该函数中要调用 Fibonacci() 两次,在第 2 级递归中要分别创建两个变量 n。这两次调用中的每次调用又会进行两次调用,因而在第 3 级递归中要创建 4 个名为 n 的变量。此时总共创建了 7 个变量。由于每级递归创建的变量都是上一级递归的两倍,所以变量的数量呈指数增长!在第 5 章中介绍过一个计算小麦粒数的例子,按指数增长很快就会产生非常大的值。在本例中,指数增长的变量数量很快就消耗计算机的大量内存,很可能导致程序崩溃。
虽然这是个极端的例子,但是该例说明:在程序中使用递归要特别注意,尤其是效率优先的程序。
所以的 C 函数皆平等
程序中的每个 C 函数与其他函数都是平等的。每个函数都可以调用其他函数,或被其他函数调用。这点与 Pascal 和 Modula-2 中的过程不同,虽然过程可以嵌套在另一个过程中,但是嵌套在不同过程中的过程之间不能相互调用。
main() 函数是否与其他函数不同?是的,main() 的确有点特殊。当 main() 与程序中的其他函数放在一起时,最开始执行的是 main() 函数中的第 1 条语句,但是这也是局限之处。main() 也可以被自己或其他函数递归调用——尽管很少这样做。
9.3 编译源代码文件的程序
使用多个函数最简单的方法是把它们都放在同一个文件中,然后像编译只有一个函数的文件那样编译该文件即可。其他方法因操作系统而异。
9.3.1 UNIX
假定在 UNIX 系统中安装了 UNIX C 编译器 cc。假设 file1.c 和 file2.c 是两个内含 C 函数的文件,下面的命令将编译两个文并生成一个名为 a.out 的可执行文件:cc file1.c file2.c
另外,还生成两个名为 file1.o 和 file2.o 的目标文件。如果后来改动了 file1.c,而 file2.c 不变,可以使用一下命令编译第 1 个文件,并与第 2 个文件的目标代码合并:cc file1.c file2.o
UNIX 系统的 make 命令可自动管理多文件程序,但是这超出了本书的讨论范围。
注意,OS X 的 Terminal 工具可以打开 UNIX 命令行环境,但是必须先下载命令行编译器。
9.3.2 Linux
假定 Linux 系统安装了 GNU C 编译器 GCC。假设 file1.c 和 file2.c 是两个内含 C 函数的文件,下面的命令将编译两个文件并生成名为 a.out 的可执行文件:gcc file1.c file2.c
另外,还生成两个名为 file1.o 和 file2.o 的目标文件。如果后来改动了 file1.c,而 file2.c 不变,可以使用一下命令编译第 1 个文件,并与第 2 个文件的目标代码合并:gcc file1.c file2.o
9.3.3 DOS 命令行编译器
绝大对数 DOS 命令行编译器的工作原理和 UNIX 的 cc 命令类似,只不过使用不同的名称而已。其中一个区别是,对象文件的扩展名是 .obj,而不是 .o。一些编译器生成的不是目标代码文件,而是汇编语言或其他特殊代码的中间文件。
9.3.4 Windows 和苹果的 IDE 编译器
Windows 和 Macintosh 系统使用的集成开发环境中的编译器是面向项目的。项目(project)描述的是特定程序使用的资源。资源包括源代码文件。这种 IDE 中的编译器要创建项目来运行单文件程序。对于多文件程序,要使用相应的菜单命令,把源代码文件加入一个项目中。要确保所有的源代码文件都在项目列表中列出。许多 IDE 都不用在项目列表中列出头文件,因为项目只管理使用的源代码文件,源代码文件中的 #include 指令管理该文件中使用的头文件。但是,Xcode 要在项目中添加头文件。
9.3.5 使用头文件
如果把 main() 放在第 1 个文件中,把函数定义放在第 2 个文件中,那么第 1 个文件仍然要使用函数原型。把函数原型放在头文件中,就不用在每次使用函数文件时都写出函数的原型。C 标准库就是这样做的,例如,把 I/O 函数原型放在 stdio.h 中,把数学函数原型放在 math.h 中。你也可以这样用自定义的函数文件。
另外,程序中经常用 C 预处理器定义符号常量。这种定义只储存了哪些包含 #define 指令的文件。如果把程序的一个函数放进一个独立的文件中,你也可以使用 #define 指令访问每个文件。最直接的方法是在每个文件中再次输入指令,但是这个方法既耗时又容易出错。另外,还会有维护的问题:如果修改了 #define 定义的值,就必须在每个文件中修改。更好的做法是,把 #define 指令放进头文件,然后在每个源文件中使用 #include 指令包含该文件即可。
总之,把函数原型和已定义的字符常量放在头文件中是一个良好的编程习惯。我们考虑一个例子:假设要管理 4 家酒店的客房服务,每家酒店的房价不同,但是每家酒店所有房间的房价相同。对应预订住宿多天的客户,第 2 天的房费是第 1 天的 95%,第 3 天是第 2 天的 95%,以此类推。设计一个程序让用户指点酒店和入住天数,然后计算并显示总费用。同时,程序要实现一份菜单,允许用户反复输入数据,除非用户选择退出。
程序清单 9.3.5.1、9.3.5.2、9.3.5.3 演示了如何编写这样的程序。第 1 个程序 清单包含 main() 函数,提供整个程序的组织结构。第 2 个程序清单包含支持的函数,我们假设这些函数在独立的文件中。最后,第 3 个程序清单列出了一个头文件,包含了该程序所用源文件中使用的自定义符号常量和函数原型。前面介绍过,在 UNIX 和 DOS 环境中,#include “hotels.h” 指令中的双引号表明被包含的文件位于当前目录中。如果使用 IDE,需要知道如何把头文件合并成一个项目。
** 程序清单 9.3.5.1**
/** 9.3.5.1 -- 房间费率程序 */
#include <stdio.h>
#include "hotel.h" /* 定义符号常量,声明函数 */
int main()
{
int nights;
double hotel_rate;
int code;
while((code = menu()) != QUIT)
{
switch(code)
{
case 1: hotel_rate = HOTEL1;
break;
case 2: hotel_rate = HOTEL2;
break;
case 3: hotel_rate = HOTEL3;
break;
case 4: hotel_rate = HOTEL4;
break;
default: hotel_rate = 0.0;
printf("Oops!\n");
break;
}
nights = getnights();
showprice(hotel_rate,nights);
}
printf("Thank you and goodbye.\n");
return 0;
}
** 程序清单 9.3.5.2**
/** 9.3.5.2 -- 酒店管理函数 */
#include <stdio.h>
#include "hotel.h"
int menu(void)
{
int code, status;
printf("\n%s%s\n", STARS, STARS);
printf("Enter the number of the desired hotel:\n");
printf("1)Fairfield Arms 2)Hotel Olympic\n");
printf("3)Chertworthy Plaza 4)The Stockton\n");
printf("5)quit\n");
printf("%s%s\n", STARS, STARS);
while((status = scanf("%d", &code)) != 1 || (code < 1 || code > 5))
{
if(status != 1)
scanf("%*s"); // 处理非整数输入
printf("Enter an integer from 1 to 5, please.\n");
}
return code;
}
int getnights(void)
{
int nights;
printf("How many nights are needed? ");
while(scanf("%d", &nights) != 1)
{
scanf("%*s"); // 处理非整数输入
printf("Please enter an integer, such as 2.\n");
}
return nights;
}
void showprice(double rate, int nights)
{
int n;
double total = 0.0;
double factor = 1.0;
for(n = 1; n <= nights; n++, factor *= DISCOUNT)
total += rate * factor;
printf("The total cost will be $%0.2f.\n", total);
}
** 程序清单 9.3.5.3**
#ifndef HOTEL_H_INCLUDED
#define HOTEL_H_INCLUDED
#define QUIT 5
#define HOTEL1 180.00
#define HOTEL2 225.00
#define HOTEL3 225.00
#define HOTEL4 355.00
#define DISCOUNT 0.95
#define STARS "**********************************"
// 显示选择列表
int menu(void);
// 返回预订天数
int getnights(void);
// 根据费率、入住天数计算费用,并显示结果
void showprice(double rate, int nights);
#endif // HOTEL_H_INCLUDED
下面是这个多文件程序的运行示例:
》********************************************************************
Enter the number of the desired hotel:
1)Fairfield Arms 2)Hotel Olympic
3)Chertworthy Plaza 4)The Stockton
5)quit
》********************************************************************
3
How many nights are needed? 1
The total cost will be $225.00.
》********************************************************************
Enter the number of the desired hotel:
1)Fairfield Arms 2)Hotel Olympic
3)Chertworthy Plaza 4)The Stockton
5)quit
》********************************************************************
4
How many nights are needed? 3
The total cost will be $1012.64.
》********************************************************************
Enter the number of the desired hotel:
1)Fairfield Arms 2)Hotel Olympic
3)Chertworthy Plaza 4)The Stockton
5)quit
》********************************************************************
5
Thank you and goodbye.
顺带一提,该程序中有几处编写得很巧妙。尤其是,menu() 和 getnights() 函数通过测试 scanf() 的返回值来跳过非数值数据,而且调用 scanf("%*s") 跳至下一个空白字符。注意,menu() 函数中是如何检查非数值输入和超出范围的数据:(status = scanf("%d", &code)) != 1 || (code < 1 || code > 5)
以上代码段利用了 C 语言的两个规则:从左往右对逻辑表达式求值;一旦求值结果为假,立即停止求值。在该例中,只有在 scanf() 成功读入一个整数值后,才会检查 code 的值。
用不同的函数处理不同的任务是应检查数据的有效性。当然,首次编写 menu() 或 getnights() 函数是可以暂不添加这一功能,只写一个简单的 scanf() 即可。待基本版本运行正常后,再逐步改善各模块。
提醒:这里我用的是 Code Blocks 编译器。
1、创建项目 File > New > Project
2、创建文件注意:File > New > File
标注的地方一定要记得勾选
9.4 查找地址:& 运算符
指针(pointer)是 C 语言最重要的概念之一,用于储存变量的地址。前面使用的 scanf() 函数中就使用地址作为参数。概括地说,如果主调函数不使用 return 返回的值,则必须通过地址才能修改主调函数中的值。接下来,我们将介绍带地址参数的函数。首先介绍一元 & 运算符的用法。
一元 & 运算符给出变量的存储地址。如果 pooh 是变量名,那么 &pooh 是变量的地址。可以把地址看作是变量在内存中的位置。假设有下面的语句:pooh = 24;
假设 pooh 的存储地址是 0B76,那么,下面的语句:printf("%d %p\n", pooh, &pooh);
。将输出如下内容(%p 是输出地址的转换说明):24 0B76
程序清单中使用了这个运算符查看不同函数中的同名变量分别储存在什么位置。
/** 查看变量被储存在何处 */
#include <stdio.h>
void mikado(int bah); /* 函数原型 */
int main()
{
int pooh = 2, bah = 5; /* main() 的局部变量 */
printf("In main(), pooh = %d and &pooh = %p\n", pooh, &pooh);
printf("In main(), bah = %d and &bah = %p\n", bah, &bah);
mikado(pooh);
return 0;
}
void mikado(int bah) /* 定义函数 */
{
int pooh = 10; /* mikado() 的局部变量 */
printf("In mikado(), pooh = %d and &pooh = %p\n", pooh, &pooh);
printf("In mikado(), bah = %d and &bah = %p\n", bah, &bah);
}
程序清单中使用 ANSI C 的 %p 格式打印地址。我的系统输出如下:
In main(), pooh = 2 and &pooh = 0060FEFC
In main(), bah = 5 and &bah = 0060FEF8
In mikado(), pooh = 10 and &pooh = 0060FECC
In mikado(), bah = 2 and &bah = 0060FEE0
实现不同,%p 表示地址的方式也不同。然而,许多实现都如本例所示,以十六进制显示地址。顺带一提,每个十六进制数对应 4 位,该例显示 12 个十六进制数,对应 48 为地址。
该例的输出说明了什么?首先,两个 pooh 的地址不同,两个 bah 的地址也不同。因此,和前面介绍的一样,计算机把它们看成 4 个独立的变量。其次,函数调用 mikado(pooh) 把时间参数的值(2)传递给形式参数。注意,这种传递只传递了值。涉及的两个变量并未改变。
我们强调第 2 点,是因为这并不是在所以语言中都成立。
9.5 指针简介
指针(pointer)是一个值为内存地址的变量(或数据对象)。正如 char 类型变量的值是字符,int 类型变量的值是整数,指针变量的值是地址。在 C 语言中,指针有许多用法。本章将介绍如何把指针作为函数参数使用,以及为何要这样用。
假设一个指针变量名是 ptr,可以编写如下语句:ptr = &pooh; // 把 pooh 的地址赋给 ptr
对于这条语句,我们说 ptr “指向” pooh。ptr 和 &pooh 的区别是 ptr 是变量,而 &pooh 是常量。或者,ptr 是可修改的左值,而 &pooh 是右值。还可以把 ptr 指向别处:ptr = &bah; // 把 ptr 指向 bah,而不是 pooh
现在 ptr 的值是 bah 的地址。
要创建指针变量,先要声明指针变量的类型。假设想把 ptr 声明为储存 int 类型变量地址的指针,就要使用下面介绍的新运算符。
9.5.1 简介运算符:*
假设已知 ptr 指向 bah,如下所示:ptr = &bah;
然后使用间接运算符 * (indirection operator)找出储存在 bah 中的值,该运算符有时也称为解引用运算符(dereferencing operator)。不用把间接运算符和二元乘法运算符(*)混淆,虽然它们使用的符号相同,但语法功能不同。val = *ptr; // 找出 ptr 指向的值
语句 ptr = &bah; 和 val = *ptr; 放在一起相当于下面的语句:val = bah;
由此可见,使用地址和间接运算符可以间接完成上面这条语句的功能,这也是“间接运算符”名称的由来。
小结:与指针相关的运算符
地址运算符:&
一般注解;后跟一个变量名时,& 给出该变量的地址。
示例:&nurse 表示变量 nurse 的地址。
地址运算符:*
一般注解:后跟一个指针名或地址时,* 给出储存在指针指向地址上的值。
示例:nurse = 22; ptr = &nurse; val = *ptr;
执行以上 3 条语句的最终结果是把 22 赋给 val。
9.5.2 声明指针
相信读者已经很熟悉如何声明 int 类型和其他基本类型的变量,那么如何声明指针变量?下面是一些指针的声明示例:
int * pi; // pi 是指向 int 类型变量的指针
char * pc; // pc 是指向 char 类型变量的指针
float * pf, *pg; // pf、pg 都是指向 float 类型变量的指针
类型说明符表明了指针所指向对象的类型,星号(*)表明声明的变量是一个指针。int * pi;
声明的意思是 pi 是一个指针,*pi 是 int 类型。
- 和指针名之间的空格可有可无。通常,在声明时使用空格,在解引用变量是省略空格。
pc 指向的值(*pc)是 char 类型。pc 本身是什么类型?我们描述它的类型是“指向 char 类型的指针”。pc 的值是一个地址,在大部分系统内部,该地址由一个无符号整数表示。但是,不要把指针认为是整数类型。一些处理整数的操作不能用来处理指针,反之亦然。
变量:名称、地址和值
通过前面的讨论发现,变量的名称、地址和变量的值之间关系密切。
编写程序时,可以认为变量有两个属性:名称和值。计算机编译和加载程序后,认为变量也有两个属性:地址和值。地址就是变量在计算机内部的名称。
在许多语言中,地址都归计算机管,对程序员隐藏。然而在 C 中,可以通过 & 运算符访问地址,通过 * 运算符获得地址上的值。
简而言之,普通变量把值作为基本量,把地址作为通过 & 运算符获得的派生量,而指针变量把地址作为基本量,把值作为通过 * 运算符获得的派生量。
虽然打印地址可以满足读者好奇心,但是这并不是 & 运算符的主要用途。更重要的是使用 &、* 和指针可以操纵地址和地址上的内容。
小结:函数
形式:
典型的 ANSI C 函数的定义形式为:
返回类型 名称(形参声明列表)
函数体
形参声明列表是用逗号分隔的一系列变量声明。除形参变量外,函数的其他变量均在函数体的花括号之内声明。
传递值:
实参用于把值从主调函数传递给被调函数。如果变量 a 和 b 的值分别是 5 和 2,那么调用:c = diff(a, b);
把 5 和 2 分别传递给变量 x 和 y。5 和 2 称为实际参数,diff() 函数定义中的变量 x 和 y 称为形式参数。使用关键字 return 把被调函数中的一个值传回主调函数。被调函数一般不会改变主调函数中的变量,如果要改变,应使用指针作为参数。
函数的返回类型:
函数的返回类型指的是函数返回值的类型。如果返回值的类型与声明的返回值类型不匹配,返回值将被转换成函数声明的返回类型。
函数签名:
函数的返回类型和形参列表构成了函数签名。因此,函数签名指定了传入函数的值的类型和函数返回值的类型。
9.6 关键概念
如果想用 C 编出高效灵活的程序,必须理解函数。把大型程序组织成若干函数非常有用,甚至很关键。如果让一个函数处理一个任务,程序会更好理解,更方便调式。要理解函数是如何把信息从一个函数传递到另一函数,也就是说,要理解函数参数和返回值的工作原理。另外,要明白函数形参和其他局部变量都属于函数私有,因此,声明在不同函数中的同名变量是完全不同的变量。而且,函数无法直接访问其他函数中的变量。这种限制访问保护了数据的完整性。但是,当确实需要在函数中访问另一个函数的数据时,可以把指针作为函数的参数。
9.7 本章小结
函数可以作为组成大型程序的构件块。每个函数都应该有一个单独且定义好的功能。使用参数把值传给函数,使用关键字 return 把值返回函数。如果函数返回的值不是 int 类型,则必须在函数定义和函数原型中指定函数的类型。如果需要在被函数中修改主调函数的变量,使用地址或指针作为参数。
ANSI C 提供了一个强大的工具——函数原型,允许编译器验证函数调用中使用的参数个数和类型是否正确。
C 函数可以调用本身,这种调用方式被称为递归。一些编程问题要用递归来解决,但是递归不仅消耗内存多,效率不高,而且费时。
9.8 复习题
1、实际参数和形式参数的区别是什么?
2、根据下面各函数的描述,分别编写它们的 ANSI C 函数头。注意,只需写出函数头,不用写函数体。
a、donut() 接受一个 int 类型的参数,打印若干(参数指定数目)个 0;
b、gear() 接受两个 int 类型的参数,返回 int 类型的值;
c、guess() 不接受参数,返回一个 int 类型的值;
d、stuff_it() 接受一个 double 类型的值和 double 类型变量的地址,把第 1 个值储存在指定位置。
3、根据下面各函数的描述,分别编写它们的 ANSI C 函数头。注意,只需写出函数头,不用写函数体。
a、n_to_char() 接受一个 int 类型的参数,返回一个 char 类型的值;
b、digit() 接受一个 double 类型的参数和一个 int 类型的参数,返回一个 int 类型的值;
c、which() 接受两个可储存 double 类型变量的地址,返回一个 double 类型的地址;
d、random() 不接受参数,返回一个 int 类型的值。
4、设计一个函数,返回两整数之和
5、如果把复习题 4 改成返回两个 double 类型的值之和,应如何修改函数?
6、设计一个名为 alter() 的函数,接受两个 int 类型的变量 x 和 y,把它们的值分别改成两个变量之和以及两变量之差。
7、下面的函数定义是否正确?
void salami(num)
{
int num, count;
for(count = 1; count <= num; num++)
printf(" O salami mio!\n");
}
8、编写一个函数,返回 3 个整数参数中的最大值。
9、给定下面的输出:
Please choose one of the following:
1)copy files 2)move files
3)remove files 4)quit
Enter the number of your choice:
a、编写一个函数,显示一份有 4 个选项的菜单,提示用户进行选择(输出如上所示)。
b、编写一个函数,接受两个 int 类型的参数分别表示上限和下限。该函数从用户的输入中读取整数。如果整数超出规定上下限,函数再次打印菜单提示用户输入,然后获取一个新值。如果用户输入的整数在规定范围内,该函数则把该整数返回主调函数。如果用户输入一个非整数字符,该函数应返回 4。
c、使用本题 a 和 b 部分的函数编写一个最小型的程序。最小型的意思是,该程序不需要实现菜单中各选项的功能,只需显示这些选项并获取有效的响应即可。
9.9 编程练习
1、设计一个函数 min(x, y),返回两个 double 类型值的较小值。在一个简单的驱动程序中测试该函数。
2、设计一个函数 chline(ch, i, j),打印指定的字符 j 行 i 列。在一个简单的驱动程序中测试该函数。
3、编写一个函数,接受 3 个参数:一个字符和两个整数。字符参数是待打印的字符,第 1 个整数指定一行中打印字符的次数,第 2 个整数指定打印指定字符的行数。编写一个调用该函数的程序。
4、两数的调和平均数这样计算:先得到两数的倒数,然后计算两个倒数的平均值,最后取计算结果的倒数。编写一个函数,接受两个 double 类型的参数,返回这两个参数的调和平均数。
5、编写并测试一个函数 larger_of(),该函数把两个 double 类型变量的值替换为较大的值。例如,larger_of(x, y) 会把 x 和 y 中较大的值重新赋给两个变量。
6、编写并测试一个函数,该函数以 3 个 double 变量的地址作为参数,把最小值放入第 1 个函数,中间值放入第 2 个变量,最大值放入第 3 个变量。
7、编写一个函数,从标准输入中读取字符,知道遇到文件结尾。程序要报告每个字符是否是字母。如果是,还要报告该字母在字母表中的数值位置。例如,c 和 C 在字母表中的位置都是 3。合并一个函数,以一个字符作为参数,如果该字符是一个字母则返回一个数值位置,否则返回 -1。
8、如程序清单中,power() 函数返回一个 double 类型数的正整数次幂。改进该函数,使其能正确计算负幂。另外,函数要处理 0 的任何次幂都为 0,任何数的 0 次幂都为 1。要使用一个循环,并在程序中测试该函数。
/** 计算数的整数幂 */
#include <stdio.h>
double power(double n, int p);
int main()
{
double x, xpow;
int exp;
printf("Enter a number and the positive integer power");
printf(" to which\nthe number will be raised. Enter q");
printf(" to quit.\n");
while(scanf("%lf%d", &x, &exp) == 2)
{
xpow = power(x, exp);
printf("%.3g to the power %d is %.5g\n", x, exp, xpow);
printf("Enter next pair of number or q to quit.\n");
}
printf("Hope you enjoyed this power trip -- bye!\n");
return 0;
}
double power(double n, int p)
{
double pow = 1;
int i;
for(i = 1; i <= p; i++)
pow *= n;
return pow;
}
9、使用递归函数重写编程练习 8。
10、为了让程序清单中的 to_binary() 函数更通用,编写一个 to_base_n() 函数接收两个在 2 ~ 10 范围内的参数,然后以第 2 个参数中指点的进制打印第 1 个参数的数值。例如,to_base_n(129, 8) 显示的结果为 201,也就是 129 的八进制数。在一个完整的程序中测试该函数。
/** 以二进制形式打印整数 */
#include <stdio.h>
void to_binary(unsigned long n);
int main()
{
unsigned long number;
printf("Enter an integer (q to quit):\n");
while(scanf("%lu", &number) == 1)
{
printf("Binary equivalent: ");
to_binary(number);
putchar('\n');
printf("Enter an integer (q to quit):\n");
}
printf("Done.\n");
return 0;
}
void to_binary(unsigned long n)
{
int r;
r = n % 2;
if(n >= 2)
to_binary(n / 2);
putchar(r == 0 ? '0' : '1');
return;
}
11、编写并测试 Fibonacci() 函数,该函数用循环代替递归计算斐波那锲数。