形参和实参
下面我们定义一个带参数的函数,我们需要在函数定义中指明参数的个数和每个参数的类型,定义参数就像定义变量一样,需要为每个参数指明类型,参数的命名也要遵循标识符命名规则。例如:
带参数的自定义函数
#include<stdio.h> void print_time(int hour,int minute) { printf("%d:%d\n",hour,minute); } int main(void) { printf(23,59); return 0; }
需要注意的是,定义变量时可以把相同类型的变量列在一起,而定义参数却不可以,例如下面这样的定义是错的:
void print_time(int hour,minute) { printf("%d:%d\n",hour,minute); }
学习C语言的人肯定都乐意看到这句话:“变量是这样定义的,参数也是这样定义的,一模一样”,这意味着不用专门去记住参数应该怎么定义了。谁也不愿意看到这句话:“定义变量可以这样写,而定义参数却不可以”。C语言的设计者也不希望自己设计的语法规则里到处都是例外,一个容易被用户接受的设计应该遵循最少例外原则(Rule of Least Surprise)。其实关于参数的这条规定也不算十分例外,也是可以理解的,请读者想想为什么要这么规定。学习编程语言不应该死记各种语法规定,如果能够想清楚设计者这么规定的原因(Rationale),不仅有助于记忆,而且会有更多收获。
总的来说,C语言的设计是非常优美的,只要理解了少数基本概念和基本原则就可以根据组合规则写出任意复杂的程序,很少有例外的规定说这样组合是不允许的,或者那样类推是错误的。相反,C++的设计就非常复杂,充满了例外,全世界没几个人能把C++的所有规则都牢记于心,因而C++的设计一直饱受争议.
我们调用print_time(23, 59)时,函数中的参数hour就代表23,参数minute就代表59。确切地说,当我们讨论函数中的hour这个参数时,我们所说的“参数”是指形参(Parameter),当我们讨论传一个参数23给函数时,我们所说的“参数”是指实参(Argument),但我习惯都叫参数而不习惯总把形参、实参这两个文绉绉的词挂在嘴边(事实上大多数人都不习惯),读者可以根据上下文判断我说的到底是形参还是实参。记住这条基本原理:形参相当于函数中定义的变量,调用函数传递参数的过程相当于定义形参变量并且用实参的值来初始化。例如这样调用:
void printf_time(int hour,int minute) { printf("%d:%d\n",hour,minute); } int main(void) { int h=23,m=59; printf_time(h,m); return 0; }
相当于在函数print_time中执行了这样一些语句:
1. int hour = h; 2. int minute = m; 3. printf("%d:%d\n", hour, minute);
main函数的变量h和print_time函数的参数hour是两个不同的变量,只不过它们的存储空间中都保存了相同的值23,因为变量h的值赋给了参数hour。同理,变量m的值赋给了参数minute。C语言的这种传递参数的方式称为Call by Value。在调用函数时,每个参数都需要得到一个值,函数定义中有几个形参,在调用时就要传几个实参,不能多也不能少,每个参数的类型也必须对应上。
肯定有读者注意到了,为什么我们每次调用printf传的实参个数都不一样呢?因为C语言规定了一种特殊的参数列表格式,用命令man 3 printf可以查看到printf函数的原型:
int printf(const char *format, ...);
第一个参数是const char *类型的,后面的...可以代表0个或任意多个参数,这些参数的类型也是不确定的,这称为可变参数(Variable Argument),在后面将详细讲解。总之,每个函数的原型都明确规定了返回值类型以及参数的类型和个数,即使像printf这样规定为“不确定”也是一种明确的规定,调用函数时要严格遵守这些规定,有时候我们把函数叫做接口(Interface),调用函数就是使用这个接口,使用接口的前提是必须和接口保持一致。
知识拓展: Man PageMan Page是Linux开发最常用的参考手册,由很多页面组成,每个页面描述一个主题,这些页面被组织成若干个Section。FHS(Filesystem Hierarchy Standard)标准规定了ManPage各Section的含义如下:表 3.1. Man Page的 Section
注意区分用户命令和系统管理命令,用户命令通常位于 /bin和 /usr/bin目录,系统管理命令通常位于 /sbin和 /usr/sbin目录,一般用户可以执行用户命令,而执行系统管理命令经常需要 root权限。
Man Page中有些页面有重名,比如敲 man printf命令看到的并不是C函数 printf,而是位于第1Section的系统命令 printf,要查看位于第3个Section的 printf函数应该敲 man 3 printf,也可以敲 man -k printf命令搜索哪些页面的主题包含 printf关键字。本书会经常出现类似 printf(3)这样的写法,括号中的3表示Man Page的第3个Section,或者表示“我这里想说的是 printf库函数而不是 printf命令”。
定义一个函数increment,它的作用是把传进来的参数加1。例如:
void increment(int x) { x=x+1; } int main() { int i=1,j=2; increment(i); increment(j); return 0; }
我们在main函数中调用increment增加变量i和j的值,这样能奏效吗?为什么?
看运行结果没有变,因为调用时传进去的是一个值(一个数值),无论在increment内如何变都只变的是increment内对应的变量的值,与外面的变量无关。除非传进去一个地址比如increment(int *a),那么就明确到某一个位置固定的变量,这样对其修改即奏效。
全局变量,局部变量和作用域
我们把函数中定义的变量称为局部变量(Local Variable),由于形参相当于函数中定义的变量,所以形参也是一种局部变量。在这里“局部”有两层含义:
- 一个函数中定义的变量不能被另一个函数使用。例如print_time中的hour和minute在main函数中没有定义,不能使用,同样main函数中的局部变量也不能被print_time函数使用。如果这样定义:
void print_time(int hour, int minute) { printf("%d:%d\n", hour, minute); } int main(void) { int hour = 23, minute = 59; print_time(hour, minute); return 0; }
main函数中定义了局部变量hour,print_time函数中也有参数hour,虽然它们名称相同,但仍然是两个不同的变量,代表不同的存储单元。main函数的局部变量minute和print_time函数的参数minute也是如此。
2.每次调用函数时局部变量都表示不同的存储空间。局部变量在每次函数调用时分配存储空间,在每次函数返回时释放存储空间,例如调用print_time(23, 59)时分配hour和minute两个变量的存储空间,在里面分别存上23和59,函数返回时释放它们的存储空间,下次再调用print_time(12,20)时又分配hour和minute的存储空间,在里面分别存上12和20。
与局部变量的概念相对的是全局变量(Global Variable),全局变量定义在所有的函数体之外,它们在程序开始运行时分配存储空间,在程序结束时释放存储空间,在任何函数中都可以访问全局变量,例如:
全局变量
#include <stdio.h> int hour = 23, minute = 59; void print_time(void) { printf("%d:%d in print_time\n", hour, minute); } int main(void) { print_time(); printf("%d:%d in main\n", hour, minute); return 0; }
正因为全局变量在任何函数中都可以访问,所以在程序运行过程中全局变量被读写的顺序从源代码中是看不出来的,源代码的书写顺序并不能反映函数的调用顺序。程序出现了Bug往往就是因为在某个不起眼的地方对全局变量的读写顺序不正确,如果代码规模很大,这种错误是很难找到的。而对局部变量的访问不仅局限在一个函数内部,而且局限在一次函数调用之中,从函数的源代码很容易看出访问的先后顺序是怎样的,所以比较容易找到Bug。因此,虽然全局变量用起来很方便,但一定要慎用,能用函数传参代替的就不要用全局变量。
如果全局变量和局部变量重名了会怎么样呢?如果上面的例子改为:
则第一次调用print_time打印的是全局变量的值,第二次直接调用printf打印的则是main函数局部变量的值。在C语言中每个标识符都有特定的作用域,全局变量是定义在所有函数体之外的标识符,它的作用域从定义的位置开始直到源文件结束,而main函数局部变量的作用域仅限于main函数之中。如上图所示,设想整个源文件是一张大纸,也就是全局变量的作用域,而main函数是盖在这张大纸上的一张小纸,也就是main函数局部变量的作用域。在小纸上用到标识符hour和minute时应该参考小纸上的定义,因为大纸(全局变量的作用域)被盖住了,如果在小纸上用到某个标识符却没有找到它的定义,那么再去翻看下面的大纸上有没有定义,例如上图中的变量x。
到目前为止我们在初始化一个变量时都是用常量做Initializer,其实也可以用表达式做Initializer,但要注意一点:局部变量可以用类型相符的任意表达式来初始化,而全局变量只能用常量表达式(Constant Expression)初始化。例如,全局变量pi这样初始化是合法的:
double pi = 3.14 + 0.0016;
但这样初始化是不合法的:
double pi = acos(-1.0);
然而局部变量这样初始化却是可以的。程序开始运行时要用适当的值来初始化全局变量,所以初始值必须保存在编译生成的可执行文件中,因此初始值在编译时就要计算出来,然而上面第二种Initializer的值必须在程序运行时调用acos函数才能得到,所以不能用来初始化全局变量。请注意区分编译时和运行时这两个概念。为了简化编译器的实现,C语言从语法上规定全局变量只能用常量表达式来初始化,因此下面这种全局变量初始化是不合法的:
1. int minute = 360; 2. int hour = minute / 60
虽然在编译时计算出hour的初始值是可能的,但是minute / 60不是常量表达式,不符合语法规定,所以编译器不必想办法去算这个初始值。
如果全局变量在定义时不初始化则初始值是0,如果局部变量在定义时不初始化则初始值是不确定的。所以,局部变量在使用之前一定要先赋值,如果基于一个不确定的值做后续计算肯定会引入Bug。
如何证明“局部变量的存储空间在每次函数调用时分配,在函数返回时释放”?当我们想要确认某些语法规则时,可以查教材,也可以查C99,但最快捷的办法就是编个小程序验证一下:
验证局部变量存储空间的分配和释放
#include <stdio.h> void foo(void) { int i; printf("%d\n", i); i = 777; } int main(void) { foo(); foo(); return 0; }
第一次调用foo函数,分配变量i的存储空间,然后打印i的值,由于i未初始化,打印的应该是一个不确定的值,然后把i赋值为777,函数返回,释放i的存储空间。第二次调用foo函数,分配变量i的存储空间,然后打印i的值,由于i未初始化,如果打印的又是一个不确定的值,就证明了“局部变量的存储空间在每次函数调用时分配,在函数返回时释放”。分析完了,我们运行程序看看是不是像我们分析的这样:
134518128 777
结果出乎意料,第二次调用打印的i值正是第一次调用末尾赋给i的值777。有一种初学者是这样,原本就没有把这条语法规则记牢,或者对自己的记忆力没信心,看到这个结果就会想:哦那肯定是我记错了,改过来记吧,应该是“函数中的局部变量具有一直存在的固定的存储空间,每次函数调用时使用它,返回时也不释放,再次调用函数时它应该还能保持上次的值”。还有一种初学者是怀疑论者或不可知论者,看到这个结果就会想:教材上明明说“局部变量的存储空间在每次函数调用时分配,在函数返回时释放”,那一定是教材写错了,教材也是人写的,是人写的就难免出错,哦,连C99也这么写的啊,C99也是人写的,也难免出错,或者C99也许没错,但是反正运行结果就是错了,计算机这东西真靠不住,太容易受电磁干扰和宇宙射线影响了,我的程序写得再正确也有可能被干扰得不能正确运行。
这是初学者最常见的两种心态。不从客观事实和逻辑推理出发分析问题的真正原因,而仅凭主观臆断胡乱给问题定性,“说你有罪你就有罪”。先不要胡乱怀疑,我们再做一次实验,在两次foo函数调用之间插一个别的函数调用,结果就大不相同了:
int main(void) { foo(); printf("hello\n"); foo(); return 0; }
结果是:
1. 134518200 2. hello 3. 0
这一回,第二次调用foo打印的i值又不是777了而是0,“局部变量的存储空间在每次函数调用时分配,在函数返回时释放”这个结论似乎对了,但另一个结论又不对了:全局变量不初始化才是0啊,不是说“局部变量不初始化则初值不确定”吗?
关键的一点是,我说“初值不确定”,有没有说这个不确定值不能是0?有没有说这个不确定值不能是上次调用赋的值?在这里“不确定”的准确含义是:每次调用这个函数时局部变量的初值可能不一样,运行环境不同,函数的调用次序不同,都会影响到局部变量的初值。在运用逻辑推理时一定要注意,不要把必要条件(Necessary Condition)当充分条件(Sufficient Condition),这一点在Debug时尤其重要,看到错误现象不要轻易断定原因是什么,一定要考虑再三,找出它的真正原因。例如,不要看到第二次调用打印777就下结论“函数中的局部变量具有一直存在的固定的存储空间,每次函数调用时使用它,返回时也不释放,再次调用函数时它应该还能保持上次的值”,这个结论倒是能推出777这个结果,但反过来由777这个结果却不能推出这样的结论。所以说777这个结果是该结论的必要条件,但不是充分条件。也不要看到第二次调用打印0就断定“局部变量未初始化则初值为0”,0这个结果是该结论的必要条件,但也不是充分条件。至于为什么会有这些现象,为什么这个不确定的值刚好是777,或者刚好是0,下面章节会详细讲。
从自定义函数介绍的语法规则可以看出,非定义的函数声明也可以写在局部作用域中,例如:
int main(void) { void print_time(int, int); print_time(23, 59); return 0; }
这样声明的标识符print_time具有局部作域,只在main函数中是有效的函数名,出了main函数就不
存在print_time这个标识符了。写非定义的函数声明时参数可以只写类型而不起名,例如上面代码中的void print_time(int,int);,只要告诉编译器参数类型是什么,编译器就能为print_time(23, 59)函数调用生成正确的指令。另外注意,虽然在一个函数体中可以声明另一个函数,但不能定义另一个函数,C语言不允许嵌套定义函数。
注意:但gcc的扩展特性允许嵌套定义函数,不做详细讨论。