上篇我们结束了关于字符输入/输出和输入验证的学习,接下来我们就将展开关于函数的学习,主要学习关键字return 和 一元运算符* 、& 以及函数的定义方式,包括使用参数和返回值、将指针变量用作函数参数、函数类型、递归。

C语言的设计思想是把函数用作构件块,我们已经使用过C标准库的函数,现在来学习一下怎么创建自己的函数。

复习函数

函数是完成特定任务的独立代码程序代码单元,语法规则定义函数的结构和使用方式,虽然C中的函数和其他语言中的函数、子程序作用相同,细节略有不同。一些函数执行某些动作;一些函数找出一个值给程序使用,一般来说,函数可以同时具备以上的两种功能。

函数的使用可以省去重复编写代码的苦差,若是程序需要多次完成某项任务,只需要编写一个合适的函数,就可以在需要这个函数时,或者是在不同程序使用该函数;其次,即使程序只完成一次任务,也值得使用函数。这样的函数可以让程序的模块化,提高代码的可读性,方便后期修改完善。

下面来完成一个实例:

代码如下:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#define SIZE 50
int main(void)
{
float list[SIZE];
readlist(list, SIZE);
sort(list, SIZE);
average(list, SIZE);
bargraph(list, SIZE);
return 0;
}

当然我们还需要编写4个函数:readlist()、sort()、average()和bargraph()的实现细节和具体代码。描述性的函数名能清晰地表达函数的用途和组织结构。单独对每个函数进行设计和测试,直到每个函数都可以完成任务,若是通用还可以用于别的程序之中。

一般来说,函数会被看作是一个根据输入及其生成值或是响应的黑盒。若非自己编写的函数,其实无需关心黑盒的内部行为,例如,使用printf()的时候,我们只需要给这个函数传入格式字符串或一些参数以及printf()生成的输出,无需了解这个函数的代码。在动手编写代码之前需要先考虑一下代码具体的作用和任务。

创建并使用简单的函数

第一个目标是创建一个在一行打印40个星号的函数,并在一个打印表头的程序中使用该函数。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#define NAME "GIGATHINK, INC."
#define ADDRESS "101 Megabuck Plaza"
#define PLACE "Megapolis, CA 94904"
#define WIDTH 40
void starbar(void);
int main(void)
{
starbar();
printf("%s\n", NAME);
printf("%s\n", ADDRESS);
printf("%s\n", PLACE);
starbar();
return 0;
}

void starbar(void)
{
int count;
for (count = 1; count <= WIDTH; count++)
putchar('*');
putchar('\n');
}

分析程序

这个程序中需要注意以下几点,程序在3个地方使用了starbar标识符:函数原型是告诉编译器函数starbar()的类型;函数调用表明在此处执行函数;函数定义就是把代码要实现的功能完完整整的写下来。

函数和变量一样,有许多的类型,任何程序在使用函数之前都要声明该函数的类型,因此在main()函数定义的前面出现了ANSI C风格的函数原型:

void starbar(void);

圆括号表明starbar 是一个函数名,第一个void是函数类型,void类型说明这是一个没有返回值的函数,第二个void的意思是说明这个函数不带参数,分号说明这是在声明函数而不是在定义函数,并且告诉编译器在别处查找该函数的定义。

一般来说,函数原型指明了函数的返回值类型和函数接受的参数类型,这些统称为该函数的签名,对于starbar()函数来说,签名就是没有返回值,没有参数。对于不识别ANSI C 风格的编译器,只需要声明函数的类型,一些老版本的编译器甚至连void 都识别不了,但是若是这么老的版本最好还是换一个。

程序把starbar()原型置于main()的前面,当然也可以放在main()里面的声明变量处,放在那个位置都可以,在main()中,执行到starbar();就是调用了这个函数。这是调用void类型函数的一种形式。当计算机执行到该句时,会找到这个函数的定义并执行其中的内容,执行完函数的代码之后,计算机会返回主函数继续执行下一行。

执行结果:

程序中starbar()和main()的定义形式相同,都包括函数类型、函数名和圆括号,然后是变量声明、函数表达式语句。这是函数头告诉后面没有分号,这是在定义starbar(),而不是在调用函数或声明函数原型。

程序把starbar()和main()放在一个文件中。当然也可以把它们分别放在两个文件中。放在一个文件比较容易编译,使用多个文件可以方便使用同一个函数,如果把函数放在一个单独的文件中,要把#define 和 #include 指令也放在这个文件里,稍后会讨论关于多个文件的情况。

starbar()函数中的变量count是局部变量,意思是这个变量只是属于starbar()函数,可以在程序中的其他地方继续使用count,不会引起名称冲突,属于是同名的不同变量。如果starbar()函数是一个黑盒,那么行为就是打印一行星号,不需要给函数任何的输入,它也没有返回值,即函数和主调函数没有通信。

函数参数

在上面的输出中,若是文字可以居中会更加的美观,可以在打印字符前先打印一些空格,虽然和打印星号功能类似,但是可以写一个更加普适的函数,使用内置的字符和重复的次数来完成。

改进实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h>
#include <string.h> /* 为strlen()提供原型 */
#define NAME "GIGATHINK, INC."
#define ADDRESS "101 Megabuck Plaza"
#define PLACE "Megapolis, CA 94904"
#define WIDTH 40
#define SPACE ' '
void show_n_char(char ch, int num);

int main(void)
{
int spaces;
show_n_char('*', WIDTH); /* 用符号常量作为参数 */
putchar('\n');
show_n_char(SPACE, 12); /* 用符号常量作为参数 */
printf("%s\n", NAME);
spaces = (WIDTH - strlen(ADDRESS)) / 2; /* 计算要跳过多少个空格*/
show_n_char(SPACE, spaces); /* 用一个变量作为参数*/
printf("%s\n", ADDRESS);
show_n_char(SPACE, (WIDTH - strlen(PLACE)) / 2);
printf("%s\n", PLACE); /* 用一个表达式作为参数 */
show_n_char('*', WIDTH);
putchar('\n');
return 0;
}

void show_n_char(char ch, int num)
{
int count;
for (count = 1; count <= num; count++)
putchar(ch);
}

优化效果:

在这一段程序中,就编写了一个带形参的函数,那么具体是如何去写并和主调函数完成通信的,接下来进行学习。

定义带形参的函数

函数的定义从下面的ANSI C 风格的函数头开始:

void show_char(char ch,int num)

这就相当于告诉编译器该函数使用了两个参数ch 和num ,ch 属于是字符型,num是整型,和定义在函数中的变量一样,形参也是局部变量,属于函数的私有,每次调用函数,就会给这些形参赋值。

Tips:ANSI C要求在每个变量前都声明其类型,就是说不能像声明普通变量一样使用同一类型的变量列表。

1
2
void dibs(int x, y, z) //无效的函数头
void dibs(int x, int y, int z) //有效的函数头

ANSI C 也可以接受之前的形式,但是会将其视为废弃的形式:

1
2
3
void show_n_char(ch, num)
char ch;
int num;

在这里圆括号中只有参数名列表,但是参数的类型在后面进行声明,值得注意的是,普通的局部变量是在左花括号之后进行声明,这上面的变量是在左花括号之前就声明了,若是变量是同一类型,是可以用逗号进行变量的分隔的。

1
2
void dibs(x, y, z)
int x, y, z; //有效

虽然show_n_char()函数可以接受来自main()的值,但是这个函数是没有返回值的,所以函数类型是void。

声明带形参的函数原型

在使用函数之前,要使用ANSI C 形式声明函数原型:

void show_n_char(char ch,int num);

当函数接受参数时,函数原型用逗号分隔的列表指明参数的数量和类型,根据个人喜好,可以省略变量名。

void show_n_char(char,int);

在原型中使用变量名并没有实际创建变量,char 也只是代表了一个字符类型的变量,以此类推。而且ANSI C 也接受过去的函数声明形式,即括号中没有参数列表,虽然这种形式最终会从标准中被剔除,了解是为了以后可以理解之前写的程序。

调用带实参的函数

在函数调用中,实际参数提供了ch和num的值,在上面的第二个程序中第一次调用函数:

show_n_char(SPACE,12);

实际参数是 SPACE 和 12,这两个值被赋给了函数中对应的形参ch 和num 。简单说,形参是被调函数的变量,实参是主调函数赋给被调函数的具体值。如上所示,实参是常量、变量或者表达式,无论是那种最终都会被赋值给形参。

被调函数不知道也不关心传入的数值是来自哪里(变量、常量或是表达式),再次强调,实参是具体的值。但是因为被调函数使用的是从主调函数拷贝来的,所以无论做什么操作,都不会影响原始数据。

注意实际参数和形式参数:

实参是在函数调用时圆括号里的表达式,形参是函数定义时的声明的变量,调用函数时,创建了声明为形参的变量并初始化为实参的求值结果。

黑盒视角

若是从黑盒的视角来看 show_n_char (),带显示的字符和显示次数是输入;执行的结果是打印指定数量的字符。输入是以参数的形式被传递给函数。这些信息清楚地表明了如何在main()中使用该函数。

黑盒方法的核心部分:ch、num和count都是show_n_char()私有的局部变量,意味着若是主调函数中有一个同名变量,那么它们是相互独立的,互不干涉,也就是说main()中count变量,改变它的值不会对show_n_char()中的count产生影响,反之亦然。

利用return从函数中返回值

前面说明了怎么把信息从主调函数传递给被调函数,那么反过来函数的返回值也可以从被调函数传回给主调函数,为了进一步说明,创建一个返回两个参数中较小值的函数,由于函数被设计用来处理整型的值,可以命名为imin()。还需要创建一个简单的main(),用于检查imin()是否能正常工作。这种一般被称为驱动程序,该驱动程序调用一个函数。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <stdio.h>
int imin(int, int);
int main(void)
{
int evil1, evil2;
printf("Enter a pair of integers (q to quit):\n");
while (scanf("%d %d", &evil1, &evil2) == 2)
{
printf("The lesser of %d and %d is %d.\n", evil1, evil2, imin(evil1, evil2));
printf("Enter a pair of integers (q to quit):\n");
}
printf("Bye!\n");
return 0;
}

int imin(int m, int n)
{
if (m < n)
return m;
else
return n;
}
//第一个版本

int imin(int m, int n)
{
return (m < n) ? m : n;
}
//第二个版本

int imin(int m, int n)
{
int min;
if (m < n)
min = m;
else
min = n;
return min
}
//第三个版本

scanf()函数返回成功读数据的个数,若是输入不是两个整数就会导致循环终止。下面是一个运行实例:

关键字return 后面的表达式的值就是函数的返回值。在该实例里该函数的返回值就是我们所需要的int类型值。所以imin()函数的类型也是int。返回值属于imin()函数私有,但是return语句把min的值传回了主调函数,下面的语句把imin()的返回值赋给lesser:

lesser = imin(n,m);

是否能写成下面这样:

imin(n,m);

lesser = min;

自然是不能,因为主调函数甚至不知道min的存在。imin()中的变量是imin()的局部变量。函数调用imin(evil1,evil2)只是把两个变量的值拷贝一份。返回值不仅可以赋值给变量,也可以被用作表达式的一部分。

实例:

answer = 2 * imin(z,star)+ 25;

printf(”%d\n”,imin(-32 + answer,LIMIT));

返回值不一定是变量的值,也可以是任意表达式的值。就像是上面程序中的第二个版本,条件表达式的值是n 和 m 中的较小者,该值要被返回给主调函数,虽然这里不要求用圆括号把返回值括起来,若是为了条理清楚可以放在圆括号里。

如果函数返回值的类型与声明类型不匹配则会按照函数类型返回对应的值。return 语句还有另外一个作用就是终止函数并将控制返回给主函数的下一条语句。就像是上面的第一个版本。

多数程序员认为只在函数末尾使用一次return 语句比较好,这样做更方便人理解函数的控制流。但是,在函数中使用多个return 语句也是对的,所以函数的功能一样,实现细节是可以优化的。其次,return 语句之后的不会执行。

1
2
return;
//这个语句可以终止函数,并且返回主函数,只有在void函数会用到这形式。

函数类型

声明函数必须声明函数的类型,带返回值的函数类型当与返回值的类型相同,没有返回值的声明为void ,最新的标准中不再支持老式的int类型假定。

类型声明是函数定义的一部分。要知道函数类型指的是返回值的类型,不是函数参数的类型:

double klink(int a,int b)

即是两个整型变量,返回值是双精度浮点型。要正确地使用函数,程序在第一次使用函数之前需要知道其类型。把完整的函数定义放在第一次调用函数的前面,但是这样的方法会增加阅读难度。所以一般来说需要提前声明函数原型,将信息告知编译器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
int imin(int, int);
int main(void)
{
int evil1, evil2, lesser;
}
//第一种方式
#include <stdio.h>
int main(void)
{
int imin(int, int);
int evil1, evil2, lesser;
}
//第二种方式

上述两种情况,函数原型都声明在使用函数之前。ANSI C 标准库里,函数被分成几个系列,每一个系列都有自己的头文件。这些头文件除了其他内容,还包含了本系列所有函数的声明。例如,stdio.h 头文件包含了标准I/O库函数的声明。math.h头文件包含了各种数学函数的声明。

sqrt()函数的声明就是:double sqrt(double);

告知编译器sqrt()函数有一个double类型的形参,且返回的也是double类型的值,不要混淆函数的声明和定义,函数声明告知编译器函数的类型,而函数定义则提供实际的代码。在程序中包含math.h头文件告知编译器:sqrt()返回double类型,但是函数的代码在另一个库函数的文件。

ANSI C函数原型

在ANSI C 标准之前,声明函数的方案有缺陷,因为只需要声明函数类型,不用声明任何参数,下面看看旧式函数会导致什么问题。旧式函数声明:

int imin();

但是上面的函数声明并没有给出imin()函数的参数个数和类型,所以,若是调用imin()时使用的参数个数不对或是类型不对,编译器是不会察觉的。

问题所在

我们看看和imax()函数相关的例子,这个函数和imin()函数关系密切。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
int imax(); //旧式函数声明
int main(void)
{
printf("The max number from %d or %d is %d\n", 3, 5, imax(3));
printf("The max number from %d and %d is %d", 3, 5, imax(3.0, 5.0));
return 0;
}

int imax(int n, int m)
{
return (m > n ? m : n);
}

这个程序展示的是定义了一个旧式函数并且错误的使用它,第一次调用printf()时省略imax()的一个参数,第二次调用时printf()用了两个浮点参数而不是整数参数,虽然有些问题,但是程序仍然可以编译运行。

运行结果:

这两个编译器都运行正常,之所以输出错误的结果就是因为程序没有使用函数原型。由于不同的系统,出现问题的具体情况也不一样,主调函数把它的参数储存在被称为栈的临时存储区,被调函数从栈中读取这些参数。在这个例子里面,两个过程并没有相互协调,主调函数根据调用的实际参数决定传递的类型,而被调函数根据形参读取值。

第一次imax(3)把整数3放入栈中,当imax()函数开始执行时,它从栈中读取两个整数,但实际上栈中只存放了一个待读取的整数,读取的第二个值是在栈中的其他值。

第二次使用imax()函数时,它传递的是float类型的值,这次把两个double类型的值放入栈中,double类型一个64位,所以总共128位的数据被放到栈中,当imax()从栈中读取两个整型的值,共读取64位,这些数据中比较大的是后面这个。

ANSI的解决方案

针对参数不匹配的问题,ANSI C 标准要求在函数声明时还需要声明变量的类型,即使用函数原型来声明函数的返回类型、参数的数量和类型。当我们定义好了函数原型之后,编译器就可以检查函数调用是否和函数原型匹配,参数数量是否正确,类型是否匹配,以imax()为例,若是两个数字类型不匹配,编译器会把实参转换成形参的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
int imax(int , int);
int main(void)
{
printf("The max number from %d or %d is %d\n", 3, 5, imax(3));
printf("The max number from %d and %d is %d\n", 3, 5, imax(3, 5));
return 0;
}

int imax(int n, int m)
{
return (m > n ? m : n);
}

如上编译之后,就会出现函数参数太少的错误消息。

若是类型不匹配,用imax(3,5)代替imax(3),再次编译该程序,输出就正常了。第二次调用中的3.0和5.0被转换成3和5,方便函数能正确地处理输入。虽然没有错误消息,但是编译器还是给出了警告:double转换成int可能会导致丢失数据。imax(3.9,5.4)相当于imax(3,5)。

错误和警告的区别是:错误导致无法编译,而警告仍然允许编译。一些编译器在进行类似的类型转换时不会通知客户,因为C标准中对此未作要求,不过,许多的编译器都允许用户选择警告的级别来控制描述警告的详细程度。

无参数和未指定参数

假定下面的函数原型:

void print_name();

一个支持ANSI C 的编译器会假定用户没有用函数原型来声明函数,为了表明函数确实没有参数,应该在圆括号里使用void 关键字:

void print_name(void);

支持ANSI C 的编译器解释为print_name()不接受任何参数。然后在调用函数时,编译器会检查确保没有使用参数。但是有一些函数会接受许多参数,例如printf(),第一个参数是字符串,剩下的参数类型和数量都不固定。对于这个情况,ANSI C 允许使用部分原型:

int printf(const char *,…);

这种原型表明,第一个参数是一个字符串(之后会介绍),可能还有其他未指定的参数。C库通过stdarg.h 头文件提供了一个定义这类(形参数量不固定)函数的标准方法。

函数原型的优点

函数原型是C 语言的一个强有力的工具,它可以让编译器在获取使用函数时可能会出现的错误和纰漏,若是编译器没发现问题,就很难觉察出来。有一种方法可以省略函数原型却能保留函数原型的优点,首先要明白,之所以使用函数原型,是为了让编译器在第一次执行到该函数之前就知道如何使用它。因此把整个函数定义放在第一次调用函数之前也有相同的效果,较小的函数这种用法十分普遍。

1
2
3
4
5
6
7
8
9
10
int imax(int a, int b)
{
return (a > b ? a : b);
}

int main(void)
{
int x, z;
z = imax(x, 50);
}

这就是最简单的使用方式,但是这只适合比较小的函数,若是功能复杂的函数模块还是将函数原型和函数定义分开比较好。

递归

C允许函数调用它自己,这种调用过程叫做递归。递归有时候难以捉摸,有时候却很方便实用。递归使用的难点在于结束递归,一般来说递归代码没有终止递归的条件测试部分,一个调用自己的函数会无限递归。

一般来说可以使用循环的地方都可以使用递归,有时候使用循环较好,有时候使用递归更好,递归方案更加简洁,效率却没有循环高。

递归演示

通过一个实例来学习一下什么是递归。在主函数里调用up_and_down()函数,该函数里面再调用自己,第一次是第一级递归,第二次是第二级递归,以此类推。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
void up_and_down(int);
int main(void)
{
up_and_down(1);
return 0;
}

void up_and_down(int n)
{
printf("Level %d: n location %p\n", n, &n);
if (n < 4)
up_and_down(n + 1);
printf("LEVEL %d:n location %p\n", n, &n);
}

下面就是系统的输出:

来分析一下程序里的递归是怎么工作的,首先,main()函数调用了带参数1的up_and_down()函数,执行的结果是up_and_down()中的形式参数n的值是1,所以打印语句#1是打印Level 1 。然后,由于n小于4,up_and_down()调用实际参数为n + 1 的up_and_down()。第二级里n是2,和这个类似,调用打印的分别是Level 3和Level 4 。

当执行到第四级的时候,n 的值是4,所以 if 测试条件为假。up_and_down()函数不再调用自己,第四级调用接着执行打印语句#2,即是打印LEVEL 4,因为 n 的值是4,此时,第四级调用结束,控制传回它的主调函数(第三级调用),执行下一个语句,以此类推。

Tips:注意,每级递归的变量 n 都属于本级递归私有。这从程序输出的地址值可以看的出来。

递归是一条函数调用链,fun1()调用 fun2()、fun2()调用fun3(),当最后的调用结束时,控制权就会返回上一级的函数,当fun3()结束时,返回给fun2(),递归的情况与此类似,只不过fun1()- fun4()都是相同的函数。

递归的基本原理

初次接触递归会觉得比较难以理解,但是递归的过程主要在于几个要点:

第一:每一级的函数调用都有自己的变量,也就是说,第一级的 n 和第二级的 n 不同,所以说程序创建了4个单独的变量,每个变量名都是 n ,但是它们的值各不相同。当程序最终返回up_and_down()的第一级调用时,最初的n 仍然是它的初值是1。

第二:每次函数调用都会返回一次,当函数执行完毕,控制权将被传回上一级递归。程序必须按顺序逐级返回递归,从某一级up_and_down()返回上一级的up_and_down(),不能跳级返回到主函数的第一级调用。

第三:递归函数中位于递归调用之前的语句,均按照被调函数的顺序执行。

第四:递归函数中位于递归调用之后的语句,均按被调函数相反的顺序执行。递归调用的这种特性在解决涉及相反顺序的编程问题很有用。

第五:虽然每级递归都有自己的变量,但是并没有拷贝函数的代码。程序按顺序执行函数中的代码,而递归调用就相当于又从头开始执行函数的代码。(实际上,递归有时候可以用循环来代替,循环有时也能用递归来代替)

最后,递归函数必须包含能让递归调用停止的语句。通常,递归函数都会使用 if 或者其他的等价测试条件在函数形参等于某个特定值时终止递归。为此,每次递归调用的形参都要使用不同的值。最终,实际参数等于4的时候,if 的测试条件(n < 4)为假。

尾递归

最简单的递归形式就是把递归调用置于函数的末尾,即正好在return 语句之前。这种形式的递归被称为尾递归,递归在函数的末尾。尾递归其实就相当于是循环。下面要介绍的实例,分别使用循环和尾递归计算阶乘,一个正整数的阶乘是从1到该整数的所有整数的乘积。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <stdio.h>
long fact(int n);
long rfact(int n);
int main(void)
{
int num;
printf("This program calculates factorials.\n");
printf("Enter a value in the range 0-12 (q to quit):\n");
while(scanf("%d", &num) == 1)
{
if (num < 0)
printf("No negative numbers, please.\n");
else if (num > 12)
printf("Keep input under 13.\n");
else
{
printf("loop: %d factorial = %ld\n", num, fact(num));
printf("recursion: %d factorial = %ld\n", num, rfact(num));
}
printf("Enter a value in the range 0-12 (q to quit):\n");
}
printf("Bye!\n");
return 0;
}

long fact(int n)
{
long ans;
for (ans = 1; n > 1; n--)
ans *= n;
return ans;
}

long rfact(int n)
{
long ans;
if (n > 0)
ans = n*rfact(n-1);
else
ans = 1;
return ans;
}

测试驱动程序把输入限制在0-12。因为12!快接近5亿,而13!比62亿还大,已超过我们系统中long 类型能表示的范围。要计算超过12的阶乘,必须使用能表示更大范围的类型,就像double或long long。

使用循环的函数把ans初始化为1,然后把ans与从n~2的所有递减整数相乘,根据阶乘的公式还要乘1,但其实并不会改变结果。现在应该考虑使用递归的函数,函数的关键在于n!= n * (n - 1)!,这样做是因为(n - 1)!是 n - 1 到1的正整数的乘积。所以这一特性很适合使用递归。就像是上面的 rfact()函数一样,可以通过递归调用来计算 n 的阶乘,当然还必须要在满足条件时结束递归,在 n 等于0 时设置返回值为1。

但是在程序中使用递归的输出和使用循环的输出是相同的,值得注意的是,虽然rfact()的递归调用不是函数的最后一行,但是在 n > 0 时,它是函数执行的最后一条语句,所以也属于尾递归。若是递归和循环都没有问题的话,应该使用哪一个呢,一般来说选择循环比较好,因为每一次的递归都会创建一组变量,所以递归使用的内存更多,而且每次递归调用都会把创建的一组新变量放在栈中。递归调用的数量受限于内存空间。其次,由于每次函数调用要花费一定的时间,所以递归的执行速度很慢。

递归和倒序运算

递归在处理倒序的时候非常的方便(在解决这类问题中,递归比循环简单)。一个简单的例子,编写一个函数,打印一个整数的二进制数。那么就需要一个以二进制形式表示整数的算法,在二进制中,奇数的末尾一定是1,偶数的末尾一定是0,所以末位的数字我们就可以用取余的算法来确定,对于数字n 来说,其二进制的最后一位是n % 2,所以计算的第一位数字实际上是二进制数的最后一位。也就是说我们可以在递归函数的递归调用之前计算n % 2,在递归调用之后打印计算结果,这样的话计算的第一个值就是二进制的最后一位。

要想获得下一位数字,必须把原数除以2,这种计算方法相当于在十进制下把小数点左移一位,若是计算结果是偶数,那么二进制下一位就是0,反之则是1。计算的过程循环往复,当我们需要停止计算的时候就是与2相除的结果小于2时停止计算,因为只要大于等于2,就说明还有二进制位。每次除以2就相当于去掉一位二进制,直到算出最后一位为止。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdio.h>
void to_binary(unsigned long n);
int main (void)
{
unsigned long num;
printf("Enter an integer (q to quit):\n");
while(scanf("%lu", &num) == 1)
{
printf("Binary equivalent: ");
to_binary(num);
putchar('\n');
printf("Enter an integer (q to quit):\n");
}
printf("Done!\n");
return 0;
}

void to_binary(unsigned long n)
{
int r;
r = n % 2;
if (n >= 2)
to_binary(n / 2);
putchar(r == 0 ? '0' : '1');
return;
}

在这个程序中,如果 r 的值是0,函数就显示0,若是 r 的值是1,函数就会显示1。

运行示例:

若是不使用递归的话,是否有二进制表示整数的算法,当然可以实现,但是由于这个算法要首先计算最后一位二进制数,在显示结果之前必须把所有的位数都存储在别处(数组)。

递归的优缺点

优点:递归给一些编程问题提供了最简单的解决方案。

缺点:递归算法会快速消耗计算机的内存资源,其次递归不方便阅读和维护。

实例:

1
2
3
4
5
6
7
unsigned long Fibonacci(unsigned n)
{
if (n > 2)
return Fibonacci(n - 1) + Fibonacci(n - 2);
else
return 1;
}

上述的递归函数仅仅是重述了数学上定义的递归,该函数使用了双递归,即函数每一级递归都要调用本身两次。在 n 比较小的时候,这个递归调用还在内存能承受的范围之内,但若是 n 较大的时候,每一级递归所创建的变量都会呈指数级的增长,这就会非常消耗计算机的内存。

Tip:这也从侧面说明了使用递归前需要注意是否为效率优先的程序。

程序中的每个C函数与其他函数都是平等的,每个函数都可以调用其他函数,也可以被其他函数调用。最特殊的当为main()函数,main()函数和其他函数在一起时,最开始执行的就是main()函数的第一条语句,但是main()也可以被自己和其他函数递归调用。(一般不这么做)

编译多源代码文件的程序

多函数的使用方法就是将它们放在同一个文件中,然后像编译一个文件那样编译即可。

UNIX

若是在UNIX系统中安装了UNIX C编译器cc,假设file1.c 和file2.c 是两个内含C函数的文件,下面的命令将编译两个文件并生成一个名为 a.out 的可执行文件:

1
cc file1.c file2.c

另外还会生成两个名为 file1.o 和 file2.o 的目标文件,若是后来对其中一个函数文件进行改动,那么可以使用以下的命令进行编译和合并:

1
cc file1.c file.o

UNIX系统的make命令可以自动管理多文件程序,但这超出了讨论范围。

linux

若是Linux系统安装了GNU C编译器GCC,假设file1.c 和file2.c 是两个内含C函数的文件,下面的命令将编译两个文件并生成名为 a.out 的可执行文件。

1
gcc file1.c file2.c

另外还生成两个名为file1.o 和file2.o 的目标文件。如果后来改动了file1.c,可以使用命令编译第一个文件,并与第二个文件的目标代码合并:

1
gcc file1.c file2.o

DOS命令行编译器

绝大多数的DOS命令行编译器的工作原理和UNIX的cc命令类似,只不过使用不同名称而已,还有一个区别,对象文件的扩展名是 .obj,而不是.o。一些编译器生成的不是目标代码文件,而是汇编语言或其他特殊代码的中间文件。

Windows和苹果的IDE编译器

Windows和Macintosh系统使用的集成开发环境中的编译器是面向项目的,项目是描述特定程序使用的资源,资源包括源代码文件,这种IDE中的编译器要创建项目来运行单文件程序。对于多文件程序,要使用相应的菜单命令,把源代码文件加入一个项目中。要确保所有的源代码文件都在项目列表里列出。许多的IDE都不用在项目列表中列出头文件,因为项目只管理使用的源代码文件,源代码使用#include 指令管理该文件中使用的头文件。

头文件使用

如果把main()放在第一个文件中,把函数定义放在第二个文件中,那么第一个文件还是需要使用函数原型。把函数原型放在头文件中,就不用在每次使用函数文件时都写出函数的原型。C 标准库就是这样做的,例如,把I/O函数原型放在stdio.h中,把数学函数原型放在math.h中。你也可以这样用自定义的函数文件。

此外,程序中经常使用C预处理器定义符号常量。这种定义只存储在那些包含#define指令的文件里,但若是程序把一个函数放进一个独立的文件中,你也可以使用#define 指令访问每个文件,最直接的方法就是在每个文件中再次输入指令,但是这方法既耗时又费力还容易出错。而且程序一般还会有维护的问题:我们若是想要修改#define 定义的值,就必须在每个文件中进行修改,更好的做法就是把#define指令放进头文件,然后使用每个源文件前使用 #include 指令即可。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <stdio.h>
#include "hotel.h"
int main(void)
{
int nights;
double hotel_rate;
int code;
while ((code = menu()) != QUIT)
{
switch(code)
{
case 1: hotel_rate = HOTEL1;
break;
case 2: hotel_rate = HOTEL2;
break;
case 3: hotel_rate = HOTEL3;
break;
case 4: hotel_rate = HOTEL4;
break;
default: hotel_rate = 0.0;
printf("Oops!");
break;
}
nights = getnights();
showprice(hotel_rate, nights);
}
printf("Thank you and goodbye.\n");
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <stdio.h>
#include "hotel.h"
int menu(void)
{
int code, status;
printf("\n%s%s\n", STARS, STARS);
printf("Please enter the number of the desired hotel:\n");
printf("1) Fairfield Arms 2) Hotel Olympic\n");
printf("3) Chertworthy Plaza 4) The Stockton\n");
printf("5) Quit\n");
printf("%s%s\n", STARS, STARS);
while ((status = scanf("%d", &code)) != 1 || code < 1 || code > 5)
{
if (status != 1)
scanf("%*s");
printf("Enter an integer from 1 to 5, please.\n");
}
return code;
}

int get_nights(void)
{
int nights;
printf("How many nights are you needed?\n");
while (scanf("%d", &nights) != 1)
{
scanf("%*s");
printf("Please enter an integer like 2.\n");
}
return nights;
}

void show_price(double rate, int nights)
{
double total = 0.0;
double factor = 1.0;
int n;
for (n = 1; n < nights; n++, factor *= DISCOUNT)
total += factor * rate;
printf("Your stay price is $%0.2f.\n", total);
return;
}
1
2
3
4
5
6
7
8
9
10
11
#define QUIT 5
#define Hotel1 180.00
#define Hotel2 225.00
#define Hotel3 255.00
#define Hotel4 355.00
#define DISCOUNT 0.95
#define STARS ”**********************************“
int menu(void);
int get_nights(void);
void show_price(double hotel_rate, int nights);
//这是 hotel.h 的文件内容

这就是整个完整的程序要使用的三个文件,两个C 源文件,一个C 头文件,下面的是多文件程序的运行示例:

在这个程序中,menu()函数和getnights()函数通过测试scanf()的返回值来跳过非数值数据,而且还调用 scanf(”%*s”)跳至下一个空白字符。注意,menu()函数中是如何检查非数值输入和超出范围的数据。

1
while ((status = scanf("%d", &code)) != 1 || code < 1 || code > 5)

以上代码利用了C语言的两个规则:从左往右对逻辑表达式求值;一旦求值结果为假,立即停止求值。在这个例子里,只有在scanf()成功读入一个整数值,才会检查code的值。在不同的函数处理不同的任务时应该检查数据的有效性,但是一般来说首次编写函数时可以暂时不添加这一功能,之后再进行逐步改善各个模块。

查找地址:&运算符

指针是 C 语言里最重要的概念之一,用于储存变量的地址,scanf()函数中就使用地址作为参数,简单来说,要是主调函数不使用return返回的值,就必须通过地址才能修改主函数的值,接下来介绍一元&运算符。

一元&运算符给出的是变量的存储地址,若是 pooh 是变量名,那么&pooh 就是变量的地址,可以把地址看作是变量在内存中的位置。

若是 pooh = 24;且其地址为 0B76,那么 printf (”%d %p\n”,pooh,&pooh);的执行结果就是:24 0B76。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
void mikado(int bah);
int main(void)
{
int pooh = 2, bah = 5;
printf("In the main, pooh = %d and &pooh = %p.\n", pooh, &pooh);
printf("In the main, bah = %d and &bah = %p.\n", bah, &bah);
mikado(pooh);
return 0;
}

void mikado(int bah)
{
int pooh = 10;
printf("In the mikado, pooh = %d and &pooh = %p.\n", pooh, &pooh);
printf("In the mikado, bah = %d and &bah = %p.\n", bah, &bah);
}

上述程序使用了%p的格式来打印地址,我们的系统输出如下:

实现不同,%p 表示的方式不同,然而许多实现都应该和这个例子一样,以十六进制显示地址,每个十六进制对应四位,这个例子显示16个十六进制数,对应64位地址。这个例子输出可以看出,两两同名的变量的地址均不相同。因此和前面介绍的一样,是看成4个独立的变量,另外mikado()函数将主函数里的pooh传递给形参bah。

Tip:这类传递只传递值,涉及的两个变量并没有改变。

这一点很重要,因为这不是在所有语言中的都能成立的,在FORTRAN中,子例程会影响主调例程的原始变量,子例程的变量名可能与原始变量不同,但是它们的地址相同。但是C语言里不是这样的,每个C函数都有自己的变量。

如何更改主调函数里的变量

有时候需要在一个函数里改变其他函数的变量,例如普通的排序任务里交换两个变量的值。简单的思路就是:

x = y;

y = x;

上面这两句话完全不起作用,因为执行到第二行时,x 的原始值已经被 y 的原始值替换了。所以需要多写一行代码将 x 的原始值存储起来:

temp = x;

x = y;

y = temp;

上面这三行代码可以实现交换值的功能,那么可以根据这个来编写一个函数并构造一个驱动来测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
void interchange(int u, int v);
int main(void)
{
int x = 5, y = 10;
printf("Originally x = %d and y = %d.\n", x, y);
interchange(x, y);
printf("Now x = %d and y = %d.\n", x, y);
return 0;
}

void interchange(int u, int v)
{
int temp;
temp = u;
u = v;
v = temp;
}
//第一版交换值程序

这是第一版的交换值程序,先来执行一下看看情况:

可以看出两个变量的值并没有发生交换,在interchange()函数中添加一些语句来检查错误出现在那。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
void interchange(int u, int v);
int main(void)
{
int x = 5, y = 10;
printf("Originally x = %d and y = %d.\n", x, y);
interchange(x, y);
printf("Now x = %d and y = %d.\n", x, y);
return 0;
}

void interchange(int u, int v)
{
int temp;
printf("Originally u = %d and v = %d.\n", u, v);
temp = u;
u = v;
v = temp;
printf("Now u = %d and v = %d.\n", u, v);
}
//第二版交换值程序

第二版的执行情况:

从这个情况来看,interchange()函数没有问题,它交换了两个变量的值,问题出在了将结果传回给主函数的过程中,interchange()使用的变量并不是main()的变量,所以交换 u 和 v 的值对x 和 y 的值没有影响。对于传回最先想到的方法是return把数值传回main()。当然可以,但是return一般只能返回一个值,但是现在需要传回两个值,可以实现需要用到指针。

指针简介

从根本上来说,指针是一个值为内存地址的变量(数据对象),指针变量的值是地址,在C语言中,指针有很多用法。

1
2
3
4
ptr = &pooh;
ptr = &bah;
//第一句是把pooh的地址赋给ptr
//第二句是把bah的地址赋给ptr

要创建指针变量,先要声明指针变量的类型,假定想把ptr声明为储存int类型变量地址的指针,就需要使用下面介绍的新运算符。

间接运算符:*

假设已知ptr指向bah,如下所示:

1
2
3
4
ptr = &bah;
val = *ptr; //找出ptr指向的值
//这两句话放在一起相当于下面的语句
val = bah;

接下来使用间接运算符 * 找出储存在bah里的值,这个运算符有时候也叫解引用运算符,这个符号既是二元乘法运算符 * ,虽然符号相同,但是语法不同。上面是使用地址和间接运算符可以间接完成最后一句的功能,这也是间接运算符的由来。

小结:

与指针相关的运算符:

地址运算符:&

一般注解:后跟一个变量名时,&给出该变量的地址。

地址运算符:*

一般注解:后跟一个指针名或地址时,* 给出储存在指针指向地址上的值。

声明指针

对于指针变量我们应该怎么声明,因为不同的变量类型占用不同的存储空间,一些指针操作要求知道操作对象的大小,还需要知道储存在指定地址上的数据类型。下面是一些指针的声明示例:

1
2
3
int * pi;	//pi是指向int类型变量的指针
char * pc; //pc是指向char类型变量的指针
float * pf, * pg; //pf, pg都是指向float类型变量的指针

类型说明符表明了指针所指向对象的类型,星号()表明声明的变量是一个指针。int * pi;声明的意思是pi是一个指针,pi 是int类型。星号和指针名之间的空格可有可无,一般来说在声明的时候使用空格,在解引用变量时省略空格。

pc 指向的值是char类型,pc 本身的值是一个地址,在大部分系统里是由一个无符号整数来表示,但是仅仅是表示,一些处理整数的操作不能用来处理指针,反之亦然。所以,指针不是整数类型,ANSI C 还专门提供了%p 的转换说明。

利用指针实现函数间的通信

指针是有很多的用法,就比如使用指针解决函数间的通信问题,可以对上面的程序进行更改,将interchange()函数中的整型变量变成指针参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
void interchange(int * u, int * v);
int main(void)
{
int x = 5, y = 10;
printf("Originally x = %d and y = %d.\n", x, y);
interchange(&x, &y);
printf("Now x = %d and y = %d.\n", x, y);
return 0;
}

void interchange(int * u, int * v)
{
int temp;
temp = *u;
*u = *v;
*v = temp;
}
//第三版交换值程序

第三版执行情况:

运行正常,那么具体的函数调用是:interchange(&x,&y);

函数传递的是x 和 y 的地址,而不是x 和 y 的值,这意味着出现在函数原型和定义的形参u 和 v 把地址作为它们的值,因此应该把它们声明为指针,由于x 和 y 是整数,所以u 和 v 是指向整数的指针,声明如下:

void interchange(int * u,int * v)

然后就是在函数体里声明了一个交换值时所需的临时变量,使用解引用运算符从 u 里提取出 x 的值进行交换。这个程序简单来说就是使用指针和 * 运算符,使得函数可以访问储存在这些位置的值并改变它们。

一般来说,可以把变量相关的两类信息传递给函数,如果是下面这样形式的函数调用,传递的是 x 的值:

function1(x);

如果是下面形式的函数调用,那么传递的是 x 的地址:

function2(&x);

第一种形式要求函数定义中的形参必须是一个与 x 的类型相同的变量:

int function1(int num);

第二种形式要求函数定义中的形参必须是一个指向正确类型的指针:

int function2(int * ptr);

如果要计算和处理值,那么使用第一种形式的函数调用;若是要在被调函数中改变主调函数的变量就使用第二种形式的函数调用。scanf()函数就是这样的,当程序要把一个值读入变量时,调用的是scanf(”%d”, &num),读取一个值然后存储到对应的地址上。

在这个例子里,指针让interchange()函数通过自己的局部变量改变main()中的变量的值。在C++中也有指针变量,那么C语言中有没有引用变量,其实是没有的,引用变量其实就是给变量取一个别名(详见后续学习)。

变量:名称、地址和值

在编写程序的时候,一般认为变量包括两个属性:名称和值,计算机编译和加载程序后,变量也有两个属性:地址和值,地址就是变量在计算机内部的名称。在大部分的语言里,地址都归计算机管,对程序员隐藏。然而在 C 中,可以通过&运算符访问地址,通过*运算符获得地址上的值。简单来说,普通 变量把值作为基本量,把地址作为通过&运算符获得的派生量;指针变量把地址作为基本量,把值作为通过星号运算符获得的派生量。

利用&、*和指针可以操纵地址和地址上的内容,就像第三版程序一样。

小结:函数

形式:

典型的ANSI C 函数的定义形式为:

返回类型 名称 (形参声明列表)

函数体

形参声明列表是用逗号分隔的一系列变量声明。出形参变量外,函数的其他变量均在函数体内声明。

1
2
3
4
5
6
int diff(int x, int y)
{
int z;
z = x - y;
return z;
}

传递值:实参用于把值从主调函数传递给被调函数,若是变量 a 和 b 的值分别是5和2,那么调用:c = diff(a,b);这条语句把5和2分别传递给变量 x 和 y 。5和2叫做实参,传递给 x 和 y 这两个形参,使用关键字return 把被调函数中的一个值传回主函数,被调函数一般不会改变主调函数的变量,若是想要改变,要使用指针作为参数,希望把更多的值传回主调函数更要这么做。

函数的返回类型:指的是函数返回值的类型,若是返回值的类型与声明的返回类型不匹配,返回值会被转换成函数声明的返回类型。

函数签名:函数的返回类型和形参列表构成了函数签名。因此,函数签名指明了传入函数的值类型和函数返回值类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
double duff(double, int);
int main(void)
{
double q, x;
int n;
q = duff(x, n);
return 0;
}

double duff(double u, int k)
{
double tor;
return tor;
}

关键概念

若是要用C编出高效灵活的程序,必须要理解函数,把大型程序组织成若干个函数更加有用,让一个函数处理一个任务,程序会更好理解,也更方便调试。更要理解函数是怎么把信息从一个函数传递到另一个函数,也就是要理解函数参数和返回值的工作原理,还要明白函数形参和其他局部变量都属于函数私有,因此声明在不同函数的同名变量是完全不同的变量,而且,函数无法直接访问其他函数的变量。若是要访问,必须把指针作为函数的参数。

本章小结

函数可以作为组成大型程序的构件块,每个函数都应该有一个单独且定义好的功能。具体的前面说过就不再赘述。ANSI C 提供了一个强大的工具——函数原型,允许编译器验证函数调用中使用的参数个数和类型是否正确。C 函数可以调用本身,这种调用方式被称为递归,有一些编程问题要用递归来解决,但是递归的缺点不仅消耗内存,效率不高且费时。

下一篇我们将对指针进行进一步的学习。