10-函数
函数
If you have a procedure with 10 parameters, you probably missed some. [^1]
目录
[TOC]
函数
零 前言
函数这个概念源自数学。在其他语言中,函数也叫方法,过程等。所以,编程中函数与数学中的函数是不同的。
我们早在第二章就接触到函数这一概念—— main 函数。当时我煞费苦心的尝试用通俗的话向你们解释 main 函数的构成以及各部分的功能。但我们刚开始学编程,对函数一定不能有一个比较深刻的认识。这一节,我将带领大家走进函数,仔细推敲品味函数。从这一节开始,使用函数的思想将会伴随我们今后的编程生涯。
一个程序可以实现很多复杂的功能,然而如果将所有的功能写在一个 main 函数中,显然是不科学的。大型程序的代码肯定不是几十行的事情,如果这样做,会让你在写代码和修改 bug 时崩溃。退一步说,对于一个项目来说,肯定时多人合作共同编写,都写在 main 函数内,就很僵硬了。
所以,函数就是一个子程序,它可以将我们的程序模块化,不同的函数实现不同的功能,然后再通过一定的方法让它们有机结合起来。将程序模块化,还可以减少不必要的重复——不用重复编写功能相同的代码。
这一章,会教你如何编写函数,并且更加深入的理解 main 函数本身。
一 函数的定义和调用
在介绍函数的定义之前,让我们先来看 3 个简单定义的函数。
这三个函数我就不详细分析了,你可以打开我之前讲 main 函数的构成那篇文章,和 main 函数对比着看。
1. 3 个 简单的函数
① 计算平均值
假设计算两个 double 类型的数值的平均值。
double average(double x, double y){ |
② 显示倒计数
不是每一个函数都有返回值:
void print_count(int n){ |
③ 显示双关语
不是每个函数都有参数:
void print_pun(){ |
2. 函数定义
返回类型 函数名(形式参数){ |
返回类型
函数的返回类型是函数返回值的类型。
- 函数不能返回数组
- 返回类型是void表示没有返回值
- 如果省略返回值,C89 会假设函数返回的是 int 类型;C99 中这是不合法的。
一些程序员喜欢将返回类型放在函数名的上边:
double |
如果返回类型很长,比如 unsigned long int
类型,那么这样写是非常有用的。
形式参数
每个形式参数前需要写明其类型,形参之间用逗号隔开。
【C语言程序设计——现代方法】这本书中写到:“如果函数没有形式参数,那么圆括号内应该出现 void ”
注意:即使几个形参具有相同的数据类型,也必须对每个形参分别进行类型说明。
double average(double a, b){// error |
函数体
C89 中,变量声明必须出现在语句之前。
C99 中,允许声明和语句混在一起,只要在第一次使用之前进行声明即可。
C89
// 声明 |
C99
// 语句 |
块
块(block):一对花括号内就是一个块
我们在讲循环时说过,如果你这样写 for 语句:
for(int i = 0; ; ){ |
在 for 语句内定义变量 i ,那么当 for 循环结束后,后面的程序没有办法再去使用 i 了,因为 i 已经不存在了。
for 语句的大括号其实就是一个块。
在块内定义的变量只属于这一个块,块外的程序是没有办法访问和修改块内定义的变量的。
如果你还是不理解,可以看看下一章内容中的作用域和生存期。
3. 函数调用
函数调用由函数名和实参列表组成,实参列表用圆括号括起来:
average(x, y); |
返回值非 void 的函数会产生一个值,该值可以存储在变量中,还可以进行测试,显示或者其他用途。
avg = average(x, y); |
如果不需要非 void 函数返回的值,总可以将其丢弃:
average(x, y); // discard return value |
average 函数的这个调用就是一个表达式语句的例子:计算出结果,但是不保存它
有时候我们可以直接将函数调用产生的结果当做 printf 函数的参数:
printf("%f", average(x, y)); |
这种做法其实也是丢弃了 average 的返回值。
说到丢弃返回值,我们最常用的两个函数 printf
和 scanf
也是有返回值的:
num_chars = printf("Hello World!\n"); // num_chars is now 13 |
我们看看 MSDN 中对 printf 返回值的描述:
The function returns the number of characters printed, or a negative value if an error occurs.
num = scanf("%d", &i); |
我们看看 MSDN 中对 scanf 返回值的描述:
scanf return the number of fields successfully converted and assigned; the return value does not include fields that were read but not assigned. A return value of 0 indicates that no fields were assigned.
为了清楚的表明函数的返回值是被故意丢弃的,C 语言通常允许在函数调用前加上 void
:
(void)printf("Hello World!\n"); |
如此一来,printf 函数的返回值强制类型转换成 void 类型。
但是,C 语言库中大量函数的返回值通常都会被丢掉;在调用它们时都使用 (void) 会很麻烦,所以我们一般不这么写。
程序:判断素数
编写程序提示用户录入数,然后给出一条信息说明此数是否为素数。
Enter a number: 24 |
把判断素数的实现写到另外一个函数中,此函数返回值为 true 就表示是素数,返回 false 表示不是素数。
参考程序:
|
main 函数中包含一个叫 n 的变量,is_prime 函数中也有一个叫 n 的变量。这两个变量是虽然同名,但是在内存中的地址不同,是完全不相同的。所以给其中一个变量赋新值不会影响另一个变量。下一章我们还会详细的讨论这个问题。
is_prime 函数中有多条 return 语句。但是任何一次函数调用只能执行其中一条 return 语句,这是因为执行 return 语句后函数就会返回到调用点。本节后面还会深入的学习 return 。
二 函数声明
在本节前面的程序中,函数的定义总是放置在调用点的上面。C 语言并没有要求函数的定义必须在调用点之前,如果我们这样写:
int main(void){ |
当程序执行到 main 函数中的 average 函数调用时,编译器没有任何关于 average 函数的信息:编译器不知道 average 函数有多少形式参数,形式参数的类型是什么,也不知道函数的返回值类型是什么。但是,编译器不会给出出错消息,而是假设 average 函数返回 int 型的值。我们可以说编译器为该函数创建了一个隐式声明(implicit declaration)。编译器无法检查传递欸 average 函数的参数个数和类型,只能进行默认的实际参数提升(见第三部分)并期待最好的情况发生。当编译器遇到后面的 average 函数时发现函数的返回类型是 double 而不是 int ,从而我们得到一个出错消息。
比如:
int average(int x, int y);// average 函数的隐式声明 |
为了避免这种定义前调用的问题,一种方法是使每个函数的定义都出现在其被调用之前。然而这种方法不够灵活,那么如何可以让函数的定义的位置可以自定义呢?
函数声明:(function declaration)在调用前声明每个函数使得编译器可以先对函数进行概要浏览,而函数的定义可以以后再给出。
函数的声明类似函数的第一行:
返回类型 函数名(形式参数列表); |
为 average 函数添加声明后程序的样子:
double average(double x, double y); |
为了和过去的那种圆括号内为空的函数声明风格相区别,我们把这种函数声明称为函数原型(function prototype)。函数原型为如何调用函数提供了完整的描述:返回值类型,实参个数和类型。
上面这句中“和过去的那种…”这里应该如何理解?我们用过去的方法对 average 函数的声明可以是这样的:
double average(); |
也就是可以不用写形参列表。
参考文章:https://www.cnblogs.com/pmer/archive/2011/09/04/2166579.html
函数原型 不需要说明函数形式参数的名称,只要显示类型即可:
double average(double, double); |
通常最好不要省略形参名称,因为这些名字可以说明每个形参的目的,并且提示程序员再函数调用时实际参数的顺序。
C99中遵循这样的规则:再调用一个函数之前,必须先对其进行声明或定义。如果没有声明而直接调用,会导致出错。
三 实际参数
形式参数:(parameter) 出现再函数的定义中
实际参数:(argument)出现在函数调用中的表达式。
在 C语言中,实际参数是通过值传递的:调用函数时,计算出每个实际参数的值并将它赋值给相应的形式参数。在函数执行的过程中,形式参数的改变不会影响实参的值,这是因为形式参数是实参的副本。从效果上来讲,每个形式参数初始化为相应的实参的值。
实际参数按值传递有利有弊。
-
利:可以直接修改形参的值
比如:计算 x 的 n 次方
int power(int x, int n){
int i = n;
int ret = 1;
for(i = 1; i <= n; i++){
ret *= x;
}
return ret;
}我们可以在函数内直接修改 n 来减少引入的变量:
int power(int x, int n){
int ret = 1;
while(n--){
ret *= x;
}
return ret;
} -
弊:如果我们需要函数返回一个以上的值,那么按值传递显然是无法直接做到的
例如:我们需要设计一个函数,将 double 类型的值分解成整数和小数部分。因为无法返回两个数,所以通过返回值返回我们计算出的整数部分和小数部分是不现实的。所以可以尝试传入两个变量给函数并修改它们:
void decompose(double x, long int_part, double frac_part){
int_part = (long)x; // drops the fractional part of x
frac_part = x - int_part;
}前面我们也说了,这显然也是不现实的。因为形参的改变无法修改实参。
如果你感到困惑,我们可以来测试一下:我们在 main 函数中调用这个函数:
int main(void){
double x = 3.1415926;
int i;
int d;
decompose(x, i, d);
printf("%d %f", i, d); // 编译应该会报错,提示 i,d 未初始化,总之,不是我们想要的结果
return 0;
}
1. 实际参数转换
C 语言允许实际参数类型和形式参数类型不匹配的情况下进行函数调用。
-
**编译器在调用前遇到原型:**就像赋值一样,将实际参数隐式转换为相应的形式参数的类型。
double average(double x, double y);
int main(void){
int a = 2, b = 3;
average(a, b);// a, b 被隐式类型转换为 double 类型然后赋值给形参
return 0;
} -
编译器在调用前没有遇到原型:编译器执行默认的实际参数提升:1)把 float 类型的实参转换为 double类型。2)把 char,short 类型的实参转换为 int 类型。
默认的实际参数提升可能无法产生期望的结果。思考下例:
int main(void){
double x = 3.0;
printf("Square: %d", square(x));
return 0;
}
int square(int n){
return n * n;
}double 类型的 x 被执行了没有效果的实际参数提升,square 实际期望 int 类型,但是却得到的是 double 类型,所以 square 将产生无效的结果。通过把 square 实参强转为正确类型可以解决这个问题:
printf("Square: %d", square((int)x));
**更好的方案是在函数调用前提供函数原型。**C99中,调用前不提供没有声明或定义是错误的。
2. 数组型实际参数
数组经常被当作实际参数。当形式参数为一维数组时,可以(而且是通常情况下)不说明数组长度:
int f(int a[]){ |
C 语言没有为函数提供任何简便的方法来确定传递给它的数组的长度,所以通常情况下,我们必须把数组长度作为额外的参数提供出来
示例:数组求和
int sum_array(int a[], int n); |
**注意:**虽然可以用运算符 sizeof
计算出数组变量的长度,但是它无法给出数组类型的形式参数参数的正确答案:
int f(int a[]){ |
原因在后面的章节会详细讨论。
上例中 sum_array
函数的函数原型可以省略形式参数的名称:
int sum_array(int [], int); |
在调用 sum_array 函数时,不要将顺序写反。
**注意:**把数组名传递给函数时,不要在数组名的后面放置方括号:
sum_array(a[], len); //error |
数组变量作为函数参数的特性
1)数组无法检测传入的数组长度是否正确,所以:
-
一个数组有 100 个元素,但是实际仅仅使用 50 个元素,实参可以只写 50:
sum_array(a, 50);
函数甚至不会知道数组还有 50 个元素存在!
-
如果实际参数给的比数组还要大,会造成数组越界,从而导致未定义行为
sum_array(a, 150);// wrong
2)在函数中改变数组型形式参数的元素,同时会改变实际参数的数组元素。
|
多维数组
多维数组的形式参数可以省略第一维的长度,比如a[][3]
但是,这样的方式不能传递具有任意列数的多维数组。幸运的是,我们通常可以通过使用指针数组的方式解决这一问题。
3. 变长数组形式参数 (C99)
4. 在数组参数声明中使用 static (C99)
5. 复合字面量(C99)
以上 3 种 C99 特性这里不做展开,因为我们不常用到,如果你有兴趣,可以自己查询。
四 return 语句
非 void 类型的函数必须使用
return
语句来指定将要返回的值。
return 表达式; |
表达式可以是
- 常量:
return 0
- 变量:
return a
- 复杂的表达式:
return n >= 0 ? n : 0
如果 return 语句表达式的值和返回类型不匹配,那么系统将把表达式的类型隐式转换为返回类型。
return 也可以出现在返回值类型为 void
的函数中:我们可以直接使用return;
(没有表达式)来让函数结束。
下面的例子中,如果 i 是负数,return 语句会让函数立即返回
void print_int(int i){ |
return 语句可以出现在 void 函数的末尾:
void print_pun(){ |
但是 return 语句不是必须的,因为在执行完最后一条语句后函数会自动返回。
如果非 void 函数到达了函数体的末尾(也就是没有 return 语句),那么程序会试图使用函数的返回值,其行为是未定义的。
有的编译器会有“control reaches end of non-void function” 这样的警告信息。
五 程序终止
既然 main 是函数,那么它必须拥有返回类型。正常情况下,main 函数的返回类型是 int 类型,因此我们见到的 main 函数都是这样定义的:
int main(void){ |
以往的 C程序常常省略 main 函数的返回类型,这其实是利用了返回类型默认为 int 类型的传统:
main(){ |
省略 main 函数的参数列表中的 void 是合法的。但是,从编程风格的角度来看,最好显式地表明 main 函数没有参数。后面将会看到,main 函数是有参数的(int main(int argc, char* argv[])
)
main 函数返回的值是状态码,在某些操作系统中程序终止时可以检测到状态码。如果程序正常终止,main 函数应该返回 0;为了表示异常终止,main 函数返回非 0 的值。(实际上,这一返回值也可以用于其他用途。)
1. exit 函数
在 main 函数中执行 return 语句时终止程序的一种办法,另一种方法是调用 exit 函数,此函数属于 <stdlib.h>
头。
exit(0)
表示程序正常终止。
使用 0 可能让人理解比较模糊,在 stdlib.h 头中定义了两个宏:
EXIT_SUCCESS
-> 0
EXIT_FAILURE
-> 1
所以我们可以写作:
exit(EXIT_SUCCESS);
其实 main 函数中使用 return 来终止程序时,return 本身回去调用 exit 函数,所以程序终止这件事最终是由 exit 函数实现的。
所以我们不去理解,不管那个函数调用 exit 函数都会使得程序终止,return 语句仅仅由 main 函数调用时才会让程序终止。
所以,一些程序员只是用 exit 函数表示程序终止,以便更容易定位程序中的全部退出点。
六 递归
如果一个函数调用它本身,那么此函数就是递归的(recursive)。
有些编程语言极度依赖递归,而有些编程语言甚至不允许使用递归。C语言介于中间:它允许递归,但是大多数 C 程序员并不经常使用递归。
用递归计算 n! 的结果:
int fact(int n){ |
为了了解递归的工作原理,一起来追踪下面这个语句的执行:
i = fact(3); |
fact(3) 发现 3 不是小于等于 1 的,fact(3) 调用 |
**注意:**要理解 fact 函数最终传递 1 之前,未完成的 fact 函数是如何“堆积”的。在最终传递 1 的那一点上,fact 函数逐个解开,直到 fact(3) 的原始调用返回 6 为止。
上面的程序也可以简化为:
int fact(int n){ |
注意: n <= 1
就是终止条件,为了放置无限递归,所有的递归都应该有终止条件。
如果你能理解下面两个程序,那么当前阶段的递归问题已经难不倒你了。
程序:快速排序
程序:归并排序
参考资料:《C语言程序设计:现代方法》
[^1]: 如果你写了一个需要10个参数的函数,你或许还漏了什么。Epigrams on Programming 编程警句