在学习C语言的时候,无论是刚接触函数,还是已经接触过函数的同学或多或少对函数都有疑惑,今天,我们从零开始,层层递进,一直深入到底层原理,把函数彻底搞懂!
函数的概念
这里我们不说官方的有一点难懂的定义,C语言的函数和数学中的函数本质其实是一样的。
数学中中函数的有自变量(输入),经过一定的运算后,得出因变量(输出)。
C语言中也非常相似,维基百科把函数解释为子程序,我们说的输入在C语言的函数中称为参数,我们说的处理的部分叫函数体,得到的结果叫**返回值。**把函数理解为子程序,程序是用来帮我们解决问题的,因为函数的最终目的是也帮我们处理解决一些事情,参数和返回值其实不是必要条件,具体要看它的用途。
函数的调用
函数的调用需要用到函数调用操作符()
调用方式为:函数名();
如果函数需要传递参数:函数名(参数1,参数2,...);
如果需要接收函数的返回值:类型名 变量名 = 函数名(参数1,参数2,...);
举个例子吧!
假设我们的需求是:求一个字符串中字符的个数,C语言中有封装好的strlen
函数供我们使用,它的功能就是求字符一个字符串中字符的个数(不包括终止字符\0
)。
我们可以先创建一个字符数组
char str[] = "Hello World!";
我们可以查一下这个字符串有12个字符。
随后通过函数调用操作符()
调用,用len
变量接收返回值。
int len = strlen(str);
最终打印结果。
图示如下:
代码如下:
函数的分类
函数分为库函数和自定义函数。
库函数
其实简单来说就是已经写好了提供给我们的函数,这类函数所实现的功能一般都是我们平常写代码过程中最最常用的功能,比如打印函数printf
,一般由集成开发环境直接提供,我们使用时直接用#include<库函数所在的头文件
>进行引用即可,如果调用库函数并没有引用它对应的头文件,则会报错,我们上面举的例子strlen
函数就是众多库函数之一,它的头文件是string.h
。
常用的库函数的分类:
库函数有很多,常用的可以简单分为以下几类:
-
IO函数 输入/输出函数
scanf,printf,getchar, putchar...
-
字符串操作函数
strlen,strcmp...
-
字符操作函数
islower,isupper...
-
内存操作函数
memset,memcmp...
-
时间/日期函数
time...
-
数学函数
sqrt,pow...
库函数的学习方法
可以看到,库函数有如此之多,一口气把每个库函数都记住这不现实,也不符合我们的编程学习的理念。正确的学习方法是,当需要实现某一个功能时,自己查找相关文档,去学习相关库函数,使用的多了,自然就可以记得。
这里推荐一个学习库函数比较好用的网站:cplusplus
这个文档全篇都是英文,如果英文不好的同学也可以把文档里的内容翻译一下,也建议大家习惯这种查阅英文文档的方式来学习,有很多优质的学习资料都是纯英文的。
接下来我将以一个我们没学过的库函数举例,手把手的教你从零学习一个不会的库函数。
以strcpy函数举例如何学习库函数
进入网站,直接在搜索框上搜索要学习的库函数
函数的各种信息如下图
头文件
首先应该看到的就是strcpy
函数所属的头文件是:string.h
参数返回值类型
随后就是函数的参数列表:
char * strcpy ( char * destination, const char * source );
可以看到,该函数接收两个char类型的指针变量,返回值也是char类型,
其中第二个参数前面有const
修饰,表示该指针所指向的空间里的内容不可更改,对const关键字有疑惑的可以看我上一篇博客:C语言关于const的爱恨情仇
功能描述
简单来说就是把source
指向的字符串内容复制到destination
,包括终止字符\0
,并且复制到这儿就停止。
-
如果想看更详细的解释可以看下面的文字,或者直接查阅原文档
原文:Copies the C string pointed by source into the array pointed by destination, including the terminating null character (and stopping at that point).
翻译:复制被
source
指向的字符串到被destination
指向的数组中,包括终止字符串\0
,并且到\0
停止复制。原文:to avoid overflows, the size of the array pointed by destination shall be long enough to contain the same C string as source (including the terminating null character), and should not overlap in memory with source.
翻译:为了避免溢出,被
destination
指向的数组应当足够长,以便于能涵盖被source
*指向的字符串(包括终止字符串\0
),并且不应在内存中与source
重叠。
参数描述
destination
:复制目的地的地址
source
:要被复制的字符串的地址
返回值描述
目的地的地址被返回
代码演示
图解分析
自定义函数
其实,对于程序员来讲,在写代码的时候需求千奇百怪,再多的库函数也无法满足我们所有要求,这个时候就需要我们自己来写函数。
C语言函数除了库函数(已经封装好的)之外,我们也可以手动自己创造属于自己的函数,在了解概念之前,我们可以看一下自定义的函数到底是个什么东西,下面是自定义函数的一个模板。
ret_type fun_name(type1 para1,type2 para2,...)
{
statement;//语句项
}
//ret_type 函数返回类型 如果不返回用void
//fun_name 函数名
//type1 第一个参数的类型
//para1 第一个参数名
有点难懂?
我们现在写一个简单的加法函数练练手
int Add(int x,int y)
{
return x+y;
}
int main()
{
int a = 1;
int b = 2;
int sum = Add(a,b);//调用Add函数,并用sum接收
printf("%d\n",sum);
}
图示如下
当然也有一些需求并不需要我们返回值,那么这个函数的返回类型就是void,再举一个例子
写一个交换两个整型变量的函数
#include <stdio.h>
void Swap(int x, int y)
{
int z = 0;//创建一个中间变量
z = x;
x = y;
y = z;
}
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;
}
打印结果:
不对,为什么我们这个代码没有完成交换两个整型的值?别急看完下面你自然就找到答案啦。
形参和实参
实参:真实传递给函数的参数。
对于函数,我们有可能需要给它传递参数,在函数执行前就已经存在的参数,我们可以称为实参。
这里可以是变量,表达式,甚至是函数。
不管实参是什么形式,它总之一定是一个确定的值。
难道还有参数在函数调用时才创建吗?
没错,那就是形参。
**形参:**形参其实就是函数名后括号中的参数。
形参是在函数调用时才会被分配空间创建,因此叫形式参数,函数调用结束后会销毁。
形参终究会被销毁,它其实是实参的临时拷贝!既然是临时拷贝,那就说明形参的改变并不能影响实参的改变,这也是为什么我们上面的Swap函数并不能如我们所愿去交换两个整数。
问题一:如何区分什么是形参什么是实参?
用上面的Add函数来举例分析,到底什么是形参什么是实参!
问题二:形参和实参的关系是什么?
我们说过,形参其实是实参的一份临时拷贝,比如在使用Swap函数的时候,我们虽然传递的是我们创建的a和b,但是函数调用的时候会复制一份同样的参数作为形参,在函数内部使用,最后形参被销毁,我们的实参本身并没有发生改变。
问题三:为什么实参竟然可以是函数?
这个问题就涉及到函数的链式访问啦,在文章后面我们解答这个问题。
函数的传值调用
函数的传值调用就是我们传递给函数的参数,是一个实际的值,函数在调用的时候会复制这个值作为形参,这就是传值调用,上面我们的Add函数和Swap函数都是传值调用。
所以,这个时候想真正的交换我们的实参,一定不能用传值调用,我们这里选择,传址调用。
函数的传址调用
传址调用顾名思义,其实就是把变量的地址作为参数传递给函数,形参用指针变量接收。
这个时候虽然形参还是实参的一份临时拷贝,但是形参存放的是和实参一模一样的地址,通过解引用可以访问相同的空间,这样就能实现上面Swap函数的功能。
代码如下:
#include <stdio.h>
void Swap(int* x, int* y)
{
int z = 0;//创建一个中间变量
z = *x;
*x = *y;
*y = z;
}
int main()
{
//交换前
int a = 1;
int b = 2;
printf("a = %d, b = %d\n", a, b);
Swap(&a, &b);//把a和b的地址传给函数
printf("a = %d, b = %d\n", a, b);
return 0;
}
图示如下:
现在你是不是明白了传值调用和传址调用的区别,传值调用并不能在函数内部直接操作函数外部的变量,因为仅仅是一份拷贝。传值调用则可以让函数与外部产生真正的联系,也就能正确实现我们的Swap函数。
函数的嵌套调用
函数的嵌套调用也很好理解,其实就是在一个函数的内部调用另一个函数。这种场景无处不在,我们知道,C语言的入口就是main
函数,main
函数也是一个函数,main函数内部可以调用其他函数,其他函数当然也可以!
下面是一段代码举例:
#include <stdio.h>
void test2()
{
printf("Hello World!\n");
}
void test1()
{
int i = 0;
for (i = 0; i < 3; i++)
{
test2(); // 调用test2函数
}
}
int main()
{
//调用test1函数
test1();
return 0;
}
函数的链式访问
前面埋下了一个伏笔,为什么函数可以作为一个函数的参数,也就是为什么实参竟然可以是一个函数?
这其实就是函数的链式访问!
链式访问就是:函数的返回值可以作为另一个函数的参数。
我们同样可以举个例子,我们可以直接把Add(a,b)
整体作为printf
的参数,这样同样会打印出结果,如下图:
函数的声明和定义
不知道大家有没有发现,我们所有自定义的函数,都放在了主函数的上面,这样做是有一定道理的。
假设我们把自定义的函数的定义部分放在了函数的后面,程序从上到下扫描,到主函数开始执行,这个时候主函数调用我们的函数,主函数是找不到这个函数的!
这并不是说我们就不能把我们定义的函数放在主函数的后面,我们可以提前声明一下这个函数。
定义其实也是一种特殊的声明,假如函数的定义部分放在前面,后面可以直接使用这个函数。
声明就是告诉编译器,有一个函数的名字叫什么,参数是什么返回值是什么,至于真的有没有这个函数,那是另一回事,但是最起码编译器有这个底气,它可以放心的寻找下去。
所以函数一定要满足,先声明后使用
以Add
函数为例,函数的声明格式为
int Add(int x,int y);
在企业中写代码,一般都要函数的定义与声明分离
函数的声明一般都要放在头文件,当我们需要用这个函数的时候,直接引用这个头文件即可。
这样以后自定义函数特别多的时候,代码看着也更加清爽。
由于函数具有外部链接属性,所以只要函数的声明放在了调用之前,不管函数的定义在不在这个源文件内,都可以被识别到(不知道什么是外部链接属性,可以看我之前的博客中有提到:C语言中static关键字用途详解)。
后面的博客中,我会写一个通讯录小程序,演示如何将定义与实现分离,并且为什么要这样做。
函数递归
程序对自身的调用的编程技巧就是递归:
函数的递归就是特殊的嵌套调用,函数自己调用自己的方式就叫做递归。
递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量.
递归的主要思考方式在于:把大事化小。
递归其实是对初学者比较不友好的一个地方,因为递归的思想并不好理解,我们后面可以通过实例来说明。
我们可以想象一下,一个函数一直自己调用自己,这是不是有点类似于循环?对于循环来讲,必须要有一个限制条件,并且接近这个条件,以便于我们跳出循环,避免死循环。
我们同样也要避免死递归。
递归的两个必要条件
1.递归的函数内必须有一个限制条件,当满足这个条件的时候,递归不再继续
2.随着递归调用的进行,必须越来越靠近这个条件。
递归开辟和销毁空间的图示
递归的实例-递归法求第n个斐波那契数
斐波那契数:前两项是1,随后每一项都是它的前两项的和
1 1 2 3 5 8…
#include<stdio.h>
int Fib(int n)
{
if (n <= 2)//递归的终止条件
{
return 1;
}
else
{
return Fib(n - 1) + Fib(n - 2);
//返回这个数的前两个斐波那契数
}
}
int main()
{
int n = 0;
scanf("%d", &n);
printf("%d", Fib(n));
return 0;
}
递归最终的输出结果依赖于它调用的函数的返回结果,如果一个函数 (1)
反复调用了自身函数(2)
,当被调用的函数(2)
没有给出返回值时,函数(1)
会一直等待它的返回。
递归的弊端
看着递归代码如此简洁,其实它也是有弊端的,上面这个求斐波那契并不是一个好的算法。上面我们求的是第4个斐波那契数,就调用了5次函数才求出来,可以想象,如果要是求第40斐波那契数,那执行的次数是很恐怖的,如此效率低下的代码,其实完全可以用循环去解决。
函数的栈帧创建和销毁
-
递归到底是怎么实现的?
-
形参和实参有什么样的关系?
-
函数调用是怎么做的?调用完结束后是怎么返回的?
-
函数是怎么传参的?顺序是怎么样的?
-
为什么未初始化的局部变量的值是随机的?
-
局部变量是怎么实现的?
等等各种各样的问题都需要我们去探讨,当然,到了这个阶段,就需要我们更加关注底层了,如果对这个部分感兴趣,可以看一下我之前写的博客:函数的栈帧创建和销毁 希望你能找到你的答案!
预告:常见库函数的实现
现在我们对函数已经有了更深的认识!后面的博客中,我会模拟实现常见的库函数的功能,具体实现方法各有不同,重点是我们能够更深的更好的理解函数!