函数
约 6022 字大约 20 分钟
2025-06-21
1.函数是什么
在数学里,函数是一种对应关系,而 C 语言里的函数和数学中的函数具有相似点,但是有很大的不同,甚至有些人认为“函数”这个名词不够恰当。准确来说,C 函数的函数是一种子程序,您可以去 Wiki 百科查看对子程序的解释。
所谓的子程序,实际上就是大型程序中的某部分子代码。子程序本身由一个或多个语句组成,负责完成某项特定的任务,而且相较于其他代码具有相对的独立性。
可以认为,程序员在实践中,发现某一段代码也就是子程序经常被反复使用,因此就将这段代码摘取出来,给其进行命名做标记。而后续编码过程中,如果需要使用这段代码,就只需要调用对应的命名标记即可。
//计算算式 (x + y) / 3 - z 的结果
#include <stdio.h>
int main()
{
int val1 = (1 + 2) / 3 - 4;
printf("%d\n", val1);
int val2 = (6 + 3) / 3 - 5;
printf("%d\n", val2);
int val3 = (-3 + 4) / 3 - 7;
printf("%d\n", val3);
return 0;
}可以看到,我们反复使用下面这段子代码:
//提炼出子代码
#include <stdio.h>
int valn = (x + y) / 3 - z;
printf("%d\n", valn);如果需要计算的次数变多,就会重复写上述的子代码,这样做的效率实在太低,因此我们把子代码摘取出来,给予一个标记/函数名作为子代码的命名,这个过程也被称为“封装”。
//把子代码封装为函数
#include <stdio.h>
void Function(int x, int y, int z)
{
int valn = (x + y) / 3 - z;
printf("%d\n", valn);
}那怎么调用呢?看下面代码:
//把子代码封装为函数
#include <stdio.h>
void Function(int x, int y, int z) //接受三个参数拿去计算并且输出结果
{
int valn = (x + y) / 3 - z;
printf("%d\n", valn);
}
//计算算式 (x + y) / 3 - z 的结果
int main()
{
//int val1 = (1 + 2) / 3 - 4;
//printf("%d\n", val1);
Function(1, 2, 4); //传递三个参数交给函数去计算
//int val2 = (6 + 3) / 3 - 5;
//printf("%d\n", val2);
Function(6, 3, 5); //传递三个参数交给函数去计算
//int val3 = (-3 + 4) / 3 - 7;
//printf("%d\n", val3);
Function(-3, 4, 7); //传递三个参数交给函数去计算
return 0;
}这样就简化了 main() 函数内冗余的代码。
注
吐槽:不过函数这个名字实际上我感觉也没错,上述例子中的确是进行了输入和输出,看上去也挺像数学里的函数或方程...
这里强调一点,使用函数时也是需要使用空间的,这类似创建变量需要有空间存储变量的值一样,函数在运行过程中也需要一定的空间资源,而这份空间也被叫做栈区,您简单理解一下就行。
2.函数的传参
实际参数 就是真实传给函数的参数,实参可以是常量、变量、表达式、函数返回值...在进行函数调用的时候,必须先有实际参数调用函数,然后再传值/拷贝给函数的 形式参数,在函数体内部进行使用。
重要
补充:“传值”这个词,在计算机中很多时候都是“拷贝”的代名词...
而简单来讲:
- 函数调用后,函数名后面括号的内容中的量就是实际参数
- 函数定义后,函数名后面括号的内容中的变量就是形式参数
//区分实参和形参
#include <stdio.h>
void Func(int x) //这里的 x 就是形式参数
{
printf("%d\n", x);
}
int main()
{
int t = 0;
Func(t); //这里的 t 就是实际参数
Func(2); //这里的 2 就是实际参数
return 0;
}只有在函数被调用的时候,实参传值给形时,形参才会有值(即“才会实例化“)此时该变量才会分配内存单元,拷贝一份实参的值来使用。
而由于“形参”是“实参”的一份临时拷贝,因此对形参的任何修改是不会影响到实参的。这样就会造成一些坑。
//使用传值的交换函数
#include <stdio.h>
void Swap(int x, int y) //用于交换两个数的函数
{
printf("x=%d y=%d\n", x, y); //打印检查是否有发生交换
int temp = x; //先存储一份 x, 避免 x 原本的值丢失
x = y;
y = temp;
printf("x=%d y=%d\n", x, y); //打印检查是否有发生交换
}
int main()
{
int a = 1;
int b = 2;
printf("a=%d b=%d\n", a, b); //打印检查是否有发生交换
Swap(a, b); //尝试交换两个变量中的值
printf("a=%d b=%d\n", a, b); //打印检查是否有发生交换
return 0;
}我们会发现,a 和 b 变量内的值确实没有发生交换,但是函数体内的形参却做了交换,但是这个交换结果没有影响到身为实参的 a 和 b 变量。因此,如果我们需要通过形参来改动实参,则不能只是传递值,而是应该传递实参的地址!
//使用传址的交换函数
#include <stdio.h>
void Swap(int* px, int* py) //用于交换两个数的函数
{
printf("x=%d y=%d\n", *px, *py); //打印检查是否有发生交换
int temp = *px; //先存储一份 x, 避免 x 原本的值丢失
*px = *py;
*py = temp;
printf("x=%d y=%d\n", *px, *py); //打印检查是否有发生交换
}
int main()
{
int a = 1;
int b = 2;
printf("a=%d b=%d\n", a, b); //打印检查是否有发生交换
Swap(&a, &b); //尝试交换两个变量中的值
printf("a=%d b=%d\n", a, b); //打印检查是否有发生交换
return 0;
}重要
补充:这里还要补充一点的是,形式参数在函数调用完后就自动销毁了,因此形式参数只有在函数体内部才是有效的。而且,我们一般把函数中的形参拷贝实参进行实例化后,所存储的位置称为栈。换句话来说:形参实例化后存储在栈空间中,这一概念您稍微了解一下即可,后续还有一个类似的堆空间的概念。
注意
警告:有关函数参数个数的易错题
int founction((a1, a2), (a3, a4), a5, a6); //这个函数有几个参数呢?实际上应该是 4 个参数,(a1, a2) 和 (a3, a4) 是逗号表达式,他们都各有一个最终结果,分别为 a2 和 a4,故实际上函数的参数为 a2, a4, a5, a6 四个。
这里提一嘴,函数的参数个数设置得越少越好,不然用户使用的时候,还得搞清楚每个参数的意义,并且还需要输入较多参数才能使用(这很麻烦)。
3.函数的分类
3.1.库函数
C 语言自带的函数,可以直接使用,方便程序员更快进行软件开发,而不用自己重复编写类似的代码(这种行为也被称为“造轮子”)。您可以在 https://cplusplus.com 或者 C/C++官网 cppreference.com(中文版 cppreference.com)中查找库函数的相关信息,例如使用方法、注意事项、函数返回值...
使用库函数就必须在代码中包含 #include <库函数对应的头文件>,表示引入头文件,头文件里实际上就是众多库函数的声明。而常用的库文件如下:
IO库<stdio.h>- 字符串操作库
<string.h> - 字符处理库
<ctype.h> - 内存操作库
<stdlib.h> - 时间/日期库
<time.h> - 数学库
<math.h> - 错误消息库
<errno.h> - 功能工具库
<stdlib.h>
例如使用库函数 scanf() 和 printf() 需要包提前在代码中包含 #include <stdio.h> 头文件。
3.2.自定义函数
一个函数应该具有函数返回值、函数名、函数参数构成的函数签名特征,而实现函数的 {} 括起来的部分被称为函数体。
//函数定义的语法形式
返回值类型 函数名(形参1, 形参2) //这一行就是函数签名, 后面的所有被 {} 包含的内容就是一个函数体
{
[return 返回值;] //返回值类型是 "void" 时,"return" 语句可以不写,或者直接写 "return;" 即可结束函数
}
//调用函数
int main()
{
函数名(实参1, 实参2);
return 0;
}在函数的调用中,形式参数拷贝实际参数的传递过来的变量或常量,形成一个新的局部变量(只能在函数内部被使用),因此形参只是实参的临时拷贝,内部数据的存储位置不在同一个位置。
//使用自定义的函数
int Add(int x, int y)
{
int z = x + y;
return z; //返回 z
}
int main()
{
printf("%d\n", Add(1, 2));
int a = 5, b = 10;
printf("%d\n", Add(a, b));
return 0;
}
/* 输出结果
3
15
*/因为不可能所有的库函数都符合我们的需求,所以我们需要自己编写符合自己需求的函数,学会写自定义函数是很重要的!
我再写几个编写函数的例子供您参考一下,这次我带您编写一个查找闰年的函数 IsLeapYear()。
重要
补充:关于什么是闰年,有一个数学上的判断,这里虽然不详细解释原理,但是您可以 前去 Wiki 看关于闰年的解释...
//编写寻找闰年的 IsLeapYear(), 函数声明和函数定义为一体
#include <stdio.h>
int IsLeapYear(int y) //自定义“判断是否为闰年”的函数
{
return ((y % 4 == 0) && (y % 100 != 0)) //满足除以 4 且被 100 整除
|| y % 400 == 0; //或者满足被 400 整除, 则认为 y 是闰年
}
int main()//主体函数
{
int y = 0;
for (y = 1000; y <= 2000; y++)
{
if (IsLeapYear(y))
{
printf("%d ", y);
}
}
return 0;
}重要
补充:一般来说,先在主函数/主程序(main)中写如何去用、在哪里用自定义函数,再去考虑写自定义函数的具体实现。这中其实就是 TDD(Test-Driven Development) 思想,即所谓的“测试驱动开发”。
另外,还有人习惯先声明函数,在声明的同时注释函数的作用,然后把具体的实现细节放在代码文件的最后,这样做也是一种“封装”,由于人的阅读习惯是自上而下,编写函数的人希望使用者知道如何调用函数,但无需关注繁琐复杂的细节,因此只需要知道函数的声明和对应的注释即可。
//编写寻找闰年的 IsLeapYear(), 函数声明和函数定义做分离
#include <stdio.h>
int IsLeapYear(int y); //“判断是否为闰年”的函数, 传入一个月份作为参数, 如果是则会返回真或假(非零值或零)
int main()//主体函数
{
int y = 0;
for (y = 1000; y <= 2000; y++)
{
if (IsLeapYear(y))
{
printf("%d ", y);
}
}
return 0;
}
int IsLeapYear(int y) //自定义“判断是否为闰年”的函数
{
return ((y % 4 == 0) && (y % 100 != 0)) //满足除以 4 且被 100 整除
|| y % 400 == 0; //或者满足被 400 整除, 则认为 y 是闰年
}注
吐槽:实际上函数的声明和变量的声明很像,都是先声明再使用...
另外您还需要注意,函数声明和函数定义写在一起时(也就是上述 IsLeapYear() 的第一种编写方式),必须把函数签名和函数体放在被调用的语句之前,也就是放在 for 循环中的 IsLeapYear(y) 之前。
类似变量先声明了才能被使用,函数这里也是一样。
而 IsLeapYear() 的第二种编写方式,函数签名本身就是函数声明,函数体作为函数定义,可以放在代码文件中的任意位置(但是不能放在另外一个函数的内部)。只要保证:在调用函数时,前面有函数声明即可。
注
吐槽:简单理解的话,函数签名就是函数声明,函数体就是函数定义...
不过 IsLeapYear() 的函数声明和函数定义分离的写法其实不是这么用的,正确的用法是,使用 .h 文件包含函数声明 .c 文件包含函数定义。当其他人需要使用这些自定义函数时,就需要将这两个文件包含在自己的项目中。
//IsLeapYear.h
#include <stdio.h>
int IsLeapYear(int y); //“判断是否为闰年”的函数, 传入一个月份作为参数, 如果是则会返回真或假(非零值或零)//IsLeapYear.c
#include <IsLeapYear.h> //相当于把函数声明引入进来, 这样这里的函数声明和函数定义形成一个完整的函数
int IsLeapYear(int y) //自定义“判断是否为闰年”的函数
{
return ((y % 4 == 0) && (y % 100 != 0)) //满足除以 4 且被 100 整除
|| y % 400 == 0; //或者满足被 400 整除, 则认为 y 是闰年
}//main.c
#include <IsLeapYear.h> //相当于把函数声明引进来, 函数定义以及在别的 .c 文件中定义了, 因此包含 IsLeapYear.h 文件后, 后续的代码就可以直接使用 IsLeapYear()
int main()//主体函数
{
int y = 0;
for (y = 1000; y <= 2000; y++)
{
if (IsLeapYear(y))
{
printf("%d ", y);
}
}
return 0;
}实际上这也是库函数的原理,理论上来说,如果想要制作库函数,就可以按照上述函数声明和函数定义分离的方式,这样用户在使用 C 语言编写程序时,只需要阅读说明文档和包含对应需要的 .h 文件,即可使用库函数。
那按照这个原理,是不是可以在 VS2022 中找到库函数的具体定义呢?理论上是可以的,有些编译器也支持查询定义的功能(但是有些较先进的编译器可能不允许用户查看)。
不过更多情况下,实现库函数的 .c 文件都被提前编译成了静态库或动态库,这样做可以提高用户代码的编译效率。
重要
补充:关于静态库和动态库的制作,我在 Linux 中会提及,这里您只需要知道如何把自己制作的函数分离为 .h 和 .c 并且进行包含使用即可。
另外,函数声明和函数定义分离为两个文件,一是为了封装,二是为了提高分工效率,三是也可能是为了保密性。
分离为两个文件,用户无需在意函数的实现细节,直接看说明文档就可以快速使用对应的函数
总不可能一堆程序员挤在一个电脑屏幕上写代码吧?多文件使得有程序员得以有效分工,每一个程序员写各种工作的头文件和源文件,最后再进行整合,这样可以提高协作效率
有些软件公司不想暴露自己软件的实现细节,因此只把
.h的函数声明/接口给出,程序员只需要使用该公司的说明文档,然后结合软件公司的技术支持就可以使用函数,而存放函数实现细节的.c文件则会被编译为静态库或动态库,这两种库实际上也是文件,只不过是二进制文件,很难被人类所读取重要
补充:实际上,存在一些逆向的工具,可以把二进制的库文件转化为可阅读的代码,但是这样的工具转化出来的代码可能和原来的代码差异很大,并且破解难度也会随着工程代码的变大而变大。
最后这里提一嘴,为了保证您的阅读体验,除非必要,否则在本系列中大部分的示例代码都将函数声明和函数定义统一放入 main() 所处的文件中。这仅仅是为了您的阅读方便,您心里清楚就行...
4.函数的返回
前面我一直都忽略了函数的返回值,函数可以依靠 return 关键字返回一个值(这个值可以是变量内的值,可以可以是一个常量)。
而函数如果想要返回多个值,在 C 语言中有多种方式:
- 使用多个全局变量,在函数内部改变,则相当于函数返回了多个值
- 返回一个数组指针,就可以通过数组返回多个值
- 形参使用多个指针变量,在函数体内部解引用,改变指针指向的值,这种形参也叫做“输出型参数”
这里给出三份代码帮助您理解。
//使用多个全局变量, 在函数内部改变, 则相当于函数返回了多个值
#include <stdio.h>
int global_var1 = 0;
int global_var2 = 0;
void ChangeGlobals()
{
global_var1 = 10;
global_var2 = 20;
}
int main()
{
ChangeGlobals();
printf("Global Var 1: %d\n", global_var1);
printf("Global Var 2: %d\n", global_var2);
return 0;
}//返回一个数组指针, 就可以通过数组返回多个值
#include <stdio.h>
void ReturnArray(int* arr)
{
arr[0] = 10;
arr[1] = 20;
return arr;
}
int main()
{
int arr[2] = { 0 };
ReturnArray(arr);
printf("Array Element 1: %d\n", arr[0]);
printf("Array Element 2: %d\n", arr[1]);
return 0;
}//形参使用多个指针变量, 在函数体内部解引用, 改变指针指向的值, 这种形参也叫做“输出型参数”
#include <stdio.h>
void changeValues(int *a, int *b)
{
*a = 10;
*b = 20;
}
int main()
{
int x = 0, y = 0;
changeValues(&x, &y);
printf("Value of x: %d\n", x);
printf("Value of y: %d\n", y);
return 0;
}5.函数的调用
5.1.函数调用
不同调用就是在自己的代码中使用 () 符号来调用函数,并且把参数填入 () 即可,这个过程通常被称为 函数调用。
//函数调用
#include <stdio.h>
void function()
{
printf("aaaaa\n");
}
int main()
{
function(); //这里就发生了函数调用
return 0;
}5.2.嵌套调用
在一个函数实现内部,可以嵌套调用其他函数,这种现象也叫做 嵌套调用。
//嵌套调用
#include <stdio.h>
void function_1()
{
printf("aaaaa\n");
}
void function_2()
{
int i = 0;
for (i = 0; i < 3; i++)
{
function_1(); //嵌套调用了另外一个函数
}
}
int main()
{
function_2();
return 0;
}注意
警告:函数可以嵌套调用但是不能嵌套定义(不允许一个 f1 函数定义内又定义了一个 f2 函数,只能在 f1 函数定义内调用一个已经定义好的函数 f2)。
//以下“嵌套定义”是不被允许的!
返回类型 f1(参数列表)
{
//一些代码
返回类型 f2(参数列表) //f1 函数定义内部又定义了一个 f2 函数定义
{
//一些代码
}
//一些代码
}值得一提的是,嵌套调用能不能在函数定义内自己调用自己呢?
//递归代码的形式
返回类型 Func(参数列表1)
{
//某一些代码
Func(参数列表2); //嵌套调用, 但是调用的是自己
//某一些代码
}C 语言允许这样做,这种嵌套调用也被称为 递归,适当使用递归会大大减少代码量,并且使得代码变得简洁,但对应的编写难度和理解难度也会提升(有可能造成代码的可读性降低)。
我们来编写一个最简单的递归,main() 实际上也是一个函数,那它能不能自己调用自己呢?
//最简单的递归,main 函数自己调用自己
#include <stdio.h>
int main()
{
printf("abcd\n");
main();
} //不断打印 abcd, 此时 main() 将不断消耗栈区空间(函数运行时存放临时数据的区域), 最后栈溢出(也就是栈空间没有可用的地方了), 程序崩溃再编写一些具有实际意义的递归代码。
//按照顺序打印一个整数的每一位数字字符
#include <stdio.h>
MyPrint(unsigned int x)
{
if (x > 9)
{
MyPrint(x / 10);
printf("%u ", x % 10);
}
else
{
printf("%d ", x);
}
}
int main()
{
unsigned int number = 0;
scanf("%u", &number); //输入 1234
MyPrint(number); //打印 1 2 3 4
return 0;
}递归不太容易理解,可以使用一些 流程图来辅助理解,待补充...
重要
补充: 关于流程图工具,待补充...
下面给出更多递归示例和对应的图解。
//自定义一个函数,实现和 strlen() 一样的功能
#include <stdio.h>
MyStrlen(const char *str)
{
if (*str == '\0')
{
return 0;
}
else
{
return (1 + MyStrlen(str + 1));
}
}
int main()
{
char arr[10] = "abcdef";
printf("%d", MyStrlen(arr));
return 0;
}//第 n 个斐波那契数列(不考虑溢出的情况)
#include <stdio.h>
int fun(int n)
{
if (n <= 2)
return 1;
else
return fun(n - 1) + fun(n - 2);
}
int main()
{
int n = 0;
while (scanf("%d", &n) == 1)
{
if (n <= 0)
{
printf("输入的数必须大于0\n");
continue;
}
printf("第%d个斐波那契数列为%d\n", n, fun(n));
}
return 0;
}//阶乘的实现(不考虑溢出的情况)
#include <stdio.h>
int fun(int n)
{
if (n <= 1)
{
return 1;
}
else
{
return n * fun(n - 1);
}
}
int main()
{
int n = 0;
printf("请输入一个非负整数:");
scanf("%d", &n);
if (n < 0)
{
printf("输入的数必须为非负整数\n");
}
else
{
printf("%d的阶乘为%d\n", n, fun(n));
}
return 0;
}汉诺塔问题、青蛙跳台阶问题,待补充...
可以看出,编写正确的递归有几个必要的条件:
- 存在限制条件可以让递归停下(不能像上面
main()自己调用自己一样,否则迟早发生stack overflow,即栈溢出) - 每次递归都要接近限制条件,不能原封不动
重要
补充:这里介绍一个叫做 stack overflow 的程序员交流网站,尽管是英文的,但是有很多程序员工作过程中常见的问题,您也可以尝试在上面进行问答...
递归有可能大量消耗栈空间,计算量极大,有的时候反而效率低,例如:求斐波那契数列的时候求第 50 个数就会很吃力了,运算时间很久...但很多递归代码可以被转化为非递归的循环代码,因此选择递归还是选择循环往往取决于需求。这里仅介绍斐波那契数列和阶乘的非递归代码,帮助您理解递归和循环之间的转化。
//第 n 个斐波那契数列(使用非递归)
#include <stdio.h>
int function(int n)
{
if (n <= 2)
{
return 1;
}
int a = 1, b = 1, c = 0;
for (int i = 3; i <= n; ++i)
{
c = a + b;
a = b;
b = c;
}
return c;
}
int main()
{
int n;
printf("请输入斐波那契数列的项数: ");
scanf("%d", &n);
printf("第 %d 个斐波那契数列为: %d\n", n, function(n));
return 0;
}//阶乘的实现(使用非递归)
#include <stdio.h>
int function(int n)
{
if (n < 0)
{
printf("请输入非负整数\n");
return -1; // 返回一个错误值,表示计算失败
}
int a = 1;
while (n > 1)
{
a *= n;
n--;
}
return a;
}
int main()
{
int n = 0;
printf("请输入一个非负整数:");
scanf("%d", &n);
printf("%d的阶乘为:%d\n", n, function(n));
return 0;
}5.3.链式访问
调用函数如果产生了返回值,也可以把这个函数调用直接作为其他函数的实参来床底,这种现象也叫做 链式调用。
//链式调用代码 1
#include <stdio.h>
#include <string.h>
int main()
{
printf("%d\n", strlen("abcd")); //输出 4
return 0;
}//链式调用代码 2
#include <stdio.h>
int main()
{
printf("%d", printf("%d", printf("%d", 43))); //输出 4321
return 0;
}链式调用可以减少一些不必要的局部变量的创建,提高代码的效率,也可以让代码变得更加简洁。