前言
C语言中,函数具有举足轻重的地位。本章节将详细讲解关于函数的分类、返回值、传参、声明定义、以及函数的递归。
一、函数是什么?
百度百科中对函数的定义:子程序
- 在计算机科学中,子程序(英语:Subroutine, procedure, function, routine, method, subprogram, callable unit),是一个大型程序中的某部分代码, 由一个或多个语句块组 成。它负责完成某项特定任务,而且相较于其他代 码,具备相对的独立性。
- 一般会有输入参数并有返回值,提供对过程的封装和细节的隐藏。这些代码通常被集成为软 件库。
简单来讲:函数就像一个工具,帮助我们解决问题,完成需求,封装功能。
二、函数的分类
- 库函数
- 自定义函数
2.1 库函数
库函数相当于C语言给我提供的工具箱,供使用者使用,完成功能。
像官方提供的print打印函数,scanf输入函数,strcpy字符串拷贝函数.....等等。
像上面我们描述的基础功能,它们不是业务性的代码。我们在开发的过程中每个程序员都可能用的到, 为了支持可移植性和提高程序的效率,所以C语言的基础库中提供了一系列类似的库函数,方便程序员 进行软件开发。
对于库函数的学习,其实最重要的是掌握如何使用文档进行辅助开发,下面推荐几个不错的文档给大家:
- MSDN(Microsoft Developer Network)软件 (离线可使用)
- https://cplusplus.com/
- https://en.cppreference.com/w/ (英文版) (该网站也是c/c++官网)
- https://zh.cppreference.com/w/ (中文版)
C语言提供了非常多的库函数,常见的库函数可以分成如下几类:
- IO函数
- 字符串操作函数
- 字符操作函数
- 内存操作函数
- 时间/日期函数
- 数学函数
- 其他库函数
下面,我来教教大家如何利用工具查找库函数:(以cplusplus网站为例)
比如想知道strcpy库函数是干什么的:(点击Legacy version可以切换到旧版本,比较好搜索)
文档详细对该函数进行了介绍,我们可以通过文档进行使用,并且文档中还有示例代码。
注:库函数必须知道的一个秘密就是:使用库函数,必须包含 #include 对应的头文件。不知道该库函数的头文件,也可以使用工具进行查看:
2.2 自定义函数
当然,一个编程语言有库函数就有自定义函数,库函数最初也是由官方写的自定义函数。自定义函数和库函数一样,有函数名,返回值类型和函数参数。 但是不一样的是这些都是我们自己来设计。这给程序员一个很大的发挥空间。
// 函数的组成
ret_type fun_name(para1, * )
{
statement;//语句项
}
ret_type //返回类型
fun_name //函数名
para1 //函数参数
函数后跟的大括号{}内部也叫做函数体。我们看个例子:(写一个函数可以找出两个整数中的最大值)
#include <stdio.h>
int find_max(int x, int y)
{
return (x > y) ? x : y;
}
int main()
{
//写一个函数可以找出两个整数中的最大值。
int num1 = 0;
int num2 = 0;
scanf("%d %d", &num1, &num2);
int max = find_max(num1, num2);
printf("Max:%d\n", max);
return 0;
}
该函数,封装了一个求两个数最大值的功能。我们再看一个例子:(写一个函数可以交换两个整形变量的内容)
void Swap(int x, int y)
{
int tmp = x;
x = y;
y = tmp;
}
int main()
{
//写一个函数可以交换两个整形变量的内容。
int num1 = 2;
int num2 = 3;
printf("交换前num1:%d num2:%d\n", num1, num2);
Swap(num1, num2);
printf("交换后num1:%d num2:%d\n", num1, num2);
return 0;
}
代码看起来很符合逻辑,但我想告诉你,这是错误的示例,我们看看结果:
为什么呢?原因在于当我们要再函数内部修改函数外部的变量时,其实是做不到的,因为函数接收的参数其实是一个披着狼皮的🐏,只拿到了值,但不能对外部变量进行修改。我们画图看看吧:
实参传递参数给形参的时候,形参只是实参的一份临时拷贝,形参的改变不会影响实参。
当进行传参时,形参会自动开辟空间来存储值,当函数结束,会自动销毁。
这种只传值,叫做传值。
实参和形参的地址是不一样的,在函数内部将形参的值进行交换,不会影响实参的值。
以下为正确写法:
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
int main()
{
//写一个函数可以交换两个整形变量的内容。
int num1 = 2;
int num2 = 3;
printf("交换前num1:%d num2:%d\n", num1, num2);
Swap(&num1, &num2);
printf("交换后num1:%d num2:%d\n", num1, num2);
return 0;
}
当实参传递的是地址时,这种叫做传址。
此时形参的修改就会影响实参,因为此形参指向实参的那块空间,让函数和函数外边变量建立起真正的联系,也就是函数内部可以直接操作函数外部的变量。
我们再看看一个特别的情况:
这种情况虽然是传递地址过去,但本质上还是一种传值,因为在函数内部,更改了形参的指针指向,也就相当于不在同一个内存空间上,进行地址的交换是没有意义的,不会影响到实参。正确写法:
void Swap3(int* px, int* py)
{
int tmp = *px;
*px = *py;
*py = tmp;
}
int main()
{
int a = 10;
int b = 20;
printf("交换前:a=%d b=%d\n", a, b);
Swap3(&a, &b);
printf("交换后:a=%d b=%d\n", a, b);
return 0;
}
三、函数的参数
3.1 实际参数(实参)
真实传给函数的参数,叫实参。 实参可以是:常量、变量、表达式、函数等。 无论实参是何种类型的量,在进行函数调用时,它们都必须有确定的值,以便把这些值传送给形参,如以下这种情况是不允许的:(find_max函数没有返回值,因此这种情况是不允许的)
void find_max(int x, int y)
{
int n = (x > y) ? x : y;
}
int main()
{
int num1 = 0;
int num2 = 0;
scanf("%d %d", &num1, &num2);
int F_max = final_max(30, find_max(num1, num2)); //find_max函数没有返回值,因此这种情况是不允许的
printf("Max:%d\n", max);
return 0;
}
3.2 形式参数(形参)
形式参数是指函数名后括号中的变量,因为形式参数只有在函数被调用的过程中才实例化(分配内存单元),所以叫形式参数。形式参数当函数调用完成之后就自动销毁了。因此形式参数只在函数中有效。
具体可以看上面的Swap错误交换的例子。
函数在调用的时候,x,y拥有自己的空间,同时拥有了和实参一模一样的内容。 所以我们可以简单的认为:形参实例化之后其实相当于实参的一份临时拷贝
四、函数的调用
可以看看自定义函数中的几个例子。
4.1 传值调用
函数的形参和实参分别占有不同内存块,对形参的修改不会影响实参。
4.2 传址调用
- 传址调用是把函数外部创建变量的内存地址传递给函数参数的一种调用函数的方式。
- 这种传参方式可以让函数和函数外边的变量建立起真正的联系,也就是函数内部可以直接操作函数外部的变量。对形参解引用(*)的修改会影响实参,但单纯修改形参内容中的地址指向,是不会影响实参的。
五、函数的嵌套调用和链式访问
函数和函数之间可以根据实际的需求进行组合的,也就是互相调用的。
5.1 嵌套调用
函数可以进行嵌套调用,如下面的例子:
//函数嵌套调用
int Add(int n, int m)
{
return n + m;
}
void function(int x, int y)
{
int sum = 0;
for (int i = 0; i < 3; i++)
{
sum += Add(x, y); //嵌套调用
}
printf("3次相加后的结果:%d\n", sum);
}
int main()
{
int n1 = 10;
int n2 = 20;
function(n1, n2);
return 0;
}
但需要注意,函数不可以嵌套定义:
void function()
{
//不能嵌套定义
void B()
{
......
}
}
int main()
{
function();
return 0;
}
函数可以嵌套调用,但是不能嵌套定义。
5.2 链式访问
把一个函数的返回值作为另外一个函数的参数。看例子:
//函数的链式访问
#include <string.h>
int main()
{
//printf是打印函数,strlen是求字符串长度函数
//strlen函数的返回值会作为printf的参数
//这种称为链式访问
printf("%d\n", strlen("abcd"));
return 0;
}
再看看这个例子:
int a = 40;
printf("%d", printf("%d", printf("%d", a)));
注意:printf函数的返回值是打印在屏幕上字符的个数,最里层的printf("%d", a),a为40,因此会在屏幕上打印40,那40就为两个字符,4一个0一个,因此返回值为2。
六、函数的声明和定义
6.1 函数声明
- 告诉编译器有一个函数叫什么,参数是什么,返回类型是什么。但是具体是不是存在,函数声明决定不了。
- 函数的声明一般出现在函数的使用之前。要满足先声明(定义)后使用。
- 函数的声明一般要放在头文件中的。
其实不止函数需要声明,变量也是要声明的,如下面的例子:
//变量声明
int main()
{
printf("%d\n", a); // 先使用
int a = 30; //后定义,程序报错
return 0;
}
当我们想先使用后定义,就需要声明了,如:(声明后,变量的定义放置的位置没有限制)
//变量声明
int a; //变量声明
int main()
{
printf("%d\n", a); // 先使用
int a = 30; //后定义,程序报错
return 0;
当然,最常规的方法是先定义后使用,如:
//变量声明
int main()
{
int a = 30; //先定义
printf("%d\n", a); // 后使用
return 0;
}
因此,变量的声明一般出现在变量使用之前。要满足先声明(定义)后使用规则。
对于函数来说,跟变量是差不多,唯一不同的是声明时的结构不同:
//n参数名是可选的,
//函数声明只需要知道参数类型即可
返回值类型 函数名(参数类型 n, ........);
错误情况,先使用后定义,因为程序的唯一入口是main函数,因此将函数定义在main函数后,这是不正确的:
//函数声明
int main()
{
int a = 10;
int b = 20;
int res = Add(a, b); //函数使用
printf("%d\n", res);
return 0;
}
//函数定义
int Add(int a, int b)
{
return a + b;
}
正确代码为:
//先声明后使用
int Add(int, int); //先声明
int main()
{
int a = 10;
int b = 20;
int res = Add(a, b); //函数使用
printf("%d\n", res);
return 0;
}
int Add(int a, int b) //后定义
{
return a + b;
}
//先定义后使用
int Add(int a, int b) //先定义
{
return a + b;
}
int main()
{
int a = 10;
int b = 20;
int res = Add(a, b); //函数使用
printf("%d\n", res);
return 0;
}
当然,声明并不是非要放在main函数的上面,只不过是一种良好的习惯。当c语言程序运行时,编译器会自动扫描整个工程,以此来找到声明:
int main()
{
int a = 20;
int b = 20;
int res = Add(a, b); //函数使用
printf("%d\n", res);
return 0;
}
int Add(int a, int b) //先定义
{
return a + b;
}
int Add(int, int); //函数声明
在C语言中,往往将声明放在.h头文件中,当.c源文件要使用时,只需:#include "头文件",然后就可以随意位置的调用和定义函数了:
add.h文件
/*
用于声明
*/
int Add(int, int);
test.c文件
#include "add.h" //引入头文件
int main()
{
int a = 20;
int b = 20;
int res = Add(a, b); //函数使用
printf("%d\n", res);
return 0;
}
int Add(int a, int b) //函数定义
{
return a + b;
}
6.2 函数定义
函数的定义是指函数的具体实现,交待函数的功能实现。
在用C语言开发时,往往需要模块化的方式进行开发。
模块化的意思就是:
- 将需要用到的声明放置在.h头文件中
- 将函数定义放在.c源文件中
- 然后创建一个.c源文件用于包含头文件,然后调用函数定义的源文件里的函数,完成对应的功能(往往该源文件也称为主程序)
//模块化开发
add.h文件
/*
用于声明
*/
int Add(int, int);
demo.c文件
/*
用于函数定义(函数实现)
*/
int Add(int a, int b) //函数定义
{
return a + b;
}
test.c文件(主程序)
#include "add.h" //引入头文件
int main()
{
int a = 20;
int b = 20;
int res = Add(a, b); //函数使用(调用)
printf("%d\n", res);
return 0;
}
列如开发一个计算器:具体图解
模块化开发还具有封装性、隐蔽性的作用:
场景:当我写了一个c语言程序功能,想卖给别人,但是又不想让别人知道源代码.c里面的代码,自己帮别人进行维护,我们需要做以下几步操作:
- 生成.lib文件。
2. 将.h头文件和.lib文件卖给别人。
.h头文件的作用:存放了功能的全部声明(函数声明)
.lib文件:虽然打开后里面为二进制内容,但还是能调用,进行开发,隐蔽性的特点就体现了。
想使用时,需要将.h头文件和.lib文件放在工程目录下:
当然,有了这两个文件,我们需要引入到主程序中:
#include "头文件"
#pragma comment(lib, "文件名.lib")
七、函数递归
7.1 什么是递归?
- 程序调用自身的编程技巧称为递归(recursion)(通俗将就是自己调用自己)
- 递归做为一种算法在程序设计语言中广泛应用。一个过程或函数在其定义或说明中有之间或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似规模较小的问题来求解,称为递归策略。(通俗将就是大事化小原则)
- 只需要少量的程序就可以描述出解题过程所需要的多次重复计算,大大减少了程序的代码量。(通俗将就是让计算机进行大量的重复计算)
- 递归拆解开,可以理解为递推与回归。
- 递归的主要思考方式在于:把大事化小
我们看个例子:
#include <stdio.h>
int main()
{
printf("递归.....");
main();
return 0;
}
你觉得程序会发送什么呢?答案是会陷入死循环打印递归....,然后程序崩溃。并且如果我们仔细观察,编译器会报一个异常:0x772F88E6 (KernelBase.dll)处(位于 code_4_7.exe 中)引发的异常: 0xC00000FD: Stack overflow (参数: 0x00000001, 0x00B02FEC)。
Stack overflow表示栈溢出。C语言有三块区域,从上往下分别是:栈区、堆区、静态区,下面看示意图:
当进行持续递归时,实际上是在持续创建函数,那么每创建一个函数就会往栈区里放,但是栈区空间也是有上限的,超出上限就会造成栈溢出。
那该如何防止在递归中出现栈溢出的情况呢?这就要遵循递归的两个必要条件了。
7.2 递归的两个必要条件
- 存在限制条件,当满足这个限制条件的时候,递归便不再继续。
- 每次递归调用之后越来越接近这个限制条件。
举个例子吧:
#include <stdio.h>
//就算0~n累加之和
int Add(int n)
{
if(n>0)
return 1 + Add(n-1);
return 0;
}
int main()
{
int n = 10;
int num = Add(n);
printf("%d\n", num);
return 0;
}
这个例子遵循了递归的两个必要条件,缺少任一条件,因此两个条件缺一不可:(会导致无限递归,栈溢出)
#include <stdio.h>
//就算0~n累加之和
//缺少条件一
int Add(int n)
{
//无止境的递归....
return 1 + Add(n-1);
return 0;
}
int main()
{
int n = 10;
int num = Add(n);
printf("%d\n", num);
return 0;
}
//缺少条件二
#include <stdio.h>
//就算0~n累加之和
int Add(int n)
{
if(n>0)
return Add(n); //缺少越来越接近这个的限制条件,也会陷入无止境递归,栈溢出
return 0;
}
int main()
{
int n = 10;
int num = Add(n);
printf("%d\n", num);
return 0;
}
7.3 递归与迭代
- 循环属于迭代的一种形式。
- 许多问题是以递归形式进行解释的,这只是因为它比非递归形式更为清晰。
- 但是有些问题的迭代实现往往比递归实现效率更高,虽然代码可读性稍微差些。
- 当一个问题相当复杂,难以用迭代实现时,此时递归实现的简洁性便可以补偿它所带来的运行时开销。
- 当遇到一些可以列出公式的题目时,使用递归是最合适的。
总结
- 函数的学习是需要在代码中体会出来的,只了解语法,不去练习,是掌握不到精髓的,尤其是函数递归,建议大家在学习函数递归时,要有大事化小的思考方式,并且勤画图,将每个递归进行拆解,这样更容易理解。
- 希望本章节关于函数的知识能帮助到大家,后续还会出一些函数递归的题目讲解,如果您想学习C/C++相关知识,可以关注我❤,持续更新干货!!感谢支持!❤