什么是函数?

函数是完成特定任务的独立程序代码单元,也是 C 程序的基本组成单位。语法规则定义了函数的结构和使用方式 —— 函数定义和函数调用。虽然 C 中的函数和其他语言中的函数、子程序、过程作用相同,但是细节上略有不同。
一些函数执行某些动作,如 printf() 把数据打印到屏幕上;一些函数找出一个值供程序使用,如 strlen() 把指定字符串的长度返回给程序。一般而言,函数可以同时具备以上两种功能。

为什么用函数?

首先,使用函数可以省去编写重复代码的苦差。如果程序要多次完成某项任务,那么只需编写一个合适的函数,就可以在需要时使用这个函数,或者在不同的程序中使用该函数,就像许多程序中使用 putchar() 一样。其次,即使程序只完成某项任务一次,也值得使用函数。因为函数让程序更加模块化,从而提高了程序代码的可读性,更方便后期修改、完善。
例如,假设要编写一个程序完成以下任务:读入一系列数字、分类这些数字、找出这些数字的平均值、打印一份柱状图。可以使用下面的程序:

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

当然,还要编写 4 个函数 readlist()、sort()、average() 和 bargraph() 的实现细节。描述性的函数名能清楚地表达函数的用途和组织结构。然后,单独设计和测试每个函数,直到函数都能正常完成任务。如果这些函数够通用,还可以用于其他程序。
许多程序员喜欢把函数看作是根据传入信息(输入)及其生成的值或响应的动作(输出)来定义的“黑盒”。如果不是自己编写函数,根本不用关心黑盒的内部行为。例如,使用 printf() 时,只需知道给该函数传入格式字符串或一些参数以及 printf() 生成的输出,无需了解 printf() 的内部代码。以这种方式看待函数有助于把注意力集中在程序的整体设计,而不是函数的实现细节上。因此,在动手编写代码之前,仔细考虑一下函数应该完成什么任务,以及函数和程序整体的关系。

如何学习函数?

函数这部分主要的知识点有三部分 —— 如何定义函数、调用函数以及函数间的通信,除此之外,还需要了解参数传递、递归、变参函数几个小知识点。先演示一个简单的例子:

#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'); 
}

该程序的输出如下:

****************************************
GIGATHINK, INC. 
101 Megabuck Plaza 
Megapolis, CA 94904 
****************************************

该程序要注意以下几点。程序在 3 处使用了 starbar 标识符:1. 函数原型告诉编译器函数 starbar() 的类型;2. 函数调用表明在此处执行函数;3. 函数定义明确地指定了函数要做什么。

定义函数

函数和变量类似,例如,变量需要先定义之后才能使用 —— 函数也需要先定义之后才能使用;变量有不同的类型 —— 函数也有类型;通过变量名来使用变量 —— 函数调用需要使用函数名。
定义一个函数有两种方式,一种是直接使用函数定义;另一种是先使用函数声明告诉编译器函数要如何使用,再使用函数定义规定函数的执行细节。

函数定义

函数定义:指对函数功能的确立,包括指定函数名,函数值类型、形参类型、函数体等,它是一个完整的、独立的函数单位。
函数定义的格式:

返回值类型 函数名(参数列表)
{
    // 函数体
    return 返回值;
}

示例:

#include <stdio.h>
// 函数定义
int add(int a, int b) {
    int c = a + b;
    return c;
}
int main() {
    // 调用函数
}

函数声明

C 语言代码由上到下依次执行,原则上函数定义要出现在函数调用之前,否则就会报错。但在实际开发中,经常会在函数定义之前使用它们,这个时候就需要提前声明。
函数声明:把函数的名字、函数类型以及形参类型、个数和顺序通知编译系统,以便在调用该函数时系统按此进行对照检查。
所谓函数声明,就是告诉编译器我要使用这个函数,你现在没有找到它的定义不要紧,请不要报错,稍后我会把定义补上。

PS:对于一些老旧的编译器来说,函数声明是不带参数列表的。

函数声明给出了函数名、返回值类型、参数列表(重点是参数类型)等与该函数有关的信息,也称为函数原型。简单来说,就是将函数定义的函数体替换为分号。

// 函数声明,也称为函数原型
返回值类型 函数名(参数列表);
// 示例:
int add(int a, int b);

函数原型相当于函数声明。需要注意的是,虽然现在一般都会说函数原型就是函数声明,但其实对于一些老旧的编译器,它们是不同的,对于这些老旧的编译器,函数声明是不带参数列表的,如int imax();,但是这样编译器无法针对参数不匹配的问题,因此提出了函数原型来解决这个问题,因此在后来的编译器中函数声明的标准中加上了参数列表。

函数原型的作用是告诉编译器该函数有关的信息,让编译器知道函数的存在,以及存在的形式,即使函数暂时没有定义,编译器也知道如何使用它。这样在有函数声明的情况下,函数定义就可以出现在任何地方了,甚至是其他文件、静态链接库、动态链接库等。

注意:函数声明并不可以代替函数定义,函数声明的意义在于可以使得函数定义出现在函数调用之后或者出现在其他文件中。

函数声明的作用

如果没有函数声明,函数定义必须在函数调用之前,否则会报错。例如,下面的例子就会报错,错误提示是 add 函数没有定义。

#include <stdio.h>
int main() {
    // 调用函数
    int c = add(3, 4);// 报错
}
// 函数定义
int add(int a, int b) {
    int c = a + b;
    return c;
}

这时在函数调用之前加上函数声明,就不会报错了。

#include <stdio.h>
int add(int a, int b);
int main() {
    // 调用函数
    int c = add(3, 4);// 正确
}
// 函数定义
int add(int a, int b) {
    int c = a + b;
    return c;
}

函数声明的参数列表中的参数名可以和函数定义的参数列表中的参数名不同。

PS:C 语言函数声明中的参数列表可以省略,C函数声明的参数列表不可省略,因为 C 会存在函数重载的问题,需要参数列表作为特征标。

函数类型

函数和变量一样,有多种类型。任何程序在使用函数之前都要声明该函数的类型。
函数类型:函数类型取决于函数的返回值以及参数的类型。返回值类型和参数类型相同的函数是同一种类型的函数。

有的 C 语言书籍说函数返回值的类型也称为函数的类型,这点我并不认同,详情见文章最后的注释部分,不过论证的过程用到了目前还没有学到的指针,可以等学习了指针的知识之后再回来看这部分的论证。

例如上面的程序中,函数原型部分 void starbar(void); 其中第一个 void 是函数类型,void 类型表明 starbar() 函数无返回值类型。

PS:如果不声明返回值类型,C 语言默认是返回 int 类型。

函数间通信

函数定义中的参数列表以及返回值都是用于函数间通信的。

函数参数

函数参数是做什么的?

函数间的通信主要是通过函数参数来实现的。
通过函数参数,主调函数可以向被调参数传递一些信息。并且,在我们学习了指针和数组的知识之后,被调函数还可以通过函数参数传递一些信息给主调函数。

PS:被调函数传递信息给主调函数的方法除了函数参数之外,还可以通过返回值。不过返回值的一个缺点是只能返回一个值。

下面我们就来学习一下函数参数的知识。

定义带形式参数的函数

例如,有一个如下所示的 ANSI C 风格的函数定义:

void show_n_char(char ch, int num)

该行告知编译器 show_n_char() 使用两个参数 ch 和 num,ch 是 char 类型,num 是 int 类型。这两个变量被称为形式参数(formal argument,但是最近的标准推荐使用 formal paramete)),简称形参。和定义在函数中变量一样,形式参数也是局部变量,属该函数私有。这意味着在其他函数中使用同名变量不会引起名称冲突。每次调用函数,就会给这些变量赋值。

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

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

声明带形式参数的函数

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

void show_n_char(char ch, int num);

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

void show_n_char(char, int);

在原型中使用变量名并没有实际创建变量,char 仅代表了一个 char 类型的变量,以此类推。

返回值

前面介绍了如何把信息从主调函数传递给被调函数。反过来,函数的返回值可以把信息从被调函数传回主调函数。
使用 return 语句可以返回值,返回值不仅可以赋给变量,也可以被用作表达式的一部分。
使用 return 语句的另一个作用是,终止函数并把控制返回给主调函数的下一条语句。

函数参数传递方式(重点)

函数参数的主要作用是主调函数传递数据给被调函数。
下面的演示了一种最常见的函数使用。定义一个求和的函数,主调程序 main 调用了求和函数,并通过求和函数的两个参数传递了待求和的值,求和函数将结果通过返回值返回给主调函数。

#include <stdio.h>
int sum(int x, int y);
int main()
{
    int a = 30, b = 24;
    printf("%d\n", sum(a, b));
    return 0;
}
int sum(int x, int y)
{
    return x + y;
}

下面我们再来写一个将两个参数值互换的函数。

#include<stdio.h>
int exchange(int a, int b);
int main()
{
    int a = 30, b = 24;

    printf("main 交换前:a=%d, b=%d\n", a, b);
    exchange(a, b);
    printf("main 交换后:a=%d, b=%d\n", a, b);

    return 0;
}
int exchange(int x, int y)
{
    printf("exchange 交换前:x=%d, y=%d\n", x, y);
    int temp = x;
    x = y;
    y = temp;
    printf("exchange 交换前:x=%d, y=%d\n", x, y);
}

image.png
哎~不对啊,exchange 函数中已经互换成功了,为什么 main 中的 a 和 b 没有互换成功呢?这就涉及到我们接下来要讲的函数参数的传递机制了。

值传递

函数传参的时候是将实际参数的值传递给形式参数。在上面的例子中就是将实际参数 a 和 b 的值 30 和 24 为形式参数 x 和 y 赋值。
这会导致什么结果呢?首先,我们要明白实际参数和形式参数是在不同的存储单元中,将实际参数的值传递给形式参数实际上就相当于在函数中定义了形式参数并给其赋值的过程。例如在上面的程序中,实际上在 exchange 函数中是 int x = 30;int y = 24;,x 和 y 是形式参数,是 exchange 函数的局部变量,在进入 exchange 函数的时候被定义,在 exchange 函数调用结束之后释放,因此形参值的任何变化都不会影响到实参的值,实参的存储单元仍保留并维持数值不变。
这个传参机制就称为值传递,意思是传递参数时只传递实际参数的值,而不是实际参数本身
特点:值传递的特点是单向传递,将实际参数的值赋值给形式参数,修改形式参数的值并不会影响实际参数。
那像上面的互换两个参数值的函数就没有办法实现了吗?
是可以实现的,这就涉及到了第二种传参 —— 地址传递。

地址传递

地址传递本质上依旧是值传递!事实上,C 语言中只有值传递,所谓地址传递是利用了指针的特性形成的,这一点在学习指针之后就会明白了。
在我看来,C 语言的教程中将传参机制分为值传递和地址传递的原因是指针的知识是放在函数这部分知识之后讲解的,因此在学习函数的时候初学者是没有指针的概念的,所以才提出值传递和地址传递的概念让小白们可以比较容易的区分。
事实上,在学习指针知识之后再回来看 C 语言只有值传递这句话就会明白了。
什么是地址传递? 这种方式使用数组名或者指针作为函数参数,传递的是该数组的首地址或指针的值,而形参接收到的是地址,即指向实参的存储单元,这种传递方式称为“参数的地址传递”。
依旧拿互换参数这个函数为例,下面演示下地址传递的程序:

#include<stdio.h>
int swap(int *a, int *b);
int main()
{
    int a = 30, b = 24;
    printf("main 交换前:a=%d, b=%d\n", a, b);
    swap(&a, &b);
    printf("main 交换后:a=%d, b=%d\n", a, b);

    getchar();
    return 0;
}
int swap(int *x, int *y)
{
    int temp = *x;
    *x = *y;
    *y = temp;
}

image.png
可以看到,地址传值是可以在被调函数中通过参数来修改主调函数的实际参数的值。
之前我们都是通过返回值将函数的计算结果返回给主调函数,这种方法有一个缺点 —— 如果主调函数需要的结果是多个值的话,通过返回值就无法解决了。而地址传值就提供了传递多个值给主调函数的一种解决方案,也是我们最常用的一种方案了。

函数调用

在函数调用中,实际参数(简称实参)提供了 ch 和 num 的值。

show_n_char(SPACE, 12);

实际参数是空格字符和 12。这两个值被赋给 show_n_char() 中相应的形式参数:变量 ch 和 num。简而言之,形式参数是被调函数(called function)中的变量,实际参数是主调函数(calling function)赋给被调函数的具体值。
实际参数可以是常量、变量,或甚至是更复杂的表达式。无论实际参数是何种形式都要被求值,然后该值被拷贝给被调函数相应的形式参数。被调函数不知道也不关心传入的数值是来自常量、变量还是一般表达式。再次强调,实际参数是具体的值,该值要被赋给作为形式参数的变量。因为被调函数使用的值是从主调函数中拷贝而来,所以无论被调函数对拷贝数据进行什么操作,都不会影响主调函数中的原始数据。

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

image.png

注释

为什么说函数返回值的类型也称为函数的类型是错的?
① 根据指针来证明:

#include <stdio.h>
int f1(int a, int b);
int main(void) {
    int (*ptr)(int a) = f1; // 会报错
}
int f1(int a, int b) {
    return a+b;
}

如果函数返回值类型就是函数类型的话,函数指针就没有必要要求指针的参数列表也要和指向的函数的参数列表一致了。

② 这里之所以说函数返回值的类型被称为函数类型,考虑其原因是C语言不支持函数重载,而且C语言可以在函数声明中不写参数列表,只在函数定义中提供参数列表。