本章介绍以下内容:
关键字:return
运算符:*(一元)、&(一元)
函数及其定义方式
如何使用参数和返回值
如何把指针变量用作函数参数
函数类型
ANSI C原型
递归
如何组织程序?C的设计思想是,把函数用作构件块。我们已经用过C标准库的函数,如printf()、scanf()、getchar()、putchar()和 strlen()。现在要进一步学习如何创建自己的函数。前面章节中已大致介绍了相关过程,本章将巩固以前学过的知识并做进一步的拓展。
9.1 复习函数
什么是函数?函数(function)是完成特定任务的独立程序代码单元。语法规则定义了函数的结构和使用方式。虽然C中的函数和其他语言中的函数、子程序、过程作用相同,但是细节上略有不同。一些函数执行某些动作,如printf()把数据打印到屏幕上;一些函数找出一个值供程序使用,如strlen()把指定字符串的长度返回给程序。一般而言,函数可以同时具备以上两种功能。
为什么要使用函数?首先,使用函数可以省去编写重复代码的苦差。如果程序要多次完成某项任务,那么只需编写一个合适的函数,就可以在需要时使用这个函数,或者在不同的程序中使用该函数,就像许多程序中使用putchar()一样。其次,即使程序只完成某项任务一次,也值得使用函数。因为函数让程序更加模块化,从而提高了程序代码的可读性,更方便后期修改、完善。
许多程序员喜欢把函数看作是根据传入信息(输入)及其生成的值或响应的动作(输出)来定义的“黑盒”。
9.1.1 创建并使用简单函数
9.1.2 分析程序
程序在3处使用了starbar标识符:函数原型(function prototype)告诉编译器函数starbar()的类型;函数调用(function call)表明在此处执行函数;函数定义(function definition)明确地指定了函数要做什么。
函数和变量一样,有多种类型。任何程序在使用函数之前都要声明该函数的类型。因此,在main()函数定义的前面出现了下面的ANSI C风格的函数原型:
void starbar(void);
圆括号表明starbar是一个函数名。第1个void是函数类型,void类型表明函数没有返回值。第2个void(在圆括号中)表明该函数不带参数。分号表明这是在声明函数,不是定义函数
程序把 starbar()原型置于 main()的前面。当然,也可以放在 main()里面的声明变量处。放在哪个位置都可以。
starbar()函数中的变量count是局部变量(local variable),意思是该变量只属于starbar()函数。可以在程序中的其他地方(包括main()中)使用count,这不会引起名称冲突,它们是同名的不同变量。
9.1.3 函数参数
9.1.4 定义带形式参数的函数
该行告知编译器show_n_char()使用两个参数ch和num,ch是char类型,num是int类型。这两个变量被称为形式参数(formal argument,但是最近的标准推荐使用formal parameter),简称形参。和定义在函数中变量一样,形式参数也是局部变量,属该函数私有。这意味着在其他函数中使用同名变量不会引起名称冲突。每次调用函数,就会给这些变量赋值。
注意,ANSI C要求在每个变量前都声明其类型。也就是说,不能像普通变量声明那样使用同一类型的变量列表:
void dibs(int x, y, z) /* 无效的函数头 */
void dubs(int x, int y, int z) /* 有效的函数头 */
ANSI C也接受ANSI C之前的形式,但是将其视为废弃不用的形式:
9.1.5 声明带形式参数函数的原型
当函数接受参数时,函数原型用逗号分隔的列表指明参数的数量和类型。根据个人喜好,你也可以省略变量名:
void show_n_char(char, int);
在原型中使用变量名并没有实际创建变量,char仅代表了一个char类型的变量,
9.1.6 调用带实际参数的函数
实际参数可以是常量、变量,或甚至是更复杂的表达式。无论实际参数是何种形式都要被求值,然后该值被拷贝给被调函数相应的形式参数。
实际参数是具体的值,该值要被赋给作为形式参数的变量
实际参数是出现在函数调用圆括号中的表达式。形式参数是函数定义的函数头中声明的变量。调用函数时,创建了声明为形式参数的变量并初始化为实际参数的求值结果
9.1.7 黑盒视角
使用同名变量,那么它们相互独立,互不影响
黑盒里发生了什么对主调函数是不可见的
9.1.8 使用return从函数中返回值
不能。因为主调函数甚至不知道min的存在。
返回值不仅可以赋给变量,也可以被用作表达式的一部分。
返回值不一定是变量的值,也可以是任意表达式的值。
实际得到的返回值相当于把函数中指定的返回值赋给与函数类型相同的变量所得到的值
使用 return 语句的另一个作用是,终止函数并把控制返回给主调函数的下一条语句。
但是,在函数中使用多个return语句也没有错
return语句导致printf()语句永远不会被执行。
return;
这条语句会导致终止函数,并把控制返回给主调函数。因为 return 后面没有任何表达式,所以没有返回值,只有在void函数中才会用到这种形式。
9.1.9 函数类型
带返回值的函数类型应该与其返回值类型相同,而没有返回值的函数应声明为void类型。
9.2 ANSI C原型
9.2.1 问题所在
9.2.2 ANSI的解决方案
如果两个参数都是数字,但是类型不匹配,编译器会把实际参数的类型转换成形式参数的类型。
9.2.3 无参数和未指定参数
9.2.4 函数原型的优点
9.3 递归
C允许函数调用它自己,这种调用过程称为递归(recursion)。
可以使用循环的地方通常都可以使用递归。有时用循环解决问题比较好,但有时用递归更好。递归方案更简洁,但效率却没有循环高。
9.3.1 演示递归
注意,每级递归的变量 n 都属于本级递归私有。这从程序输出的地址值可以看出(当然,不同的系统表示的地址格式不同,这里关键要注意,Level 1和LEVEL 1的地址相同,Level 2和LEVEL 2的地址相同,等等)。
9.3.2 递归的基本原理
1.每级函数调用都有自己的变量。也就是说,第1级的n和第2级的n不同,所以程序创建了4个单独的变量,每个变量名都是n,但是它们的值各不相同。当程序最终返回 up_and_down()的第1 级调用时,最初的n仍然是它的初值1
2.每次函数调用都会返回一次。当函数执行完毕后,控制权将被传回上一级递归。程序必须按顺序逐级返回递归,从某级up_and_down()返回上一级的up_and_down(),不能跳级回到main()中的第1级调用。
3.递归函数中位于递归调用之前的语句,均按被调函数的顺序执行。例如,程序清单9.6中的打印语句#1位于递归调用之前,它按照递归的顺序:第1级、第2级、第3级和第4级,被执行了4次。
4.第4,递归函数中位于递归调用之后的语句,均按被调函数相反的顺序执行。例如,打印语句#2位于递归调用之后,其执行的顺序是第4级、第3级、第2级、第1级。递归调用的这种特性在解决涉及相反顺序的编程问题时很有用。稍后将介绍一个这样的例子。
5.第5,虽然每级递归都有自己的变量,但是并没有拷贝函数的代码。程序按顺序执行函数中的代码,而递归调用就相当于又从头开始执行函数的代码。除了为每次递归调用创建变量外,递归调用非常类似于一个循环语句。实际上,递归有时可用循环来代替,循环有时也能用递归来代替。
6.最后,递归函数必须包含能让递归调用停止的语句。通常,递归函数都使用if或其他等价的测试条件在函数形参等于某特定值时终止递归。为此,每次递归调用的形参都要使用不同的值。例如,程序清单9.6中的up_and_down(n)调用up_and_down(n+1)。最终,实际参数等于4时,if的测试条件(n < 4)为假。
图9.4 递归中的变量
9.3.3 尾递归
最简单的递归形式是把递归调用置于函数的末尾,即正好在 return 语句之前。这种形式的递归被称为尾递归(tail recursion),因为递归调用在函数的末尾。尾递归是最简单的递归形式,因为它相当于循环
注意,虽然rfact()的递归调用不是函数的最后一行,但是当n>0时,它是该函数执行的最后一条语句,因此它也是尾递归。
既然用递归和循环来计算都没问题,那么到底应该使用哪一个?一般而言,选择循环比较好。首先,每次递归都会创建一组变量,所以递归使用的内存更多,而且每次递归调用都会把创建的一组新变量放在栈中。递归调用的数量受限于内存空间。其次,由于每次函数调用要花费一定的时间,所以递归的执行速度较慢。
9.3.4 递归和倒序计算
递归在处理倒序时非常方便(在解决这类问题中,递归比循环简单)。
9.3.5 递归的优缺点
在本例中,指数增长的变量数量很快就消耗掉计算机的大量内存,很可能导致程序崩溃。
在程序中使用递归要特别注意,尤其是效率优先的程序。
所有的C函数皆平等
9.4 编译多源代码文件的程序
9.4.1 UNIX
假定在UNIX系统中安装了UNIX C编译器cc(最初的cc已经停用,但是许多UNIX系统都给cc命令起了一个别名用作其他编译器命令,典型的是gcc或clang)。假设file1.c和file2.c是两个内含C函数的文件,下面的命令将编译两个文件并生成一个名为a.out的可执行文件:
cc file1.c file2.c
另外,还生成两个名为file1.o和file2.o的目标文件。如果后来改动了file1.c,而file2.c不变,可以使用以下命令编译第1个文件,并与第2个文件的目标代码合并:
cc file1.c file2.o
UNIX系统的make命令可自动管理多文件程序,但是这超出了本书的讨论范围。
注意,OS X的Terminal工具可以打开UNIX命令行环境,但是必须先下载命令行编译器(GCC和Clang)。
9.4.2 Linux
假定Linux系统安装了GNU C编译器GCC。假设file1.c和file2.c是两个内含C函数的文件,下面的命令将编译两个文件并生成名为a.out的可执行文件:
gcc file1.c file2.c
另外,还生成两个名为file1.o和file2.o的目标文件。如果后来改动了file1.c,而file2.c不变,可以使用以下命令编译第1个文件,并与第2个文件的目标代码合并:
gcc file1.c file2.o
9.4.3 DOS命令行编译器
绝大多数DOS命令行编译器的工作原理和UNIX的cc命令类似,只不过使用不同的名称而已。其中一个区别是,对象文件的扩展名是.obj,而不是.o。一些编译器生成的不是目标代码文件,而是汇编语言或其他特殊代码的中间文件。
9.4.4 Windows和苹果的IDE编译器
Windows和Macintosh系统使用的集成开发环境中的编译器是面向项目的。项目(project)描述的是特定程序使用的资源。资源包括源代码文件。这种IDE中的编译器要创建项目来运行单文件程序。对于多文件程序,要使用相应的菜单命令,把源代码文件加入一个项目中。要确保所有的源代码文件都在项目列表中列出。许多IDE都不用在项目列表中列出头文件(即扩展名为.h的文件),因为项目只管理使用的源代码文件,源代码文件中的#include指令管理该文件中使用的头文件。但是,Xcode要在项目中添加头文件。
9.4.5 使用头文件
如果把main()放在第1个文件中,把函数定义放在第2个文件中,那么第1个文件仍然要使用函数原型。把函数原型放在头文件中,就不用在每次使用函数文件时都写出函数的原型。C 标准库就是这样做的,例如,把I/O函数原型放在stdio.h中,把数学函数原型放在math.h中。你也可以这样用自定义的函数文件。
总之,把函数原型和已定义的字符常量放在头文件中是一个良好的编程习惯。
9.5 查找地址:&运算符
指针(pointer)是 C 语言最重要的(有时也是最复杂的)概念之一,用于储存变量的地址。
概括地说,如果主调函数不使用return返回的值,则必须通过地址才能修改主调函数中的值。
一元&运算符给出变量的存储地址。如果pooh是变量名,那么&pooh是变量的地址。可以把地址看作是变量在内存中的位置。
9.6 更改主调函数中的变量
普通的排序任务中交换两个变量的值
temp = x;
x = y;
y = temp;
9.7 指针简介
指针(pointer)是一个值为内存地址的变量(或数据对象)
假设一个指针变量名是ptr,可以编写如下语句:
ptr = &pooh; // 把pooh的地址赋给ptr
对于这条语句,我们说ptr“指向”pooh。ptr和&pooh的区别是ptr是变量,而&pooh是常量。或者,ptr是可修改的左值,而&pooh是右值。还可以把ptr指向别处:
ptr = &bah; // 把ptr指向bah,而不是pooh
现在ptr的值是bah的地址。
9.7.1 简介运算符:*
使用间接运算符*(indirection operator)找出储存在bah中的值,该运算符有时也称为解引用运算符(dereferencing operator)。不要把间接运算符和二元乘法运算符(*)混淆,虽然它们使用的符号相同,但语法功能不同。
val = *ptr; // 找出ptr指向的值
语句ptr = &bah;和val = *ptr;放在一起相当于下面的语句:
val = bah;
小结:与指针相关的运算符
地址运算符:&
一般注解:
后跟一个变量名时,&给出该变量的地址。
示例:
&nurse表示变量nurse的地址。
地址运算符:*
一般注解:
后跟一个指针名或地址时,*给出储存在指针指向地址上的值。
示例:
nurse = 22;
ptr = &nurse; // 指向nurse的指针
val = *ptr; // 把ptr指向的地址上的值赋给val
执行以上3条语句的最终结果是把22赋给val。
9.7.2 声明指针
声明指针变量时必须指定指针所指向变量的类型
int * pi; // pi是指向int类型变量的指针
char * pc; // pc是指向char类型变量的指针
float * pf, * pg; // pf、pg都是指向float类型变量的指针
类型说明符表明了指针所指向对象的类型,星号(*)表明声明的变量是一个指针。int * pi;声明的意思是pi是一个指针,*pi是int类型
图9.5 声明并使用指针
*和指针名之间的空格可有可无。通常,程序员在声明时使用空格,在解引用变量时省略空格。
9.7.3 使用指针在函数间通信
那么传递的是x的值:
function1(x);
如果下面形式的函数调用,那么传递的是x的地址:
function2(&x);
第1种形式要求函数定义中的形式参数必须是一个与x的类型相同的变量:
int function1(int num)
第2种形式要求函数定义中的形式参数必须是一个指向正确类型的指针:
int function2(int * ptr)
如果要计算或处理值,那么使用第 1 种形式的函数调用;如果要在被调函数中改变主调函数的变量,则使用第2种形式的函数调用。
图9.6 按字节寻址系统(如PC)中变量的名称、地址和值
编写程序时,可以认为变量有两个属性:名称和值(还有其他性质,如类型,暂不讨论)。计算机编译和加载程序后,认为变量也有两个属性:地址和值。地址就是变量在计算机内部的名称。
普通变量把值作为基本量,把地址作为通过&运算符获得的派生量,而指针变量把地址作为基本量,把值作为通过*运算符获得的派生量。
小结:函数
形式:
典型的ANSI C函数的定义形式为:
返回类型 名称(形参声明列表)
函数体
形参声明列表是用逗号分隔的一系列变量声明。除形参变量外,函数的其他变量均在函数体的花括号之内声明。
示例:
int diff(int x, int y) // ANSI C
{ // 函数体开始
int z; // 声明局部变量
z = x - y;
return z; // 返回一个值
} // 函数体结束
传递值:
实参用于把值从主调函数传递给被调函数。如果变量a和b的值分别是5和2,那么调用:
c = diff(a,b);
把5和2分别传递给变量x和y。5和2称为实际参数(简称实参),diff()函数定义中的变量x和y称为形式参数(简称形参)。使用关键字return把被调函数中的一个值传回主调函数。本例中, c接受z的值3。被调函数一般不会改变主调函数中的变量,如果要改变,应使用指针作为参数。如果希望把更多的值传回主调函数,必须这么做。
函数的返回类型:
函数的返回类型指的是函数返回值的类型。如果返回值的类型与声明的返回类型不匹配,返回值将被转换成函数声明的返回类型。
函数签名:
函数的返回类型和形参列表构成了函数签名。因此,函数签名指定了传入函数的值的类型和函数返回值的类型。
示例:
double duff(double, int); // 函数原型
int main(void)
{
double q, x;
int n;
...
q = duff(x,n); //函数调用
...
}
double duff(double u, int k) //函数定义
{
double tor;
...
return tor; //返回double类型的值
}
9.8 关键概念
9.9 本章小结
函数可以作为组成大型程序的构件块。每个函数都应该有一个单独且定义好的功能。使用参数把值传给函数,使用关键字return把值返回函数。如果函数返回的值不是int类型,则必须在函数定义和函数原型中指定函数的类型。如果需要在被调函数中修改主调函数的变量,使用地址或指针作为参数。
ANSI C提供了一个强大的工具——函数原型,允许编译器验证函数调用中使用的参数个数和类型是否正确。
C 函数可以调用本身,这种调用方式被称为递归。一些编程问题要用递归来解决,但是递归不仅消耗内存多,效率不高,而且费时。