第6章 函数
本章重点
· 函数的定义和声明
· 函数调用
· 变量的作用域
· 递归
在C语言程序中,功能大多是依靠定义函数来实现的。在前面的章节中已经接触过函数的概念,例如程序的主函数main()、标准输出函数printf()、标准输入函数scanf()等。本章将进对函数的定义、调用、内部函数与外部函数的区别、变量的作用域等知识点逐一讲解,并在最后讲解递归的概念及用法。
什么是函数
大家在学习数学时都接触过函数的概念,所谓函数就是对自变量(输入)和因变量(输出)之间关系的一种描述。在C语言中,函数是指用来实现一个或多个特定功能的代码块。有的函数可以进行某种数学运算,例如求绝对值的函数abs();有的函数则会对程序的状态造成特定的影响,例如要求用户输入数据(函数scanf())、在屏幕上显示结果(函数printf())等;还有的函数可以完成一些更加复杂的操作。例如一个打印的功能就可以设计为一个函数,具体示例如下:
void foo()
{
printf("这是我的第一个函数!\n");
}
在上面的示例代码中主要包含三个组成部分,具体如下:
· void():表示这个函数没有返回值
· foo():函数名称,该名称可以自己定义
· 函数体:大括号中的内容就是这个函数的函数体,也叫做这个函数的定义或实现。
这些代码是函数真正要做的事情。函数foo()的函数体中只有一行代码,就是通过printf()函数输出一行字"这是我的第一个函数!"。
Foo()函数实现了打印数据的功能,在程序中通常会在main()函数中调用foo()函数以实现最终效果,具体如例6-1所示。
例程6-1 在主函数中调用子函数foo()
#include <; void foo() { printf("这是我的第一个函数!\n"); } int main() { foo(); }
运行结果如图6-1所示。
图1-1 运行结果
程序的执行流程如图6-2所示,粗黑线表示程序执行流。
图1-2 主函数中调用子函数foo()的流程图
从上图中可以看到程序是顺序执行的,首先从主函数开始执行,遇到"foo();"语句后则调到foo()函数的函数体继续执行,在执行完foo函数后才会回到刚刚的调用点(位于主函数中的"foo();"语句)之后,接着执行调用点后面的其它语句(本例中为空,因此主函数就结束了)。
例6-2中的函数multiply10()则实现了接收用户输入、并进行处理——将用户输入的数乘以10、并将结果显示到屏幕上的功能。
例程6-2 "乘十"函数
#include <; void multiply10() { int data; scanf("%d", &data); printf("%d * 10 = %d\n", data, data * 10); } int main() { multiply10(); }
运行结果如图6-3所示。
图1-3 运行结果
在例 6-3中,定义了一个函数multiply10(),并在main()函数中调用了它。在函数multiply10()中,首先声明一个int型变量data,并通过scanf()函数从键盘对它进行赋值,最后我们通过格式输出函数printf()把变量data的值乘以十的结果打印到显示器。所以在程序运行之后首先我们输入了一个整数"64",之后程序给我们输出了一个结果:对64进行乘10运算的值。
在C语言中对函数有这样的描述:函数是用于完成特定任务的程序代码的自包含单元。什么是"自包含单元"呢?它是指编写的函数能做到"一次编写,到处拷贝,终身使用":某个函数不会因为被放在不同的程序里,或者被放在程序的不同位置而有任何执行上的区别。
函数作为C语言程序的基本构成单元,发挥着至关重要的作用,主要包括以下两个方面:
· 使用函数可以避免重复编写相同的代码。如果程序中需要用到某个特定功能,只要将该功能实现在一个函数中即可。
· 函数可以使整个程序模块化,有利于程序的阅读、修改和排错。函数名往往可以阐明一个函数的作用,所以有时候就算某些代码只会被使用一次,也要将其放在一个函数中。
函数的定义
经过上一节的学习,大家对函数的概念已经有了一定的了解。在程序中要想调用函数,首先需要定义函数。接下来,本节将针对如何定义函数进行详细讲解。
6.1.1 函数定义的形式
在C语言中,函数分为无参数函数和有参数函数两种,这两个函数在定义时有所不同,具体如下:
1、无参数函数
所有无参函数就是指函数中不包含任何参数,在定义时只需写清楚函数名、返回值类型和函数体即可,具体语法格式如下:
返回值类型 函数名() { 变量声明A 变量声明B … 语句A 语句B … }
对上述语法格式的具体说明如下:
· 返回值类型:指明了该函数返回值的类型。如果无参函数没有返回值,那么返回值类型就是void。
· 函数名:是由用户定义的标识符,函数名后有一个空括号,用来存储参数列表。对于无参数函数而言,括号中中无参数,但外面的括号是不可以省略的。
· 函数体:C语言的函数体(花括号中的内容为函数体)由两部分组成,分别是声明和语句。C89标准要求所有的声明都位于函数体的开头,声明不能和语句部分交叉。具体示例如下:
例程6-3 错误的函数体(声明与语句交叉)
void foo() { int a; a = 1; int b; b = 2; return; }
在上面的示例代码中,由于第三行声明了变量a,第四行是对其进行赋值的语句,接着又出现了声明了变量b的代码,这就出现了声明和语句交叉的情况,所以编译出错。
上述例程必须进行调整才可以正常通过编译,具体代码如例6-4所示。
例程6-4 修正后的函数体
void foo() { int a; int b; a = 1; /* 交换了第四行和第五行 */ b = 2; return; }
在例程6-4中,第七行的return意味着函数运行流程的终结。程序执行时。一旦遇到return语句,立即结束执行并返回调用点。由于这个函数并没有返回值,第七行的return语句是可以省略的。省略return语句后的代码如例6-6所示。
例程6-5 省略return语句后的函数
void foo() { int a; int b; a = 1; b = 2; }
2、有参数函数
与无参数函数相比,有参数函数需要使用在函数定义首行的括号中填写参数列表。有参数函数的定义方法如下所示:
返回值类型 函数名(形式参数列表)
{
变量声明A
变量声明B
…
语句A
语句B
…
}
形式参数列表:决定了该函数的参数。"参数"是指该函数开始执行时就可以使用、并被调用者赋值了的一系列变量,它可以是任何类型的变量,每个参数之间是用逗号分隔。这里的参数之所以被称为"形式参数",是为了和"实际参数"相区别。之后会详细讲解形式参数与实际参数的区别。
下面我们实现一个求两个数之中较大数的函数max,并在主函数中加以调用,如例6-6所示。
例程6-6 求两数中较大的数
#include <; int max(int a, int b) { if(a > b) { return a; } else { return b; } } int main() { int a, b, ret; printf("请输入两个整数 a 和 b:"); scanf("%d %d", &a, &b); ret = max(a, b); printf("较大的数是 %d。\n", ret); }
运行结果如图6-4所示。
图1-4 运行结果
在例6-7中,max函数用来判断输入的两个数中的较大数,并把其返回。程序第三行的"int"指明max 函数返回一个整型值,在max函数中,需要传递两个int类型的参数a和b,参数a和b的具体值是由调用者在调用时传递进来的。
函数体中的return语句把一个值作为函数的返回值返回给调用者。对于带返回值的函数,至少应有一条return语句,且应保证每条离开函数的路径都以带返回值的return语句结尾。如果return语句后面还有其它语句,那么那些语句将不会被执行。图6-4解释了程序的执行流程。
图1-5 max程序的执行流程图
6.1.2 函数定义与函数声明
在上一小节的例子中,max()函数被定义在了主函数的前面,如果把其定义在主函数的后面可以吗?答案是肯定的。但是当我们试着把例 6-7中的max()函数移动到main()函数的后面编译并运行会发现,编译器提示我们"函数max()未声明",为了解决这个问题,还需要先为max()函数添加函数声明。
对于例 6-7中的函数max而言,它的声明如下所示:
int max(int a, int b);
修改后的程序如例6-8所示。
例程6-7 添加了函数声明的max程序
#include <; int max(int a, int b); int main() { int a, b, ret; printf("请输入两个整数 a 和 b:"); scanf("%d %d", &a, &b); ret = max(a, b); printf("较大的数是 %d。\n", ret); } int max(int a, int b) { if(a > b) { return a; } else { return b; } }
编译并运行,同之前图 6‑4 求较大的数程序的运行结果的没有差异。
编译器在看到函数的调用时,需要知道这个函数是如何定义的、需要什么类型的参数、返回什么类型的值等等信息,这样才能为这个调用生成对应的机器码(更确切地讲,是汇编指令)。因此函数声明需要位于第一次调用该函数之前。这也解释了为什么将函数max放在主函数之后,必须要在主函数之前添加函数声明——否则编译器遇到主函数中对函数max的调用时,还不知道有max这个函数的存在呢!
值得注意的是,函数声明仅仅包括函数定义的头部,不包括后面的大括号与函数体。函数声明需要用一个英文分号作为结尾。
6.1.3 形式参数与实际参数
通过前面的学习,大家了解到函数的参数分为形式参数和实际参数两种。本小节中将具体介绍形式参数和实际参数。
为了帮助大家更好地理解形式参数和实际参数,接下来,对例程6-8 进行修改,下面的例程调用了定义的max函数,对两个数进行大小比较。修改后的程序如例 6-9所示。
例程6-8 修改过的max程序
#include <; int max(int a, int b); int main() { int value1, value2, ret1, ret2; ret1 = max(100, 150); value1 = 7; value2 = 9; ret2 = max(value1, value2); printf("运行结果为 %d 与 %d。\n", ret1, ret2); } int max(int a, int b) { if(a > b) { return a; } else { return b; } }
在例6-9中,位于第12行的两个参数a和b就是形式参数。 形式参数顾名思义是指只在形式上存在、但并不真正存在的参数。 C语言里把出现在函数定义中的参数称为形式参数,简称形参。
与形式参数相对,实际参数(简称"实参")则是指实际存在的参数。在上面的例程里,第八行的常量100和150以及第11行的变量value1和value2都是调用函数max时的实际参数。
(脚下留心:如何描述函数参数的值?
当一个函数被调用的时候,形式参数会得到实际参数的值。在max函数被调用之前,形式参数的值是不存在的,仅当函数被调用后,实际参数的值才被传递给形式参数。因此一般谈论"函数max的两个参数a和b的值是多少"没有意义,而应该说"在XX行被调用时,函数max的两个参数a和b的值是什么"。
由上可知,形式参数和实际参数的功能是传递数据:调用函数时,调用方把实际参数的值赋给被调用函数的形式参数,从而实现调用者向被调用函数的数据传递。
在实际使用时,形式参数和实际参数应有相同的数量、类型和顺序。如果某个形参和实参的类型不同,编译器会首先尝试进行类型转换,如果转换失败,则报错。
6.1.4 可变参数
前面的例 6-8我们定义了一个max函数,对于这个函数,我们很明确的要求输入两个参数,如果我们需要设计一个更加复杂的函数,比如求多个整数的和,同时又不能明确在调用该函数时需要输入参数的个数,这时就需要用到C语言的可变参数。
在C语言中允许定义一个具有不确定个数参数的函数,这种情形被称为可变参数,也叫不定参数。带有可变参数的函数的声明方式如下:
返回值类型 函数名(形式参数列表, ...)
与固定参数的函数相比,可变参数的函数在声明时只要在形参列表的最后提供额外的三个"."即可。可变参数的函数仍然可以有个数确定的固定参数,固定参数之后则是个数可变的可选参数。
下面就是带有一个固定参数和可选参数的函数声明:
int func_a(int x, …)
下面则是一个带有两个固定参数和可选参数的函数声明:
int func_b(char a, double b, …)
(脚下留心:可变参数的使用须知
使用可变参数时需要注意以下四点:
· 使用可变参数的函数必须至少有一个固定参数;
· 定义可变参数的函数时,固定参数必须位于可选参数之前;
· 开发者需要自己确定可选参数的类型;
· 开发者需要自己确定可选参数的数量(例如将可选参数的数量当作一个固定参数传到函数中)。
完成了可变参数函数的声明,下面来看看如何在对应的函数中得到传递进来的实际参数——肯定不能靠省略号"…"来访问可选参数。C语言提供了几个宏来方便程序员访问可变参数列表。
首先需要使用va_list定义一个变量,这个变量将被用来存储指向不同可变参数的指针。有关指针的概念将在"指针"一章中详述,这里大家只要了解如何使用即可。下面的语句定义了一个名为argPtr的可变参数指针。
va_list argPtr;
刚刚定义的argPtr没有任何意义,因为还没有进行初始化。宏va_start用来初始化argPtr,得到的是第一个可变参数的地址。
va_start(argPtr, <最后一个固定参数>);
例如对于下面的函数声明
int func_b(char a, double b, …)
应当使用
va_start(argPtr, b);
对argPtr进行初始化。初始化后,argPtr指向第一个可变参数(注意不是指向最后一个固定参数)。
宏va_arg可以返回argPtr当前指向的可变参数的值,同时修改argPtr,使其指向下一个可变参数。调用va_arg时还要指定当前参数的类型。
下面的语句将取得argPtr指向的可变参数(类型为double)放到变量val中,并将argPtr指向下一个可变参数的位置:
double val = va_arg(argPtr, double);
最后需要调用va_end使argPtr无效:
va_end(argPtr);
注意:宏va_start和宏va_end需要成对出现。
回到本小节开头sum_n的例子上。要计算不确定个数的参数之和,函数sum_n有下面两种实现思路:
1. 将要相加的所有数直接放在参数中,最后使用一个特殊值来标记参数列表的结束,这个值被称为结束符。例如选择-1作为结束符,可以像下面这样调用函数sum_n:
sum_n(1, 2, 3, 5, 10, 28, 4, -1);
这种方法的问题在于要相加的数中不能有被选为结束符的那个数(例如-1),否则在函数sum_n遍历参数列表时,遇到第一个-1就认为参数列表结束了。
2. 首先传递一个数标明这次调用时一共有几个数需要相加,然后才是所有要想加的数。这样的好处是无需使用特殊的结束符。例如在计算1+2+4时,可以像下面这样调用sum_n:
sum_n(3 /* 一共三个数 */, 1, 2, 4);
这里选择第二种方式来实现函数sum_n。具体实现如例6-10所示。
例程6-9 任意个数的参数相加的实现
#include <; #include <; /* 至少应该有一个数来相加,first 代表第一个要相加的数 */ void sum_n(int count, int first, ...) { int i; int sum; va_list argPtr; /* 检查 count 是否合理 */ if(count < 1) { printf("参数个数不合理!\n"); return; } /* 初始化 sum */ sum = first; /* 初始化 argPtr */ va_start(argPtr, first); /* 由于第一个数已经在 sum 中了,所以 i 从 1 开始计数 */ for(i = 1; i < count; ++i) { int val = va_arg(argPtr, int); sum += val; } va_end(argPtr); printf("%d 个数的和为 %d。\n", count, sum); } int main() { sum_n(1, 1); sum_n(2, 3, 4); sum_n(6, 200, 1, 2, 3, 4 ,5); sum_n(0, 1, 2, 3); }
程序的运行结果如图6-6所示:
图 6‑6 计算任意个参数之和
在例 6-9中,定义了一个具有可变参数的函数sum_n,其形参的第一个参数count表示需要相加的整数的个数,代码的第8行定义了一个可变参数指针argPtr。第10行道第14行对参数count的合法性进行了验证,保证其在逻辑上合理。第18行对可变参数指针argPtr进行了初始化,其意义是使argPtr指向第一个可变参数first。第20行到第24行通过一个循环对所有的可变参数进行遍历并求和。第25行通过调用函数va_end使argPtr失效,这一行对应第18行的va_start函数。第26行输出求和运算的结果。在主函数中分别针对输入不同的参数个数情况对sum_n进行了测试。
需要注意的是每次调用va_star函数对可变参数指针argPtr进行初始化,在使用完毕argPtr之后要再次调用va_end函数使argPtr失效,否则会影响下一次使用可变参数指针argPtr的结果。
6.1.5 函数的返回值
如前所述,函数的返回值是指函数被调用之后,执行函数体中的语句所得到的、并返回给调用者的值。例如上述例程中调用max函数所得到的最大数就是通过函数的返回值实现的。
关于函数的返回值,有如下几点需要加以注意:
第一,return语句的一般为下列两种形式:
return 表达式;
return (表达式);
对于无返回值的函数,可以写
return;
用来强制返回。该语句的功能是计算表达式的值,并返回给调用者。
第二,函数的值只能通过 return 语句返回主调函数。一个函数中允许有多个return语句,但每次调用只能有一个return语句被执行;
第三,每条return语句只能返回一个函数值。return语句之后的语句不会被执行。
第四,return后面表达式的类型和函数定义返回值的类型应保持一致。如果不一致,则编译器会以函数定义中的返回值类型为准自动进行类型转换。
第五,没有返回值的函数,总是应当使用void作为返回值类型,用来表示"无返回值",而不是返回一个任意类型的值。一旦函数的返回值类型被定义为void后,就不能在调用时使用被调用函数的函数值了。为保证程序的可读性和逻辑性,凡不要求返回值的函数都应定义为void。
函数调用
在上一小节中介绍了如何定义函数,本小节讲解如何在程序中调用刚刚定义的那些函数的方法,及函数的嵌套调用。
6.1.6 函数的调用方式
函数是C语言的基本组成元素,要想让定义好的函数发挥作用,必须进行函数调用。C语言中调用函数的一般形式为:
函数名(实际参数列表);
从上面的语法格式可以看出,当我们调用一个函数时,需要明确函数名和实际参数表,实际参数表的内容要符合形式参数表的定义,例如传入参数的个数和参数的类型。
实参表中的参数可以是常数、变量或表达式。各实参之间使用英文逗号分隔。当调用的函数没有参数时,就不需要填写实际参数表。
根据C语言的语法规则,函数调用分为三种情况,如下所示:
1、将函数调用作为表达式
函数调用作为表达式中的一项出现在表达式中,函数的返回值参与表达式的运算。此时要求函数必须有返回值。
例如上述例程的第八行为ret1 = max(100, 150)是一个赋值表达式,将max(100, 150)的返回值赋给变量ret1。
2、将函数调用作为语句
例如常见的
printf("Hello, world!\n");
就是将函数调用作为一条语句。在这种情况下,被调用的函数有没有返回值均可。由于不存在变量和赋值运算符,所以在调用有返回值的函数时无法取得其返回值。调用语句的结尾使用英文分号作为语句结束
3、将函数调用作为实参
与第一种情形类似,将函数作为另一个函数调用的实际参数使用,同样要求该函数有返回值。例如下面的语句
printf("%d\n", max(100, 150));
即把调用函数max的返回值又作为printf函数的实参来使用。
(脚下留心:注意函数参数的求值顺序
在函数调用中应当注意求值顺序的问题。所谓求值顺序是指对实参表中各个变量是自左至右求值还是自右至左求值。简单而言,在两种不同的求值顺序下,下面语句的执行结果是不同的:
printf("%d %d %d %d", ++i, --i, i++, i--);
这是知名的《C语言程序设计》中的一个十分经典的例子。由于++i、--i、i++和i--都会修改i的值,因此在不同的求值顺序下会得到不同的结果。大家在写程序时绝对不要写出类似上面的语句,即在一条语句中首先修改了一个变量的值,然后再去使用这个变量。这是极其恶劣的编程风格,既不利于程序员阅读、理解代码,也不利于程序的兼容性(不同编译器可能对调用顺序有不同的实现)。因此大家完全没必要重视函数参数的求值顺序,只要保证不写出这种恶心的代码即可。
6.1.7 嵌套调用
为了完成复杂的功能,我们需要定义并调用大量不同的函数,那么这些函数的关系又是怎样的呢?
首先C语言不允许对函数作嵌套定义, 即在一个函数中不能完整地包含另一个函数。在一个程序中每一个函数的定义都是互相平行和独立的。如例6-11a所示。
例程6-10 C语言不允许对函数进行嵌套定义
/*C语言不允许下面的函数嵌套定义形式*/
void foo1(int a)
{
printf("%d", a);
void foo2(int b)
{
Printf("%d", b);
}
}
虽然C语言不允许嵌套定义函数,但可以嵌套调用函数。即在调用一个函数的过程中,又调用另一个函数。如例6-11b所示。
例程6-11 函数的嵌套调用
133 #include <;
134 void max(int a, int b)
135 {
136 int ret;
137 if(a > b)
138 {
139 ret = a;
140 }
141 else
142 {
143 ret = b;
144 }
145 printf("较大的数是 %d。\n", ret);
146 }
147 int main()
148 {
149 int a, b;
150 printf("请输入两个整数 a 和 b:");
151 scanf("%d %d", &a, &b);
152 max(a, b);
153 }
程序的运行结果如图6-7所示:
图 6‑7 程序运行结果
在例程6-11中,main函数中调用了函数max,函数max中又调用了函数printf。为了帮助大家更好地理解执行流程,接下来通过一张图来描述,如图6-9所示。
图 6‑8 函数嵌套调用的执行流程图
上图展示了程序中含有两层函数调用嵌套的情形。具体其执行过程是:首先执行main 函数,执行到调用函数max的语句时,转而使用提供的参数去执行max函数;函数max中调用函数printf时,又转去执行printf函数;当函数printf执行完毕后,返回max函数调用点处继续执行;最后当max函数执行完毕,返回main函数的调用点处继续执行。
(多学一招:函数调用最多可以嵌套多少层?
大家肯定会问:"既然函数嵌套调用和普通的调用看上去没什么区别,那是不是可以进行无限层的函数嵌套调用了?"很遗憾,函数可以嵌套调用多少层是由程序运行时一个名为"栈"的数据结构决定的。一般而言,Windows上程序的默认栈大小大约为8 KB,每一次函数调用(假设没有参数和任何局部变量的话——不过这样为什么还要调用参数呢)至少占用8个字节,因此粗略计算下,函数调用只能嵌套大约一千层——如果嵌套调用的函数里包含许多变量和参数,实际值要远远小于这个数目。
当然,纯靠手写代码写出一千层嵌套函数调用基本是不可能的,但是一种名为"递归"的方法可以轻松达到这个上限。本章的最后会介绍递归以及它的正确使用方法。
内部函数与外部函数
之前讲过的函数都是在同一个源文件中进行相互调用,如果要调用的函数位于不同的源文件中,该怎样做呢?本节将介绍如何调用不同源文件中的函数,以及如何将函数限制为只能在同一个源文件中进行调用。
6.1.8 内部函数
在我们编码的过程中,尤其是在由多人协作编写一个由多个源文件组成的程序时,往往会存在一个问题——函数重名。在C语言中,一旦编译器发现具有相同函数名的两个不同函数,将会给我们报一个"函数被重定义"的错误。为了解决这个问题,需要引入内部函数。
如果一个函数只能被同一个源文件中的其它函数所调用,那么这个函数就被称为内部函数。内部函数只能在当前源文件中被调用,对于其它源文件中的函数而言是不可见的
当定义内部函数时,需要在函数名前面加上static关键字,先来看一个例子,如例6-12所示。
例程6-12 定义内部函数b
1 static float b()
2 {
3 printf("这是函数 b!\n");
4 return 1.0f;
5 }
在例程6-12中,函数b就是一个内部函数,这个函数就不能被其它源文件中的函数调用了——即使进行函数声明了也不行。如果其它源文件中的函数想调用内部函数b,那么编译时会发生错误,如图6-10所示。
图 6‑9尝试调用内部函数b时报错
6.1.9 外部函数
如果一个函数可以被其它源文件中的函数所调用,那么这个函数就被称为外部函数。
例如,有一个包含两个源文件的工程,源文件名称分别为a.c和b.c。源文件a.c中包含主函数,它的内容如例6‑12所示:
例程6-13 源文件a.c的内容
154 int main()
155 {
156 b();
157 }
注意到主函数中调用了函数b,函数b是在b.c中实现的。
源文件b.c中实现了函数b,内容如例6‑13所示:
例程6-14 源文件b.c的内容
158 #include <;
159 void b()
160 {
161 printf("这是函数 b!\n");
162 }
编译这个工程,会发生什么呢?
图 6‑10 工程编译结果
程序的运行结果如图6-11所示:
图 6‑11 程序运行结果
尽管工程编译成功,执行仿佛也是正常的,但是从编译时的输出中可以看到编译时其实发生错误了:源文件a.c中并没有函数b的声明,因此编译时,编译器自动认定函数b应当返回一个int值,即函数b应该有这样的原型:
int b();
但实际上函数b的原型是这样的:
void b();
因为上面的例程里没用到b的返回值,所以没看出什么不同,编译和运行过程看起来都很正常。难道函数的原型与程序是否正常运行没关系么?
当然不是,下面就是一个反例。对a.c进行修改,修改后的代码如例6-14所示。
例程6-15 修改后的a.c
163 #include <;
164 int main()
165 {
166 float ret = b();
167 printf("%f\n", ret);
168 }
对b.c进行修改,修改后的代码如例6-15所示。
例程6-16 修改后的b.c
169 #include <;
170 float b()
171 {
172 printf("这是函数 b!\n");
173 return 1.0f;
174 }
程序的运行结果如图6-12所示:
图 6‑12未声明外部函数导致错误的输出结果
函数b中明明返回的是1,为什么输出时变成了12呢?这是因为编译和运行上述例程时发生了下面的情况:
1. 编译器在源文件a.c中没有找到函数b的声明,于是假设函数b返回值int型;
2. 程序运行时,主函数中对b函数的调用得到了返回值1.0f(注意这是个浮点数),但是由于编译器认为b应当返回一个整数,因此1.0f被作为一个整数传递给了主函数;
3. "整数"1.0f再被转换为浮点数,存放在ret中;
4. 调用printf将转换后的浮点数ret输出,就得到了结果12。
这里的问题就出在编译器认为函数b返回的是int型,而实际上函数b返回的是一个float型的值,因此最后得到了错误的结果。
为什么编译器不会自动查找并确定函数b的返回值类型呢?这是因为编译器一次只能编译一个源文件,而不会在意其它源文件中的内容到底是什么。具体的知识将在第十三章中加以介绍。
为了解决这个问题,就需要将函数b定义为外部函数,这样它就可以被同一个工程中的其它源文件调用了;同时需要在源文件a.c中添加函数b的函数声明。
使用关键字extern修饰的函数定义即为外部函数,修改后的函数b如例6-16所示。
例程6-17 将函数定义为外部函数
extern float b()
{
printf("这是函数 b!\n");
return 1.0f;
}
同时在调用函数b的源文件a.c中添加函数b的声明。添加声明后的源文件a.c如例6-17所示:
例程6-18 添加函数b声明后的源文件a.c
175 #include <;
176 extern float b();
177 int main()
178 {
179 float ret = b();
180 printf("%f\n", ret);
181 }
程序的运行结果如图6-13所示:
图 6‑13添加外部函数声明后的运行结果
从图6-15中可以看出函数b的返回值为浮点数1.0f,这意味着程序的运行结果是正确的。
C语言规定,在定义和声明外部函数时,extern关键字是可以省略的。所以下面例程中的函数b仍然是外部函数:
例程6-19 省略extern关键字后的外部函数定义
float b()
{
printf("这是函数 b!\n");
return 1.0f;
}
同样地,函数声明中的extern关键字也可以被省略,具体示例如下
float b();
大家一定发现了:这就是普通的函数声明。因此在默认情况下,程序中定义和声明的函数都是外部函数。
(脚下留心:外部函数与已有函数重名
C语言还规定,一个项目中不能有与函数名称相同的外部函数,否则编译工程时会发生下面的错误:
图 6‑14 外部函数重名、但返回值类型不同时发生的错误
图 6‑15 外部函数重名且返回值类型相同时发生的错误
编译程序时如果出现了上述错误提示,就需要检查程序中是否有重名的外部函数。如果发现了,将其中一个改名即可。
格式化输出函数printf也是一个外部函数,它的声明位于中。大家是不是感觉这个文件很神秘呢?其实一点儿都不神秘,这个头文件中无非是包含了许多外部函数的声明而已。
下面来看一看外部函数printf的函数声明究竟是什么。
首先在主程序的"#include <;"一行上点右键,在弹出的菜单中选择"打开文档<;",如图6-16所示。
图 6‑16 打开头文件
随后编辑区中就打开了头文件,如图6-17所示。
图 6‑17 头文件
按Ctrl + F键打开搜索窗口,搜索"printf"字样,如图6-18所示。
图 6‑18 搜索printf
VS会自动跳到printf函数声明的地方,如图6-19所示
图 6‑19 printf的声明
这里就是函数printf的声明了。
如果主程序中只用到了printf函数,而没有使用里面声明的其它函数,那么完全可以在主程序中手动声明printf函数,然后去掉对头文件的包含。这样的程序与包含了的程序是完全相同的。
声明printf函数的方法如例6-19所示:
例程6-20 自行声明外部函数printf
182 int __cdecl printf(const char * _Format, ...);
183 int main()
184 {
185 printf("这个程序中没有引用 !\n");
186 }
程序的运行结果如图6-20所示:
图 6‑20 自行声明外部函数printf
尽管可以通过自行声明的方式实现对外部函数printf的调用,但还是建议大家使用VS提供的标准头文件,这样可以避免笔误及兼容性问题,且是良好编程风格的体现。
局部变量与全局变量
通过前面的学习,发现变量既可以定义在函数内,也可以定义在函数外。定义在不同位置的变量,其作用域也是不同的。C语言中的变量,按作用域范围可分为局部变量和全局变量,接下来,本节将针对它们进行详细地讲解。
6.1.10 局部变量
局部变量是指在一个函数内部声明的变量,如例6-20所示。
例程6-21 局部变量示例
void foo(int param)
{
int val;
char ch;
/* 其它语句略 */
}
上述例程中,变量val和ch都在是函数foo内部声明的,因此它们都是局部变量。
(脚下留心:
注意param是这个函数的参数,也可以算作该函数的局部变量之一。
局部变量只在被声明的函数内有效,在这个函数以外是不能使用的。这是因为局部变量只是为这个函数准备的,只有执行这个函数时,程序才会为相关的局部变量分配内存,直到从这个函数中返回,为局部变量分配的那块内存会被程序撤销。
局部变量的生命从当前函数开始执行开始,直到从当前函数返回时结束——这被称为局部变量的生命周期。
下面的例6-21说明了无法在主程序中调用另一个函数的局部变量。
例程6-22 错误地使用局部变量
#include <; void foo(int param) { int val; char ch; printf("%d %d\n", val, ch); } int main() { val = 1; }
程序的运行结果如图6-21所示:
图 6‑21 未定义局部变量的错误提示
出现错误的原因是变量val定义在函数foo中的局部变量,所以只在foo中有效。对于main而言,并不存在这样一个名为val的局部变量,所以编译时就出错了。
(动手体验:在主函数中调用了函数foo,是不是就可以访问变量val了?
大家可能会有这样的疑问:"是不是在主函数中调用函数foo,之后就可以在主函数中访问变量val了?"请尝试在主函数中调用函数foo,然后再在主函数中为val赋值,看看是否还会出错。如果仍然报错,是什么原因呢?请试着解释一下。
6.1.11 全局变量
在所有函数范围之外定义的变量称为全局变量。全局变量不属于任何一个函数,而是对所有函数"一视同仁"——任何一个函数都可以使用全局变量。全局变量在整个程序的运行过程中都是有效的。
为了说明全局变量的性质,下面实现一个给出幸运数的程序,该程序不断读入用户输入的数,直到用户输入了一个17的倍数为止。这个数就是用户今天的幸运数。如例6-22所示:
例程6-23 幸运数
#include <; /* luckyNumber用来存储用户输入的数 */ int luckyNumber = -1; void print() { printf("您今天的幸运数是 %d!\n", luckyNumber); } int main() { while(luckyNumber % 17 != 0) { printf("请输入一个数:"); scanf("%d", &luckyNumber); } print(); }
程序的运行结果如图6-22所示:
图 6‑22 幸运数
程序的最开始定义了一个变量luckyNumber,没有定义在任何一个函数夫人范围内。其作用是保存用户输入的数。通过程序运行结果可以看到这个变量在主函数和函数print中的值都是相同的。这意味着全局变量的生命周期是整个程序。事实上,当程序启动时,程序就已经为全局变量分配内存区域了;仅当程序退出时,全局变量占用的内存区域才会被回收。
(脚下留心:不应在程序中随意使用全局变量
大家注意到使用全局变量无须发愁变量生命周期的问题,那为什么不在整个程序中都使用全局变量呢?
这是因为很多程序可能十分复杂,往往一个程序中有成百上千个局部变量。如果把它们全都声明成全局变量,不仅不利于代码阅读,也不利于程序维护。更重要的原因是,全局变量是不能重名的,这也就意味着不同的函数使用的全局变量名称如果相同,那它们使用的其实是同一个变量(所以才叫全局变量),这会造成不同函数执行时的互相影响,编写程序不仅没有变得简单,反而更复杂了。
局部变量的存在方便了模块化编程,这使开发者在同一时间只要专注于一个局部的问题即可,而不用时刻考虑全局程序的情形,大大节省了精力。
在一个源文件中同时存在重名的局部变量和全局变量时,局部变量会"覆盖"全局变量,这样在相关函数中就无法使用和局部变量同名的全局变量了。
局部变量对全局变量的覆盖如例6-23所示:
例程6-24 局部变量对全局变量的覆盖
#include <; int val = 100; void foo() { int val = 20; printf("函数 foo 中,val = %d\n", val); } void bar() { int val = 30; printf("函数 bar 中,val = %d\n", val); } int main() { printf("主函数中,val = %d\n", val); foo(); bar(); }
运行结果如图6-23所示。
图 6‑23 局部变量覆盖全局变量
在例6-23中声明了一个全局变量val,同时函数foo和bar中也都声明了同名局部变量val,根据前面讲到的规则,在这两个函数中,只能访问它们自己的局部变量val,而不能访问全局变量val了。
6.1.12 变量的作用域
如前所述,局部变量在函数内才是有效的,离开该函数就不能再使用了。这种变量有效性范围称为变量的作用域。
变量的作用域表示这个变量在程序的哪些地方是可以被有效访问的:在这个有效范围之内程序可以对变量进行操作,如给变量赋值,对变量进行计算,输入输出等;在这个有效范围之外,程序就不能再使用该变量了。
C语言中所有的变量都有自己的作用域。变量声明的位置不同,其作用域也不同。对于局部变量而言,作用域是其声明所在的整个大括号范围;而全局变量的作用域则是从声明开始,直到当前源文件的结束。当然程序中也可以跨源文件使用同一个全局变量,后文中会介绍这一点。
有关变量的作用域,有如下三项规则:
1、变量的声明定义位置规则
在C语言中使用变量之前,首先要对变量进行声明。先来看一个错误的例子,如例6-24所示。
例程6-25 声明变量
#include <; int main() { sum = 3; int sum; return 0; }
运行程序,发现编译器会提示变量sum未定义。原因在于变量sum在声明之前有一条赋值语句sum=3,由于此时sum尚未定义,编译器无法找到这个变量。由此可见,变量需要先声明再使用。将sun=3放在int sum之后程序就可以通过编译了。
2、局部变量的作用域规则
在本节的开头介绍过,局部变量是定义在函数内的,上一节中的例子又揭示了变量要在定义之后才能使用。两者相结合就可以得到局部变量的作用域:
一般地,局部变量的作用域从定义开始,到函数的右括号结束。
以主函数为例:在main函数中定义的局部变量,从定义的地方开始,到main函数结束为止。在这个区间内都可以正确使用该局部变量。用注释指出可以正确使用局部变量sum的地方,如例程6-25所示:
例程6-26 使用变量
#include <; /*这里不能使用sum */ int main() { /*这里不能使用sum */ int sum = 0; /*这里可以使用sum */ printf("%d\n", sum); return 0; /*这里可以使用sum,但是不会被执行 */ printf("%d\n", sum); } /*这里不能使用sum */
程序的运行结果如图6-24所示:
图 6‑24 使用变量
程序中一共有5处注释。在main函数的定义之外自然是不能使用sum的;在main函数定义内部、sum定义之前也是不能使用sum的;在sum定义之后、return之前可以正确地打印sum的值;在main函数内、return定义之后也可以使用sum,但是没有意义,因为main函数执行到return已经返回了。由此可见,虽然本例程中有两处printf,但是真正的输出仅有一个。
3、嵌套语句块的同名变量作用域规则
语句块是指使用大括号包围起来的几条语句,一般位于函数内部。如果程序中包含了语句块,语句块可能会影响变量的作用域。语句块对同名变量作用域的影响,如例6-26所示:
例程6-27 语句块与同名变量
#include <; int main() { int sum = 3; { int sum = 6; printf("%d\n", sum); } printf("%d\n", sum); return 0; }
运行结果如图6-25所示。
图 6‑25 语句块与同名变量
上述例程首先在main函数中定义了一个变量sum并将它赋值为3。随后定义了一个语句块,在语句块内重新定义了一个变量sum,并初始化为6,同时在语句块内输出sum的值。跳出了语句块之后,再次输出sum的值。
可以看到语句块内的printf输出了语句块中的sum值,而语句块外部的printf输出了main函数中的sum值。如果在语句块中定义了变量,那么这个变量的作用域从定义开始,一直延伸到语句块结束。如果它和语句块外部的某个变量重名了,在语句块内访问到的将是语句块内定义的变量。换句话说,外部的重名变量在语句块内失效。
下例综合展示局部变量和全局变量的作用域,以及通过语句块在函数中间声明变量的方法。如例6-27所示:
例程6-28 计算平方与立方
#include <; /* 一个全局变量 */ int foobar = 230; /* 计算传入参数的平方和立方 */ void calc(int v) { int square; square = v * v; printf("%d 的平方是 %d。\n", v, square); { int cube; cube = square * v; printf("%d 的立方是 %d。\n", v, cube); } } int main() { int val = 10; calc(val); calc(foobar); }
运行结果如图6-26所示。
图 6‑26 变量的作用域
局部变量square和v的作用域是六至十五行——从大括号开始至大括号结束。变量cube的作用域是十至十四行——函数calc的函数体内嵌套的大括号的区域。变量val的作用域是十八至二十二行之间。全局变量foobar的作用域是第三行开始到整个源文件结束。
注意不能在第十四行之后使用变量cube,因为它的作用域在遇到同级的大括号时已经结束了。不过可以在内层的大括号中(第十至十四行)使用外层大括号中的局部变量——因为变量square和v的作用域随着外层大括号的结束而结束。
(多学一招:变量的作用域和变量的生命周期有什么区别?
变量的作用域指当前变量可以被访问的范围,而变量的生命周期指某个变量从被分配内存空间到内存空间被回收的周期。在上述例程中,主函数的局部变量val作用域只在十八至二十二行之间,这意味着函数calc中无法访问该变量。但是在调用函数calc时,变量val的生命周期仍未结束——直到从主函数中返回时,val的生命周期才结束。
6.1 变量的存储类型
通过上一节的学习我们知道变量按照作用域可分为局部变量和全局变量,另外按照变量的生存周期,还可分为静态存储方式和动态存储方式。
6.1.1 变量的存储方式
从变量的作用域(即从空间)角度来分,可以分为全局变量和局部变量。从另一个角度,按照变量值存在的作时间(即生存期)角度来分,可以分为静态存储方式和动态存储方式。
· 静态存储方式:是指在程序运行期间分配固定的存储空间的方式。
· 动态存储方式:是在程序运行期间根据需要进行动态的分配存储空间的方式。
在内存中中,为用户提供的存储空间可以分为以下三个部分:
· 程序区
· 静态存储区
· 动态存储区
用户存储空间如图6-27所示:
图6-27 用户存储空间示意图
全局变量全部存放在静态存储区,在程序开始执行时给全局变量分配存储区,程序行完毕就释放。在程序执行过程中它们占据固定的存储单元,而不动态地进行分配和释放;
动态存储区存放以下三种数据:
1、 函数的形式参数。
2、 自动变量(未加static声明的局部变量)。
3、函数调用实的现场保护和返回地址。
对以上这些数据,在函数开始调用时分配动态存储空间,函数结束时释放这些空间。
在c语言中,每个变量和函数有两个属性:数据类型和数据的存储类别。生存期和作用域是从时间和空间这两个不同的角度来描述变量的特性,这两者既有联系,又有区别。 一个变量究竟属于哪一种存储方式, 并不能仅从其作用域来判断,还应有明确的存储类型说明。在C语言中,对变量的存储类型说明有以下四种:
1、自动变量
2、寄存器变量
3、外部变量
4、静态变量
后面的的内容将逐个介绍这四种存储类型。
6.1.2 自动变量
自动变量这种存储类型是C语言程序中使用最广泛的一种类型,自动变量用关键字auto作出说明。
C语言规定,函数内凡未加存储类型说明的变量均视为自动变量,即自动变量可省去说明符auto。 在前面各章节的程序中所定义的变量凡未加存储类型说明符的都是自动变量。
如下面的代码:
6 auto int val;
等同于
7 int val;
自动变量保存在动态存储区。
6.1.3 寄存器变量
在C语言中可以使用寄存器变量来优化程序的性能。
通常情况下变量是存放在内存中的,当程序需要使用某个变量时,CPU从内存中将该变量取出,再进行运算,如果需要保存结构,CPU再 将数据回写到内存中。如果有一些变量使用频繁,比如在执行多次循环操作时,将会为存取变量的值消耗大量时间。如果将一个常用的变量声明为寄存器变量,如果可能的话,编译器就会为它分配一个单独的寄存器,在整个函数执行期间对这个变量的操作全都是对这个寄存器进行操作,这时候就不用频繁地去访内存了,由于访问寄存器要比访问内存速度快很多,所以自然就提高了性能。CPU寄存器变量使用register关键字声明。
需要注意的是寄存器变量不是强制性的。也就是即便使用register关键字去声明一个变量为寄存器变量,编译器还是有可能把它作为一个普通的变量而不是寄存器变量来使用的。现代编译器能够识别使用频繁的变量并进行优化,所以通常情况下不需要程序员指定。我们只需要对其了解即可。
6.1.4 外部变量
一个程序在很小规模下,可以用一个源文件来完整表达,但是更多的程序是由多个源文件组成的。构成一个程序的多个源文件之间,可以通过声明全局变量为外部变量(extern)来进行沟通。外部变量使用extern关键字声明。
假如一个程序由三个源文件构成,每个文件都必须访问同一个全局变量,在这种情况下,其中的二个文件必须把变量声明为extern,另外一个则不能。如例6-27 所示。
例6-27 多个文件访问同一个全局变量
/* */ int g_val = 100; void main() { … } /* */ extern g_val; /* */ extern g_val;
根据C语言的定义,其中只能有一个源文件具有主函数main,而其他文件不能含有main,否则程序不知道该从什么地方开始执行,一般情况下在包含main函数的源文件中分配变量。
(脚下留心:
注意:带extern的变量说明是变量声明,不是变量定义。如果共同的变量一次都没有定义,或者在各个文件中分别定义,造成定义多次,或者声明的类型不一致,都会造成直接或间接的错误。
6.1.5 静态变量
静态存储变量通常是在变量定义时就分定存储空间,并一直保持不变, 直至整个程序结束。在C语言中静态变量使用关键字static说明。
静态变量的内存分配,编译时,将其分配在内存的静态存储区中,程序运行结束释放该单元。静态变量若定义时未赋初值,在编译时,系统自动赋初值为0;若定义时赋初值,则仅在编译时赋初值一次,程序运行后不再给变量赋初值。静态变量的生存期是整个程序的执行期间。
静态变量根据其作用域可分为静态局部变量和静态全局变量。
1、静态局部变量:
在局部变量前加上"static"关键字,就成了静态局部变量。静态局部变量存在内存的全局数据区。函数结束时,静态全局变量不会消失,每次该函数调用时,也不会为其重新分配空间。它始终驻留在全局数据区,直到程序运行结束。静态局部变量的初始化与全局变量相似。
静态局部变量与全局变量共享全局数据区,但静态局部变量值定义在它的函数中可见。静态局部变量与局部变量在存储位置上不同,使得其存在的时限也不同,导致这两者的运行结果也不同。如例 6-28 所示。
例 6-28 静态局部变量
#include<; int g = 1; /* 全局变量 */ void func(); void main() { printf("g=%d \n", g); func(); printf("g=%d \n", g); g += 10; printf("g=%d \n", g); func(); system("pause"); } void func() { static int a = 2; /* 静态局部变量 */ int b = 5; /* 局部变量 */ a += 2; g += 12; b += 5; printf("a=%d, b=%d, g=%d \n", a, b, g); }
程序的运行结果如图6-28所示:
图6-28 静态局部变量
程序中主函数main两次调用了func函数,从运行结果可以看出,程序第一次进入func函数时,静态局部变量a被初始化,第二次进入该函数时,不再进行初始化,这时它的值是第一次调用后的结果值4。
静态局部变量属于静态存储方式,它具有以下四个特点:
1、静态局部变量在函数内定义,但不像自动变量那样,当调用时就存在,退出函数时就消失。静态局部变量始终存在着,也就是说它的生存期为整个源程序。
2、静态局部变量的生存期虽然为整个源程序,但是其作用域仍与自动变量相同,即只能在定义该变量的函数内使用该变量。退出该函数后,尽管该变量还继续存在,但不能使用它。
3、允许对构造类静态局部量赋初值。若未赋以初值,则由系统自动赋值。数值型变量自动赋初值0,字符型变量赋空字符。
4、对基本类型的静态局部变量若在说明时未赋以初值,则系统自动赋予0值。而对自动变量不赋初值,则其值是不定的。
通常情况下静态局部变量有以下两个用途:
1、 可以用静态局部变量确定某函数是否被调用过。
2、 使用静态局部变量保留多次调用的值。
2、静态全局变量
在全局变量前加一个static,使该变量只能在这个源文件中可用,称之为静态全局变量。也可以叫做全局静态变量。声明静态全局变量例子的如下所示。
#include <; static int n; /* 默认初始化为0,注意此处定义的n只能在当前源文件中使用。*/ void fn() { n++; printf("n=%d\n", n); }
使一个变量只在一个源文件中使用有时是非常有必要的。原因有如下两点:
1、 不必担心另外源文件使用它的名字,改名字在源文件中是唯一的。
2、 源文件的全局变量不能被其他源文件所使用,不能被其他源文件所修改,保证变量的值可靠。
全局变量与全局静态变量有以下三个区别:
1、若程序由一个源文件构成时,全局变量与全局静态变量没有区别。
2、若程序由多个源文件构成时,全局变量与全局静态变量不同:全局静态变量使得该变量成为定义该变量的源文件所独享,即:全局静态变量对组成该程序的其它源文件是无效的。
3、全局变量可以在所有源文件里调用;除了本文件,其他文件可以通过extern的方式引用。
模块化编程
尽管C语言是一门简洁明快而又功能强大的语言,但在较复杂的工程中,很多缺乏足够开发经验的人还是感到力不从心。这是因为在大中型项目中,出于设计、团队合作、调试、测试和后期维护的需要,一个大型程序往往被分解成多个不同的部分分别加以实现,每个这样的部分称作一个模块。C语言极度依赖于函数,并不支持已成主流的面向对象等概念,这就使得基于C语言的大型程序极其依赖于良好的设计。而模块化的思路就是各种各样设计所共享的理念基础。
6.1.13 函数与模块化
函数是进行模块化编程的基础。函数帮助开发者将同样的功能组织在一起,同样的代码在工程中只出现一次,相似的代码往往也可以整合到一个函数中,再通过参数的不同加以区分。例如,编程完成一个程序,该程序具备下列功能:
· 读入一串数;
· 对读入的数进行排序;
· 输出所有数中的最大值、最小值和平均值;
· 打印柱状图。
要想实现上述功能,该程序需要定义若干函数,这些函数完成的功能如图6-27所示。
图 6‑27 实现不同功能的函数
对于编程经验丰富的人来说,可以轻松写出下面的主函数:
#include <; int main() { float list[100]; /* 请用户输入100 个数 */ readlist(list, 100); /* 为100个数进行排序 */ sort(list, 100); /* 计算统计结果 */ statistics(list, 100); /* 打印柱状图 */ printgraph(list, 100); return 0; }
上面的程序不仅包含了整个程序的运行逻辑,还将要调用的每个子函数的原型做了限定,之后程序员只要继续实现每个子函数即可。
同样地,如果设计得当,上面的每个子函数也可以在其它工程项目中发挥作用,这叫做代码的复用。
6.1.14 C语言模块化编程
函数是C语言编程的基础。在掌握了函数的基础之上,程序员就要关心如何组织、设计程序中不同的模块了。
一般而言,程序中的一个模块对应于一个或几个功能,这几个功能应当是有密切关联的。这样进行模块的划分,有助于进行团队分工、程序调试,还有助于程序结构的划分,最后还可以增加程序的可读性、可维护性和可移植性。
在基于C语言的项目中,一个模块一般由一个.c源代码文件和一个.h头文件组成。头文件中包含这个模块的所有函数声明和公共常量(这些常量属于本模块,但又可能被其它模块所用,例如在调用本模块中的外部函数的时候),源代码文件中则包含这个模块所有函数的具体实现。全局变量一般也使用一个模块来封装。
在基于C语言的大型项目中要避免出现随处声明、使用全局变量的情况出现,尽可能地避免让程序过多依赖于全局变量。这是因为全局变量的生命周期场、作用域太广,访问者、修改者都难以控制,一旦出现问题很难排查。
本节意在为大家简单介绍函数与模块化编程之间的关系。有关模块化编程的具体应用和实例,请参阅本书中"程序开发流程简介"和"综合实例"两章中的有关内容。
6.2 递归
C语言允许函数调用自己本身,这种函数调用自身的情况被称为递归。递归是一种在程序设计中被广泛使用的算法,使用递归的方法可以把一个大型且复杂的问题层层简化成为一个与原问题相似的、规模较小的问题来求解。使用递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。
6.2.1 递归的基本原理
递归是一种在数学中和计算机科学中都出现的一种思想,在这两个方面的定义如下:
1、数学中的递归。
在数学中最常见的例子是斐波纳契数列:
1 1 2 3 5 8 13 21 34 …
在这个数列中,从第三个数开始,后面的数等于它前面两个数相加之和。例如3 = 1 + 2,13 = 5 + 8。假定n是数列中的项的位置,这个数列的定义如下:
注意到在上述定义(斐波那契数列的递推公式)中,要计算f(n)的值,就要先计算f(n-1)和f(n-2)的值。可以把f(n)写成f(n - 1) + f(n - 2),还可以进一步展开f(n – 1)和f(n – 2),这样f(n)就等于(f(n – 3) + f(n – 2)) + (f(n – 4) + f(n – 3)),当然还可以继续向下展开。
在数学中这种想要计算函数自身的值,仍然要通过这个函数自身来计算另一个值的情形,被称为递归。
2、编程语言中的递归
在编程语言中,递归是指一个函数直接或间接调用自己本身,这种函数称为递归函数。
C语言允许函数的递归调用。在递归调用中,调用者和被调用者是同一个函数。执行递归函数将反复调用其自身,每调用一次就进入新的一层。示意图见图6-28:
图 6‑28 递归调用流程图
上面的流程图展示了一个不断调用自身的函数foo。直接将上面的流程图写成程序,如例6-28所示:
例6‑28 无限递归的程序
#include <; void foo(int param) { int val; char ch; printf("%d %d\n", val, ch); } int main() { val = 1; }
但是上面的递归调用存在一个问题……是的,这个递归没有终止条件!函数foo是一个递归函数,但是运行该函数将无休止地调用自身,这将导致程序陷入死循环并引发严重问题。
运行上面的程序会发生如下问题,如图6-29:
图 6‑29 无限递归导致程序崩溃
由于程序无限制的调用自己,导致栈空间耗尽,所以程序崩溃了。
如果想要实现一个正确的递归函数,应该具有下面三个条件:
· 递归边界条件,即边界条件即终止递归调用的条件。
· 递归前进处理功能,即如果进行递归的话,将要执行的语句。
· 递归返回处理功能,即如果不再进行递归,从控制流程从函数返回的语句。
通常递归边界条件的实现方法是使用条件判断,当边界条件不满足时,递归前进;当边界条件满足时,递归返回。处理流程图如图6- 所示:
图6- 递归的处理流程图
按照递归的处理流程,将例6-28的无限递归程序简单修改一下,就可以得到一个一定能够终止的递归程序了。为了展示递归的原理,程序中增加了一些说明文本,如例程6-29所示:
例 6‑29 有限递归程序(递归十次后结束递归)
#include <; void foo(int depth) { int n; if(depth == 10) /*递归边界条件,当递归深度达到10时,不再进行递归操作*/ { /*递归返回处理功能*/ return; } else {/*递归前进处理功能*/ printf("将要进行第 %d 层调用。\n", depth + 1); foo(depth + 1); printf("从第 %d 层调用中返回。\n", depth + 1); } } int main() { printf("将要进行第 %d 层调用。\n", 1); foo(1); printf("从第 %d 层调用中返回。\n", 1); }
程序的运行结果如图6-30所示:
图 6‑30 有限递归程序
例程6-29为无限递归程序提供了结束递归的条件:函数foo每进行一次递归,就为变量depth加1;当递归到第十层时(此时depth等于10)停止递归,直接返回,如程序中五至八行所示。增加了结束递归的条件之后,递归就不会无休止地进行下去了。
通过例6-29,我们初步了解了怎样实现递归函数,下面结合这个例程来介绍递归的基本原理,具体要点如下。
· 递归调用是分级的(图6-31)。对于例6-29中的递归函数foo而言,第一次调用被称为第一级调用,第二次调用被称为第二级调用,以此类推。
图 6‑31 递归调用的分级
· 每一级递归函数都有自己的局部变量,互相之间是不能相互访问的(图6-32)。以例6-28的无限递归函数foo为例,第一级递归中的变量n和第二级递归中的变量n没有任何关系;虽然它们的变量名都是n,但是它们是相互独立的,可能具有不同的值。
图 6‑32 不同级别之间的递归调函数有不同的局部变量
· 每一次递归调用都有一次对应的返回。每一级调用只能返回到上一级调用者那里,无法一次返回多层,也不能直接返回到主函数。
· 递归函数中,位于递归调用之前的语句是逐级顺序执行的,而位于递归调用之后的语句是逐级逆序执行的。如例6-29中,在第十三行递归调用之前,输出的是"将要进行第1层调用"、"将要进行第2层调用"直到"将要进行第9层调用",与递归函数的执行顺序相同;而第十四行的语句位于递归调用之后,输出的是"从第9层调用中返回"、"从第8层调用中返回"直到"从第1层调用中返回",与递归函数的执行顺序相反,但与递归函数的返回顺序相同。
· 递归函数中必须包含可以终止递归调用的语句或条件。一般而言,递归函数中会使用条件判断语句判断当前是否到达了结束递归的条件,若条件满足,则不再继续进行递归,由此递归调用被中止。如在例6-2中,第六行程序判断了递归的级别(或称层次、深度)是否为第十级后,到达第十级后不再继续递归调用,这意味着递归只能进行十次,十次后即终止。
(脚下留心:
在的过程当中系统为每一层的返回点、局部量等开辟了栈来存储。递归次数过多容易造成等。所以不宜用递归解决层次过多的问题。另外递归算法解题通常显得很简洁,但递归算法解题的运行效率较低。
6.2.2 递归的应用:计算阶乘
一个整数的阶乘被定义为这个整数到1的乘积。使用公式表示如下:
对于整数N,N的阶乘
。
特别地,0的阶乘等于1。
为计算一个数的阶乘,最简单的实现方法是通过循环来实现。不过也可以使用递归来完成运算。下面给出一个使用递归进行阶乘运算的例子,如例6-30所示。
例6‑30 使用递归计算阶乘
#include <; long long factor(int n) { long long ret; if(n == 0) { return 1; } else { ret = n * factor(n - 1); return ret; } } int main() { long long ret = factor(20); printf("20! = %lld\n", ret); }
程序运行后的结果如图6-33所示:
图 6‑33 使用递归计算阶乘
程序中使用了long long int型变量以存储计算结果,这是因为阶乘运算的结果往往很大,使用long long int可以在一定范围内避免计算结果的溢出。
为什么可以使用递归来实现阶乘运算呢?这是因为对于阶乘运算而言,还有如下的性质:
由于(N-1)!是(N-1)到1的所有整数的乘积,将这个结果再乘以N自然就是N的阶乘了。放在程序中,就有
factor(n) = n * factor(n - 1);
这意味着计算N的阶乘的问题可以被化为一个较小规模的问题——计算(N - 1)的阶乘,而计算(N - 1)的阶乘又可以被化为一个较小规模的问题——计算(N - 2)的阶乘……以此类推。直到最后N = 0时,factor(N) = 1,递归结束。N = 0就是这次递归运算的结束条件。
(动手体验:
请使用循环来实现计算n的阶乘的程序。
例如,所要求的数是4,则阶乘式是1*2*3*4,得到的积是24,24就是4的阶乘。如果所要求的数是6,则阶乘式是1*2*3*```*6,得到的积是720,720就是6的阶乘。如果所要求的数是n,则阶乘式为1*2*3*```*n,设得到的积是x,x就是n的阶乘。
阶乘运算既可以使用循环实现,又可以使用递归实现,到底哪种实现方式更好呢?一般而言,如果一个操作可以同时使用循环和递归实现的话,应该优先选择使用循环的实现方式。这是因为递归会消耗更多的栈空间,每一级递归的函数调用也会消耗额外的时间。
6.2.3 递归的应用:进制转换
首先回顾一下第二章中讲过的将十进制数转换为二进制数的方法——除二取余法:把给定的正整数不断除以2,将每次得到的余数连起来再逆序,就是对应的二进制表示。以十进制的13为例:
由上可知,13的二进制表示就是1101。
与阶乘计算相同,除二取余法也是不断地把一个较大规模的问题转换成一个稍小规模的问题(每次n都是上一次运算时的1/2)。注意到最后的二进制表示是逆序输出的,恰好和递归的返回顺序一致(也就是递归函数中递归调用之后的语句的执行顺序)。这两点启发大家使用递归实现这一功能。
例6-31展示了如何使用C语言进行十进制数转换为二进制数的工作。
例6‑31 使用递归进行进制转换
#include <; void convert(int n) { if(n == 0) { printf("0"); } else if(n == 1) { printf("1"); } else { int mod = n % 2; convert(n / 2); printf("%d", mod); } } int main() { int dec; printf("请输入要转换的十进制正整数或0:"); scanf("%d", &dec); printf("转换后的结果是:"); convert(dec); printf("\n"); }
程序的运行结果如图6-34所示:
图 6‑34 使用递归进行进制转换
递归函数convert中,递归结束的条件有两个,分别是n == 1和n == 0。n为1时,意味着只要输出1作为转换后的二进制数最高位即可,转换可以到此结束;而n为0时,则必须输出一个"0"作为转换后的结果。
本章小结
本章详细介绍了C语言中有关函数的点点滴滴,包括函数的定义和声明、变量的作用域、局部变量和全局变量的异同等等,并介绍了递归调用的有关知识。此外还对模块化编程做了基础性的介绍。本章依然是C语言的知识基础,希望大家能在学习之后进行巩固训练,以牢固掌握该部分内容。