C语言中函数概念的引入
C语言中,一个程序无论大小,总是由一个或多个函数构成,这些函数分布在一个或多个源文件中。每一个完整的C程序总是有一个main函数,它是程序的组织者,程序执行时也总是由main函数开始执行(main函数的第一条可执行语句称为程序的入口),由main函数直接或间接地调用其他函数来辅助完成整个程序的功能。
函数充分而生动地体现了分而治之和相互协作的理念。
- 复杂问题简单化。它可以将一个大的程序设计任务分解为若干个小的任务,这样便于实现、协作及重用,有效地避免了做什么都要从头开始进行。
- 可重用性。同时,大量经过反复测试和实践检验的库函数更是提高了程序的开发效率、质量,有效地降低了开发成本。这体现了程序设计中分工协作的思想。
- 抽象化思想。程序用于模拟客观世界,函数抽象了现实生活中能相对独立地进行工作的人或组织,函数间的相互协作正好映射了现实生活中人或组织间的相互协作。
- 封装的思想。另外,函数还体现了封装的思想。它有效地将函数内部的具体实现封装起来,对外只提供可见的接口(传入的形式参数与返回的函数值)。这样,调用函数时就不用关心该函数内部具体的实现细节,而只需关注其接口即可调用和使用它来辅助完成所需功能。
- 简化程序。另外,利用函数还可以大大降低整个程序总的代码量。
函数(Function)是一段可以重复使用的代码,这是从整体上对函数的认识。
参数(Parameter)是函数需要处理的数据
返回值(Return Value)是函数的执行结果
函数是一段可以重复使用的代码,用来独立地完成某个功能,它可以接收用户传递的数据,也可以不接收。
return是C语言中的一个关键字,只能用在函数中,用来返回处理结果。
函数一旦遇到 return 语句就返回(停止执行),后面的所有语句都不会被执行到。
函数定义时给出的参数称为形式参数,简称形参;函数调用时给出的参数(传递的数据)称为实际参数,简称实参。函数调用时,将实参的值传递给形参,相当于一次赋值操作。注意:实参和形参的类型、数目必须一致。
#include <stdio.h>
int max(int, int);
int main (void) {
int a;
int b;
int maxVal;
scanf("%d %d", &a, &b);
maxVal = max(a, b);
printf("%d\n", maxVal);
return 0;
}
int max(int a, int b) {
if (a > b) {
return a;
} else {
return b;
}
}
函数的参数和返回值
如果把函数比喻成一台机器,那么参数就是原材料,返回值就是最终产品;函数的作用就是根据不同的参数产生不同的返回值。
函数的参数
在函数定义中出现的参数可以看做是一个占位符,它没有数据,只能等到函数被调用时接收传递进来的数据,所以称为形式参数,简称形参。
函数被调用时给出的参数包含了实实在在的数据,会被函数内部的代码使用,所以称为实际参数,简称实参。
形参和实参的功能是作数据传送,发生函数调用时,实参的值会传送给形参。
形参和实参有以下几个特点:
1) 形参变量只有在函数被调用时才会分配内存,调用结束后,立刻释放内存,所以形参变量只有在函数内部有效,不能在函数外部使用。
2) 实参可以是常量、变量、表达式、函数等,无论实参是何种类型的数据,在进行函数调用时,它们都必须有确定的值,以便把这些值传送给形参,所以应该提前用赋值、输入等办法使实参获得确定值。
3) 实参和形参在数量上、类型上、顺序上必须严格一致,否则会发生“类型不匹配”的错误。
函数的返回值
函数的返回值是指函数被调用之后,执行函数体中的程序段所取得的值,可以通过return语句返回。
函数中可以有多个 return 语句,但每次调用只能有一个return 语句被执行,所以只有一个返回值。
一旦遇到 return 语句,不管后面有没有代码,函数立即运行结束,将值返回。
没有返回值的函数为空类型,用void进行说明。
一旦函数的返回值类型被定义为 void,就不能再接收它的值了。例如,下面的语句是错误的:
int a = func();//error
为了使程序有良好的可读性并减少出错, 凡不要求返回值的函数都应定义为 void 类型。
函数的调用
所谓函数调用(Function Call),就是使用已经定义好的函数。函数调用的一般形式为:
函数名(实参列表);
实参可以是常数、变量、表达式等,多个实参用逗号,分隔。
在C语言中,函数调用的方式有多种,例如:
//函数作为表达式中的一项出现在表达式中
z = max(x, y);
m = n + max(x, y);
//函数作为一个单独的语句
printf("%d", a);
scanf("%d", &b);
//函数作为调用另一个函数时的实参
printf( "%d", max(x, y) );
total( max(x, y), min(m, n) );
在函数调用中还应该注意的一个问题是求值顺序。所谓求值顺序是指对实参列表中各个参数是自左向右使用呢,还是自右向左使用。对此,不同编译器的规则可能不同。请看下面一段代码:
#include <stdio.h>
int main(){
int i=8;
printf("%d %d %d %d\n",++i,++i,--i,--i);
return 0;
}
在 VC6.0 和 C-Free 5.0 下的运行结果为:
8 7 6 7
在 Xcode 下的运行结果为:
9 10 9 8
可见 VC 6.0 和 C-Free 5.0 是按照从右至左的顺序求值,而 Xcode 相反,按照从左向右的顺序求值。
函数的嵌套调用
函数不能嵌套定义,但可以嵌套调用,也就是在一个函数的定义或调用过程中出现对另外一个函数的调用。
如果一个函数 A() 在定义或调用过程中出现了对另外一个函数 B() 的调用,那么我们就称 A() 为主调函数或主函数,称 B() 为被调函数。
当主调函数遇到被调函数时,主调函数会暂停,CPU 转而执行被调函数的代码;被调函数执行完毕后再返回主调函数,主调函数根据刚才的状态继续往下执行。
一个C语言程序的执行过程可以认为是多个函数之间的相互调用过程,它们形成了一个或简单或复杂的调用链条。这个链条的起点是 main(),终点也是 main()。当 main() 调用完了所有的函数,它会返回一个值(例如return 0;)来结束自己的生命,从而结束整个程序。
函数是一个可以重复使用的代码块,CPU 会一条一条地挨着执行其中的代码,当遇到函数调用时,CPU 首先要记录下当前代码块中下一条代码的地址(假设地址为 0X1000),然后跳转到另外一个代码块,执行完毕后再回来继续执行 0X1000 处的代码。整个过程相当于 CPU 开了一个小差,暂时放下手中的工作去做点别的事情,做完了再继续刚才的工作。
从上面的分析可以推断出,在所有函数之外进行加减乘除运算、使用 if…else 语句、调用一个函数等都是没有意义的,这些代码位于整个函数调用链条之外,永远都不会被执行到。C语言也禁止出现这种情况,会报语法错误,请看下面的代码:
#include <stdio.h>
int a = 10, b = 20, c;
//错误:不能出现加减乘除运算
c = a + b;
//错误:不能出现对其他函数的调用
printf("c.biancheng.net");
int main(){
return 0;
}
C语言函数的声明以及函数原型
C语言代码由上到下依次执行,原则上函数定义要出现在函数调用之前,否则就会报错。但在实际开发中,经常会在函数定义之前使用它们,这个时候就需要提前声明。
所谓声明(Declaration),就是告诉编译器我要使用这个函数,你现在没有找到它的定义不要紧,请不要报错,稍后我会把定义补上。
函数声明的格式非常简单,相当于去掉函数定义中的函数体再加上分号;,如下所示:
返回值类型 函数名( 类型 形参, 类型 形参… );
也可以不写形参,只写数据类型:
返回值类型 函数名( 类型, 类型…);
函数声明给出了函数名、返回值类型、参数列表(参数类型)等与该函数有关的信息,称为函数原型(Function Prototype)。
函数原型的作用是告诉编译器与该函数有关的信息,让编译器知道函数的存在,以及存在的形式,即使函数暂时没有定义,编译器也知道如何使用它。
有了函数声明,函数定义就可以出现在任何地方了,甚至是其他文件、静态链接库、动态链接库等。
我们知道,使用 printf()、puts()、scanf()、getchar() 等函数要引入 stdio.h 这个头文件,很多初学者认为 stdio.h 中包含了函数定义(也就是函数体),只要有了头文件程序就能运行。其实不然,头文件中包含的都是函数声明,而不是函数定义,函数定义都在系统库中,只有头文件没有系统库在链接时就会报错,程序根本不能运行。
最后再补充一点,函数原型给出了使用该函数的所有细节,当我们不知道如何使用某个函数时,需要查找的是它的原型,而不是它的定义,我们往往不关心它的实现。
C/C++函数参考手册
http://www.cplusplus.com/
C语言函数的递归调用
递归条件
采用递归方法来解决问题,必须符合以下三个条件:
1、可以把要解决的问题转化为一个新问题,而这个新的问题的解决方法仍与原来的解决方法相同,只是所处理的对象有规律地递增或递减。
说明:解决问题的方法相同,调用函数的参数每次不同(有规律的递增或递减),如果没有规律也就不能适用递归调用。
2、可以应用这个转化过程使问题得到解决。
说明:使用其他的办法比较麻烦或很难解决,而使用递归的方法可以很好地解决问题。
3、必定要有一个明确的结束递归的条件。
说明:一定要能够在适当的地方结束递归调用。不然可能导致系统崩溃。
递归说明
1、当函数自己调用自己时,系统将自动把函数中当前的变量和形参暂时保留起来,在新一轮的调用过程中,系统为新调用的函数所用到的变量和形参开辟另外的存 储单元(内存空间)。每次调用函数所使用的变量在不同的内存空间。
2、递归调用的层次越多,同名变量的占用的存储单元也就越多。一定要记住,每次函数的调用,系统都会为该函数的变量开辟新的内存空间。
3、当本次调用的函数运行结束时,系统将释放本次调用时所占用的内存空间。程序的流程返回到上一层的调用点,同时取得当初进入该层时,函数中的变量和形参 所占用的内存空间的数据。
4、所有递归问题都可以用非递归的方法来解决,但对于一些比较复杂的递归问题用非递归的方法往往使程序变得十分复杂难以读懂,而函数的递归调用在解决这类 问题时能使程序简洁明了有较好的可读性;但由于递归调用过程中,系统要为每一层调用中的变量开辟内存空间、要记住每一层调用后的返回点、要增加许多额外的 开销,因此函数的递归调用通常会降低程序的运行效率。
//n的阶乘问题
#include <stdio.h>
int fac(int);
int main (void) {
int n = 3;
printf("%d\n", fac(n));
return 0;
}
int fac(int n) /*每次调用使用不同的参数*/
{
int t; /*每次调用都会为变量t开辟不同的内存空间*/
if( (n==1)||(n==0) ) /*当满足这些条件返回1 */
return 1;
else
{
t=n*fac(n-1); /*每次程序运行到此处就会用n-1作为参数再调用一次本函数,此处是调用点*/
return t; /*只有在上一句调用的所有过程全部结束时才运行到此处。*/
}
}
C语言模块化开发(多文件编程)
在C语言中,我们可以将一个.c文件称为一个模块(Module);所谓模块化开发,是指一个程序包含了多个源文件(.c 文件)以及头文件(.h 文件)。
C语言代码要经过编译和链接才能生成可执行程序:
编译是针对单个源文件(.c 文件)的,有多少个源文件就生成多少个目标文件,并且在生成过程中不受其他源文件的影响。也就是说,每个源文件都是独立编译的。
链接器的作用就是将这些目标文件拼装成一个可执行程序,并为代码(函数)和数据(变量、字符串等)分配好虚拟地址,这和搭积木的过程有点类似。
从源代码生成可执行文件的内部机理
如果在 Linux 下使用 GCC 来编译,使用最简单的$gcc demo.c命令,就可以在当前目录下看到 a.out。
事实上,从源代码生成可执行文件可以分为四个步骤,分别是预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。下图是 GCC 生成 a.out 的过程:
而目标文件的结构、可执行文件的结构、链接的过程是我们要重点研究的,它能够让我们明白多文件编程以及模块化开发的原理,这是大型项目开发的基石。
最后需要说明的是:汇编的过程非常简单,仅仅是查表翻译,我们通常把它作为编译过程的一部分,不再单独提及。这样,源文件经过预处理、编译和链接就生成了可执行文件。
模块化开发
现代软件的规模往往都很大,动辄数百万行代码,程序员需要把它们分散到成百上千个模块中。这些模块之间相互依赖又相互独立,原则上每个模块都可以单独开发、编译、测试,改变一个模块中的代码不需要编译整个程序。
在C语言中,一个模块可以认为是一个源文件(.c 文件)。
在程序被分隔成多个模块后,需要解决的一个重要问题是如何将这些模块组合成一个单一的可执行程序。在C语言中,模块之间的依赖关系主要有两种:一种是模块间的函数调用,另外一种是模块间的变量访问。
函数调用需要知道函数的首地址,变量访问需要知道变量的地址,所以这两种方式可以归结为一种,那就是模块间的符号引用。
模块间依靠符号来“通信”类似于拼图版,定义符号的模块多出一个区域,引用符号的模块刚好少了那一块区域,两者刚好完美组合。如下图所示:
这种通过符号将多个模块拼接为一个独立的程序的过程就叫做链接(Linking)。
在项目开发中,我们可以将一组相关的变量和函数定义在一个 .c 文件中,并用一个同名的 .h 文件(头文件)进行声明,其他模块如果需要使用某个变量或函数,那么引入这个头文件就可以。
这样做的另外一个好处是可以保护版权,我们在发布相关模块之前,可以将它们都编译成目标文件,或者打包成静态库,只要向用户提供头文件,用户就可以将这些模块链接到自己的程序中。
static关键字的用法和概念引入
我们知道,全局变量和函数的作用域默认是整个程序,也就是所有的源文件,这给程序的模块化开发带来了很大方便,让我们能够在模块 A 中调用模块 B 中定义的变量和函数,而不用把所有的代码都集中到一个模块。
但这有时候也会引发命名冲突的问题,例如在 a.c 中定义了一个变量 n,在 b.c 中又定义了一次,链接时就会发生重复定义错误,原因很简单,变量只能定义一次。
如果两个文件都是我们自己编写的或者其中一个是,遇到这样的情况还比较好处理,修改变量的名字即可;如果两个文件都是其他程序员编写的,或者是第三方的库,修改起来就颇费精力了。
实际开发中,我们通常将不需要被其他模块调用的全局变量或函数用 static 关键字来修饰,static 能够将全局变量和函数的作用域限制在当前文件中,在其他文件中无效。
总结起来,static 变量主要有两个作用:
1) 隐藏
程序有多个模块时,将全局变量或函数的作用范围限制在当前模块,对其他模块隐藏。
2) 保持变量内容的持久化
将局部变量存储到全局数据区,使它不会随着函数调用结束而被销毁。
注意extern和static不能一起使用。
extern修饰全局变量和函数,被修饰的变量和函数可以在别的文件里使用。
而static修饰的变量和函数作用范围仅限于定义它的文件内部。
两者是冲突的。
扩展
编译原理的初步认识
包括内容:词法分析、语法分析、代码生成、代码优化等
- 词法分析——正则表达式和自动机原理
- 语法分析——语法分析算法(自顶向下和自底向上)
编译本身是两个不同逻辑描述方式的映射,这还不涉及到连接的工作。连接是针对软硬件底层环境做的可运行态前的规范化组织。
所以编译,其实也可以等同翻译。和解释其实没太多区别。你说解释是一条条的,难道高级的解释性语言就不能全局优化后,再执行吗?显然也可以。
编译和解释,我的理解最大区别在于编译包含了编译的后端,针对逻辑描述,使用一个具体指令集进行实现。而解释不存在这个工作。其他方面都没有本质区别。
编译和解释也不存在什么效率不效率的本质区别,编译差的性能一样差。有些事情,解释执行和编译执行性能也几乎相同。特别是大家都是更多的调用已有库,而不是程序自身逻辑占用了大量的计算时间。
也不存在,编译一定出来的是具体机器指令,这个存在指令集映射是两回事。编译完全可以作成VM指令集,然后在不同硬件上再次做二次编译。这也可以叫做编译。
至于编译和解释是怎么实现的。哈,这个就复杂了。我认为计算机里面目前能看到的,逻辑最复杂的系统,无非是OS和编译器。数据库如果抛掉这两个组成部分,其他的部分并不复杂。
编译器我个人的理解,基本流程是
1、文本的组织整理,信息的识别。
2、语法逻辑的验证,由此获取无矛盾或冲突的逻辑序列。
3、根据逻辑序列,采用各种方法进行在保证前后关联性的前提下,进行优化整理,并获取逻辑执行表。
4、根据逻辑执行表匹配指令集。
基本这样。无非优势根绝外部的评价标准的改变,存在个二次再编译的循环过程而已。
当然以上只是原理,看起来简单,实际也不难,但确实非常烦。
之前一直不清楚编译原理到底讲的什么,后面听了老师讲解才明白,原来是这么回事,为了方便以后的查找,所以今天把它记了下来。
编译原理分为四步 :
- 1:预处理:头文件处理、宏定义处理、条件变量处理、特殊标志符处理、去掉注释;执行的代码与生成的文件:gcc -E .c -o .i
- 2:编译处理:对词法、语法、语义进行分析,是否正确,最后生成符号。执行的代码与生成的文件:gcc -S .i -o .s
- 3:汇编处理:将编译处理后的.s文件翻译成二进制代码,执行的代码与生成的文件:gcc -c .s -o *.o
- 4:链接:找符号、确定data与code段的地址,执行代码与生成的文件:gcc -o * *.o
- 编译完成后就是执行,在执行的过程中才确定堆栈的地址
内存分三段