C语言第七章函数

本章主要内容

1、函数定义、函数调用、函数原型

2、函数的参数传递与返回值

3、递归函数

4、变量的作用域与存储类型

5、模块化程序设计方法

 分而治之与信息隐藏

从生活中的问题求解谈起,管理学观点认为工作必须有分工,各司其职。例如,一家人需要准备早餐,金枪鱼三明治、薯条、咖啡,全家三口一人一样,并行工作的方式效率更高。

分工合作的思想,在程序设计里也适用,称之为分而治之。

 

假如将所有的功能都塞到一个main函数中?

假如系统提供的函printf()用10行代码替换,name你编过的程序会变成什么样子?

 

因此,我们需要把主函数中的一部分逻辑放到其他函数中,不能所有的事情都要主函数来处理。

 

那么一个函数中适合放多少行代码?

1986年IBM在OS/360的研究结果:多数有错误的函数大于500行,91年的时候又有研究表明,对小于143行的函数代码更容易维护。

 

分而治之

把一个复杂的问题分解为若干个简单的问题,提炼出公共任务,把不同的功能分解到不同的模块中,逐个解决。分而治之是解决复杂问题的基本方法,也是模块化程序设计的一个基本思想。

 

分而治之,有利于分工合作,还有利于信息隐藏,把函数内的具体实现细节隐藏起来,进行更好的封装,对于函数使用者而言,也无需知道函数内的实现细节,只要对外的接口不便,就不会影响使用。尽管汇编语言里有“子程序”的概念,但是高级语言中的函数才真正体现了“封装”的思想,函数通过参数和返回值与外界交流,通过参数获取外部信息,函数接口必须定义清楚。即函数的输入输出就是与外界数据交换的接口

 


程序设计的艺术

算法设计艺术-程序的灵魂

结构设计艺术-程序的骨架和肉体,优雅的结构给人以美的感受,即模块化的程序设计,如结构化(Structural),面向对象(Object-Oriented)、面向组件(Component-Oriented)、面向智能体(Agent-Oriented)

 

函数的定义

函数是c语言程序模块化编程的最小单位,一个模块根据模块化的粒度可以由一个或多个函数组成,一个C程序由一个或多个源程序文件组成。

 

函数的分类

函数生来都是平等的,互相独立的,没有高低贵贱和从属之分,main有一点特殊,C程序的执行都是从main函数开始,调用其它函数后流程回到main函数。

标准库函数:一种是ANSI/ISO C定义的标准库函数,符合标准的C语言编译器都必须提供这些函数,函数的行为也要符合ANSI/ISO C的定义;还有一种是第三方库函数,是由其他厂商自行开发的C语言函数库,不在标准范围内,能扩充C语言的功能(图形、数据库等)

自定义函数:用户自己定义的函数

 

函数的定义

一种是有返回值的,返回值类型要和定义的类型一致,如果不一致会发生自动类型转换,有时候不安全会丢失信息;另外一种是没有返回值的,明确void;如果定义的时候函数类型不写,默认返回整形。

 

向函数传递值和从函数返回值

调用者通过函数名来调用函数,有返回值时,可以放到一个赋值表达式中,还可放到一个函数调用语句中,作为另一个函数的参数。函数参数在函数调用的时候是实参,定义的时候是形参。

 

函数调用

每次执行函数调用时,现场保护并为函数的内部变量(包括形参)分配内存,把实参的值复制给形参,单向传值(实参->形参);然后执行函数内的语句,当执行到return语句或者}时,从函数退出,根据函数调用栈中保存的返回地址,返回到当前函数调用的地方,程序控制权交给调用者,返回值作为函数调用表达式的值,收回分配给函数内所有变量(包括形参)的内存。

 

例,计算n!

 1 /* 函数功能:   用迭代法计算n!
 2    函数入口参数:整型变量n表示阶乘的阶数
 3    函数返回值:返回n!的值
 4 */   
 5 
 6 long Fact(int n)   /*函数定义*/
 7 {
 8     int i;
 9     long result = 1;
10     for( i=2; i<=n; i++){
11         result *= i;
12     }
13     return result;
14 }

 

函数注释:参考上面n!的例子

 

函数原型(Function Prototype) 

调用函数前先声明返回值类型、函数名和形参类型,写上函数的原型,有利于编译器对函数参数类型是否匹配进行检查。良好的编程习惯需要在程序的最前面写上函数的原型。

 

函数定义与函数原型的区别

 

函数封装与防御性程序设计

函数封装(Encapsulation):对外界函数的影响仅限于入口参数,函数对外界对的影响仅限于一个返回值和数组、指针形参

 

如何增强程序的健壮性,使函数具有遇到不正确使用或非法数据输入时避免出错的能力?

在函数的入口处,检查输入参数的合法性

 1 /*函数功能:用迭代法计算n! */
 2 long Fact(int n)
 3 {
 4     int i;
 5     long result = 1;
 6     if(n<0){
 7         printf("Input data error!\n);
       return -1;
8 } 9 else{ 10 for( i = 2; i<=n; i++){ 11 result *= i; 12 } 13 return result; 14 } 15 }

 

主函数如何修改,检查函数返回值。printf函数和scanf函数都是有返回值的,printf函数的返回值是其输出的字符个数,scanf函数的返回值是读入的有效字符个数。

 

将n!函数中参数定义成无符号整数会发生什么情况?

 1 #include <stdio.h>
 2 unsigned long Fact(unsigned int n);
 3 
 4 int main(){
 5     int m;
 6     long ret;
 7     printf("Input m:");
 8     scanf("%d",&m);
 9     ret = Fact(m);
10     if(ret == -1){
11         printf("Input data error!\n");
12     }
13     else {
14         printf("%d! = %ld\n",m,ret); 
15     } 
16     return 0;
17 }
18 
19 unsigned long Fact(unsigned int n){
20     unsigned int i;
21     unsigned long result = 1;
22     if(n<0){
23         printf("Input error\n");
24         return -1;
25     }
26     else {
27         for(i=2;i<=n;i++){
28             result *= i;
29         }
30         return result;
31     }
32 }

 

永远不会执行到n<0的分支。warning:comparison of unsigned expression < 0 is always false

为什么?

试验一下,打印无符号整数-1看看,结果是个很大的数,出现这种情况的原因是负数在C语言中的是以二进制补码方式存储的。

-1原码:10000001

-1反码:111111110

-1补码:111111111

-1的补码是个非常大的数,这就是存在死代码的原因,那么怎么去掉冗余代码?

保证不会输入<0的数,使用do—while循环。

 

例:编写计算组合数的程序,利用上面的阶乘函数(函数复用)。

使用do-while循环对Fact输入的值进行过滤,称这种设计为防御性程序设计(defensive/secure program)

 1 #include <stdio.h>
 2 
 3 unsigned long Fact(unsigned int n );
 4 
 5 int main(){
 6     double p;
 7     int m,k;
 8     do{
 9         printf("Input m,k(m>=k>0):");//0!=1
10         scanf("%d,%d",&m,&k);
11     } while(m<0 || m<k || k<0);
12     printf("%d,%d,%d",Fact(m),Fact(k),Fact(m-k));
13     p = (double)Fact(m)/(Fact(k)*Fact(m-k));
14     printf("p = %.0f\n",p);
15 }
16 
17 unsigned long Fact(unsigned int n){
18     unsigned int i;
19     unsigned long result = 1;
20     for(i=2;i<=n;i++){
21         result *= i;
22     }
23     return result;
24 }

 

断言(Assert)

#include <assert.h>

void assert(int expression);
//expression为真,无声无息;为假,程序退出。

在特定的情况下使程序终止,换句话说,即测试一个条件并可能使程序终止。

1 unsigned long Fact(unsigned int n){
2     unsigned int i;
3     unsigned long result = 1;
4     assert(n>=0);
5     for(i=2;i<=n;i++){
6         result *= i;
7     }
8     return result;
9 }

在win版的DEVC++中运行出错,读入数据与变量实际值不一致。但是在linux虚拟机中运行正常

DevC++中的运行结果:

CentOS中的运行结果:

 

考虑使用断言的情况有以下几种:一是检查程序中各种假设的正确性;二是证实某种不可能发生的状况确实不可能发生。仅能用于调试程序,不能作为程序的一个功能,只有在debug版是有效的,release版是失效的。

 

函数不能嵌套定义,函数是相互平行的,该限制可以使编译器简化。但是可以嵌套调用,在调用一个函数的过程中又调用另外一个函数。

 

递归函数:

例:阶乘问题,之前是迭代方法做的,现在用递归方式,递归方式比较简单,比较符合数学定义。

1 //递归计算阶乘
2 long Fact(int n){
3     if(n<0) 
4         return -1;
5     else if(n==0||n==1) 
6         return 1;
7     else 
8         return Fact(n-1)*n;
9 }

 

1 //递归计算阶乘,无符号整数
2 unsigned long Fact(unsigned int n){
3   
4     if(n==0||n==1) 
5         return 1;
6     else 
7         return Fact(n-1)*n;
8 }

递归必须具备两个条件:

基本条件:控制递归调用结束,每个递归函数必须至少有一个基本条件能用非递归的方式计算得到;

一般条件:控制递归调用想着基本条件的方向转化,一般条件必须最终能转化为基本条件。

 

递归层数使用很多的时候,栈空间占用较大,时间空间代价都很高。

递归方法编程的优点:符合人类的思维习惯,简洁精炼。缺点:时空效率较低;增加了函数调用的开销,每次调用都需要线程保护,参数传递,分配内存,消耗的栈空间、时间都比较多。

例:计算Fibonacci数列

n=5的时候需要调用函数15次,一个程序如果能用迭代和递归方式解决,尽量不用递归。

 

哪些问题可以用递归方式解决:

-数学定义是递归的,比如阶乘、Fibonacci数列

-数据结构是递归的,比如单向链表

-问题解法是递归的,经典问题,比如汉诺塔、八皇后

 

上节回顾:

函数定义、函数的返回值、函数的调用、函数原型

int Max(int x,int y){

  return x>y?x:y;

}

明确函数返回值类型,如果不返回应该写void

调用函数是通过函数名来调用,调用函数的时候涉及到函数的参数传递,

函数定义的时候参数为形参,调用的时候的参数是实参;

无论那种情况都要写函数原型,函数定义是有函数体的,函数原型是没有函数体的,写函数原型可以让编译器检查函数参数类型是否匹配;

递归函数(函数的递归调用),嵌套函数的特例,调用自己;函数的递归低用效率不高,层数比较深的时候,堆栈空间占用较多(需要保存返回地址),其次是时间消耗也较大,使用递归的情况通常有三种。

 

汉诺塔问题(Hanoi)

印度神话,上帝创造世界时只做了三根金刚石柱子,,第一根上从下往上按大小顺序摞着64片黄金圆盘,上帝命令婆罗门把圆盘从下开始按大小顺序重新摆放到第二根上

规定每次只能移动一个圆盘,在小圆盘上不能放大圆盘。

当n=64时,需要移动多少次呢?

约1844亿次,若按照每次消耗1微秒计算,则64个圆盘的移动需要60万年。

 

第一步:将问题简化

n=2 时,需要3次;

第二部,将n个圆盘分为两个部分,将前面n-1个看成一个整体;

 

设计第1个函数:

将n个圆盘借助C从A移到B   Hanoi(int n,char a,char b,char c);

step 1 Hanoi(n-1,a,c,b);

step2 Hanoi(n,a,b);

step3 Hanoi(n-1,c,a);

 1 void Hanio(int n ,char a,char b, char c){
 2     if(n==1){//终止条件
 3         Move(n,a,b);
 4     }
 5     else{ //一般条件
 6         Hanio(n-1,a,c,b);
 7         Move(n,a,b);
 8         Hanio(n-1,c,b,a);
 9     }
10 }

 

设计第2个函数:

将一个圆盘从A移动到B Move(int n, char a,char b);计算机不能真的搬,只是打印搬运结果

1 void Move(int n, char a, char b){
2     printf("Move %d: from %c to %c\n",n,a,b);
3 }

 

数学归纳法是递归问题求解的基础,汉诺塔问题整个程序如下:

 1 #include <stdio.h>
 2 void Hanio(int n, char a, char b, char c);
 3 void Move(int n,char a, char b);
 4 
 5 int main(){
 6     int n;
 7     char A,B,C;
 8     printf("请输入需要移动的盘子个数:");
 9     scanf("%d",&n);
10     printf("Steps of moving %d disks from A to B by means of C:\n", n);
11     Hanio(n,'A','B','C');
12     return 0;
13 
14 }
15 
16 void Hanio(int n ,char a,char b, char c){
17     if(n==1){//终止条件
18         Move(n,a,b);
19     }
20     else{ //一般条件
21         Hanio(n-1,a,c,b);
22         Move(n,a,b);
23         Hanio(n-1,c,b,a);
24     }
25 }
26 
27 void Move(int n, char a, char b){
28     printf("Move %d: from %c to %c\n",n,a,b);
29 }

 变量的作用域和存储类型

变量的作用域(Scope)是指在源程序定义的位置及其能被访问的范围,分为两种类型:局部变量和全局变量

局部变量(Local Variable)就是在语句块(函数,复合语句)内定义的变量

全局变量(Global Variable)就是在所有函数之外定义的变量

 

局部变量的作用域:有效范围仅为改语句块(函数,复合语句),仅能由语句块内的语句访问,退出语句块时释放内存,不再有效。

举个例子:

 1 int main(){
 2     int x=1,y=1;
 3     {
 4     int y = 2;//语句块内定义变量
 5     x = 3;
 6     printf("x=%d,y=%d\n",x,y);
 7     }
 8     printf("x=%d,y=%d\n",x,y);
 9     return 0;
10 }

全局变量的定义域,有效范围从定义的位置开始,到程序结束。

 

假如变量同名。。。。。

并列语句块内各自定义的不同作用域的变量同名不会影响,互不干扰,形参的值不会传递给实参,参考下面的例子。

实参的值可以传递给形参,但是形参的值不会传递给实参,这就是函数参数的单相传递。函数形参是局部变量。

 

局部变量和全局变量同名,局部变量隐藏全局变量。修改一下上面的代码:

 

运行结果:

 

只要作用域不同,新的声明隐藏旧的声明。

编译器只能区分不同作用域中的同名变量,不能区分相同作用域中的同名变量。编译器是通过内存地址来操作的。一个变量代表两个不同的值,仅当他能代表两个不同的内存地址,编译器通过将同名变量映射到不同的内存地址来实现作用域的划分。局部变量和全局变量被分配的内存区域不同,因而内存地址也不同。形参和实参的作用域、内存地址不同,所以形参值的改变不会影响实参。

 

全局变量有什么用?

当多个函数必须共享同一个变量,或者少数几个函数必须共享大量变量时,可以使用全局变量。使用全局变量的好处是使函数间的数据交换更容易,更高效。比如使用全局变量来计数,打印计算Fibonacci数列第n项需要调用函数的次数。如果使用函数参数来传递count的话,需要每次调用都返回,无法返回两个值(f,count)。

 

例:打印Fibonacci数列每一项调用函数的次数,之前只需要答应第n想需递归调用的次数。

原则:尽量不要使用全局变量,大多数情况下使用形参和返回值进行数据交流会比共享全局变量更好一些。

为什么?

-因为谁都可以改写全局变量,很难跟踪确定谁修改了它。

-其次,很难在其他程序中复用,依赖全局变量的函数不是独立的,为在另一程序中使用它,不得不带上函数所需的全局变量。

-修改程序时,若改变全局变量(如类型),则需检查同一个文件中的每一个函数,以确认该变化对函数的影响程度。

 

变量的存储类型

内存中供用户使用的存储空间分为四个部分,代码区、常量区、静态存储区、动态存储区

“静态”:表示事情发生在程序构建的编译时和链接时,而非程序实际开始运行的载入时和运行时。

“动态”:表示事情发生在程序实际开始运行的载入时和运行时。

 

变量的存储类型

在内存中存储(编译器为变量分配内存)的方式。

静态存储方式,是指在程序运行期间分配固定的存储空间的方式。

 

存储类型决定变量的生存期。(Lifetime)

--何时生,何时灭。

--静态分配的变量在整个程序的生命周期内全程占据内存。

静态存储区中的变量:与程序共存亡,比如上大学的宿舍,从你报名开始一直到毕业,不会因为寒暑假期间你不住就分配给别人。

动态存储区中的变量:与语句块共存亡,比如旅游住宾馆,你走了之后就分配给别人了。

 

如何声明变量的存储类型?

    存储类型    数据类型    变量名;

C存储类型的关键字:

--auto 自动变量

--static 静态变量

--extern 外部变量,编译器并不对其分配内存,只是表示“我知道了”

使用场景:要想在变量定义之前使用该变量,可以先使用外部变量进行声明

--register寄存器变量

 

 

自动变量--动态局部变量(缺省类型:在数据类型前面不加任何修饰的话就表示该变量是自动变量)

    auto   数据类型   变量名;

进入语句块时自动申请内存,退出时自动释放内存。所谓的释放的内存,就是变量的值恢复为随机值。离开函数,值就消失。

 

静态变量

  static  数据类型  变量名;

--从程序运行起占据内存,程序退出时释放内存;

--离开函数,值仍保留。有一定的记忆功能。

--包括:静态局部变量(函数里面定义)、静态外部变量(所有函数外面定义)。

--生存期相同,作用域不同(语句块内,文件内),如果一个外部变量没有声明为静态的,那么程序中的所有文件都可以访问,但如果声明为静态的,只有当前文件能够访问,目的是多人合作开发的时候防止出现同名情况,但是这种情况出现的可能性几乎为0,因为首先我们就不提倡使用全局变量,因而这种静态全局变量会用的更少。

 

自动变量和静态变量的区别:

 

由于静态变量生存周期是整个程序运行期间,所以每次调用完Func函数,静态变量p的值都会更新,可以实现累乘运算。

如果将静态变量p换成自动变量,那么每次调用Func函数,p的值都为1。 

 

静态局部变量和全局变量自动初始化为0.自动变量不初始化时,值是随机值。

 

寄存器变量

寄存器变量在cpu里,不用和外部打交道,运行速度很快,但是内部容量有限,生存期与程序“共存亡”。

    register  类型名 变量名;

适用于使用频率较高的变量,可使程序变小,执行速度更快。

现代编译器有能力把自动把普通变量优化为寄存器变量,并且可以忽略用户的指定。所以一般无需特别声明变量为register。

 

模块化程序设计

模块各司其职

--每个模块只负责一件事情,他可以更专心

--一个模块一个模块的完成,最后再将他们集成

--便于单个模块的设计、开发、调试、测试和维护。

开发人员各司其职

--按模块分配任务,职责明确

--并行开发,缩短开发时间

 

何时需要模块化呢?

某一功能,若重复实现3遍以上,则应考虑模块化,将它写成通用函数,向小组成员发布。拿来拿去主义,不是人类懒惰的表现,二是智慧的表现。

 

模块化的优点--便于复用

--构建新的软件系统可以不必每次从零做起;

--直接使用已有的经过反复验证的软构件,组装或修改后成为新的系统

--提高软件生产效率和程序质量。

 

模块分解的过程:自顶向下(Top-down),逐步求精(Stepwise refinement)的程序设计方法

先全局后局部,先整体后细节,先抽象后具体,不一定是严格的自顶向下,可以由不断的自底向上修正所补充的自顶向下的程序设计方法。

 

生活中的自顶向下、逐步求精

1、start

2、准备早餐

  2.1准备一个金枪鱼三明治

    2.1.1 拿来两片面包

    2.1.2 准备一些金枪鱼酱

  2.2准备一些薯片

    2.2.1将土豆切成条

    2.2.2油炸这些土豆条

  2.3冲一杯咖啡

    2.3.1烧些开水放入杯中

    2.3.2在水杯中加入一些咖啡

3、end

 

模块分解的基本原则

--保证模块的相对独立性--高聚合、低耦合

--模块的实现细节对外不可见--信息隐藏;外部:关心做什么;内部:关心怎么做

设计好模块接口

--指罗列出一个模块的所有与外部打交道的变量

--定义好后不要轻易改动

--在模块开头(文件的开头)进行函数声明

 

例:猜数游戏,猜多个数,10次猜不对就猜下一个数

模块分解过程:开始->初始化->主功能->退出处理->结束

初始化--为程序运行所做的准备工作

退出处理--在退出前要做的事情,如打印结果、资源释放等

进一步细化: 主函数包括计算机生成数字、用户猜数字两大功能

主函数:开始->生成数字->用户猜数字->结束

 1 #include <stdio.h>
 2 #include <time.h>
 3 #include <stdlib.h>
 4 
 5 int main()
 6 {
 7     int number;
 8     srand(time(NULL));
 9     number = MakeNumber();//生成数字
10     GuessNumber(number);//用户猜数字
11     return 0;
12 }

 

继续逐步求精:猜完(10次猜不对)提示用户是否继续,否就结束,是就继续生成数字猜数字。

 

 1 int main()
 2 {
 3     int number;
 4     char reply;
 5     srand(time(NULL));
 6     do{
 7         number = MakeNumber();
 8         GuessNumber(number);
 9         printf("Do you want to contnue(Y/N or y/n)?");
10         scanf(" %c",&reply);
11     } while (reply == 'Y' || reply == 'y');
12     return 0;
13 }

 

进一步细化用户猜数字:

处理用户输入,判断是否有输入错误,是否在合法的数值范围内。

 1 void GuessNumber(const int number)
 2 {
 3     记录用户猜测次数的计数器置初值为1;
 4     do{
 5         读入用户猜测的数字;
 6         判断用户猜的数字是否有输入错误,是否在合法的数值范围内,并进行错误处理;
 7         记录用户猜测的计数器增1;
 8         判断用户猜的数是大还是小,并输出相应的提示信息;
 9     } while(未猜对并且猜测次数未超过MAX_TIMES次);
10     若猜对
11         输出“Congratulations! You're so cool!";
12     否则若超过MAX_TIMES次仍未猜对
13         输出“Mission failed after 10 attempts.";
14 }

 

整个程序代码:

 1 #include <stdio.h>
 2 #include <time.h>
 3 #include <stdlib.h>
 4 #define MAX_TIMES 10
 5 #define MAX_NUMBER 100
 6 #define MIN_NUMBER 1
 7 
 8 int makeNum(void);
 9 void guessNum(const int num);
10 int isValid(const int num);
11 int isRight(const int mNum,const int g_Num);
12 
13 int main(){
14     int num;
15     char reply;
16     srand(time(NULL));
17     do{
18         num = makeNum();
19         guessNum(num);
20         printf("do you want to continue(Y/N ory/n)?");
21         scanf(" %c",&reply);
22     } while(reply == 'y' || reply == 'Y');
23     return 0;
24 } 
25 
26 void guessNum(const int num){//用户猜测数字
27     int gus_num,count,right,ret;
28     count =1;
29     do{
30         printf("This is %d time guess.\nPlease input your guess num:",count);
31         ret = scanf("%d",&gus_num);
32         if(ret != 1 || !isValid(gus_num)){
33             printf("input error\n");
34             while(getchar()!='\n');
35             continue;
36         }
37         count ++;
38         right = isRight(num,gus_num);       
39     } while(!right && count <= MAX_TIMES);
40     if(right){
41         printf("Congratulations! You're so cool.\n");
42     } else{
43         printf("Mission failed after %d times attempts!\n",MAX_TIMES);
44     }
45 }
46 
47 int makeNum(void){//计算机生成随机数
48     int number;
49     number = (rand()%(MAX_NUMBER - MIN_NUMBER+1))+MIN_NUMBER;
50     return number;
51 }
52 
53 int isValid(const int num){//判断猜的数字是否有效(在限制范围内)
54     if(num >= MIN_NUMBER && num <= MAX_NUMBER){
55         return 1;
56     } else{
57         return 0;
58     }
59 }
60 
61 int isRight(const int mNum,const int g_Num){
62     //判断猜的数字是否正确
63     if(g_Num>mNum){
64         printf("too big!\n");
65         return 0;
66     } else if( g_Num < mNum){
67         printf("too small!\n");
68         return 0;
69     } else return 1;
70 }

 

 

参数声明为const ,常量方式,保证变量不会被改写

 

函数设计的基本原则有三条:

1、函数的规模要小

2、函数功能要单一

3、函数的接口要定义清楚

检查入口参数有效性、合法性;检验计算结构是否在合法的范围内,敏感操作前的检查(除法);检查函数调用是否成功;

对函数接口进行注释说明

 

命名规则

LInux/UNIX风格的函数名命名  function_name

Windows风格的函数名命名:用大写字母开头、大小写混排的单词组合而成 FunctionName

变量名形式:“名词”或者“形容词+名词”,如oldValue与newValue等

函数名形式:“动词”或者“动词+名词”(动宾词组),如GetMax()等

 

posted @ 2020-08-02 17:11  s0cket  阅读(632)  评论(0)    收藏  举报