一、C语言中的函数是什么
我们首先知道函数这个词基本都是在数学中了解的,例如:y = x + 1,这就是一个数学中的函数,x是自变量,y是因变量,我们每次输入一个x的值,都能获得一个唯一的y值。在C语言中的函数也是这样,我们通过传参向函数中输入值,函数通过计算后返回唯一的值。对于函数,我们也称为子程序,通过编写函数,完成整个程序中的部分功能,这样可以将程序分解为模块,提高代码的可读性和可维护性。并且,函数可以重复使用,提高开发效率。
二、函数的分类
1. 库函数
1️⃣库函数是什么
C语⾔的国际标准ANSI C规定了⼀ 些常⽤的函数的标准,被称为标准库,那不同的编译器⼚商根据ANSI提供的C语⾔标准就给出了⼀系列 函数的实现。这些函数就被称为库函数。像我们经常使用的printf和scanf就是库函数。
2️⃣了解并使用库函数
下面推荐一个学习和使用库函数的网站:C library - C++ Reference (cplusplus.com)
这里是库函数的头文件(每个库函数都有自己的头文件,且使用库函数时必须在使用前包含定义它的头文件)。
在上面有搜索功能,输入函数名即可
现在教大家如何学习一个新的库函数(以printf为例):
①函数原型
②函数功能
③参数说明(由于printf参数说明过长就不全部展示了,感兴趣可以自己查看)
④返回类型说明
⑤代码举例
⑥代码输出(例子中的代码)
⑦相关函数链接
2. 自定义函数
虽然C语言提供许多库函数供程序员使用,但在日常编码中,这些库函数是不能完全满足我们的需求,这时就需要我们自己去定义函数满足需求,这些由自己定义的函数就叫自定义函数。
我们要自定义函数,首先就要了解函数的组成:
ret_type:函数的返回类型
fun_name:函数名(可根据需求自己命名)
形式参数:后面讲解
函数体:函数功能实现的地方
我们可以使用函数做一个简单地减法运算,代码如下:
三、函数中的形参与实参
1. 实参(实际参数)
实参:在执行或调动函数时,传给函数的参数。
在上面的代码中,实参就是int c = Sub(a, b);这条语句中的a,b;在调用Sub时,在函数名后面加个()括号中的变量就是实参,实参与实参之间用逗号(,)分开。当然,括号中的实参也可以是常量,表达式,函数等,无论实参是什么类型,都必须有具体的值。
2. 形参(形式参数)
1️⃣形式参数的介绍
形参:在定义函数时命名的参数,用来接收实参传过来的值(只在定义它的这个函数中有效)。
在上面代码中,形参就是int Sub(int a, int b)这条语句中的a,b。由这个代码我们可以知道,命名形参时,需要将形参的数据类型给上,实参与形参的数据类型必须一致,而且形参名可以与实参名一样。
虽然形参接受实参的值,但形参与实参并不共用一块内存空间,形参只是实参的临时拷贝,所以当我们对形参进行修改时,并不会影响实参的值,我们通过一个代码展示:
我们可以看到,明明传了实参过去,并通过形参交换了值,但实参中的值并没有交换,这就是因为实参与形参不是同一块空间,我们也可以看一下实参与形参的内存空间来证明:
(我们这里将形参改一下名,能更好的观察形参的内存地址,&这个运算符是获得右边变量的地址)
从这能更清晰发现这一现象。那么我们如何通过函数交换两个变量的值呢?这将在传址调用中讲解
2️⃣形式参数为空
在我们自定函数时,不是所有的函数都要有传参操作,对于有些函数,我们只需要执行时输出其中内容即可,不用传任何参数,例如我们做游戏时的菜单函数:
menu函数前面的void返回类型在函数的返回中谈到
四、函数的返回(return 语句)
1. 函数返回的介绍
在前面写减法代码中,我们的Sub函数前面是int 类型,这表明我们在这个函数结束时需要返回一个int 类型的值。所以,我们有了这条语句:return a - b; 由于int - int还是int类型,我们确实返回了一个int的值。那么,这个return后面接的就是我们要返回的值,且当执行到了return语句后,就会直接结束这个函数的执行,返回到调用它的函数,并将return后面计算得到的值带出。(return后面的值得类型要与函数返回类型一致)例如:在减法代码中,我们用int c = Sub(a,b);表示用c接收了Sub函数的返回值。
2. return语句的注意事项
我们使用函数时,大多是情况下都需要得到函数的返回值,这样就会经常使用return语句。在这里讲一下return语句当中的注意事项。
①return后面可以是一个数值,变量,表达式等,如果是表达式则会先计算出表达式的值,然后将这个值返回。
②当return后面的值与函数返回类型不一致,系统会自动将值的类型隐式转换成函数的返回类型。
③执行了return语句后,函数中return后面的代码将不再执行。
④如果函数中存在if等分支语句,要确保每一种情况都能有return返回,否则编译器会报错
3. 返回类型为void
在上面我们写了menu的函数,这个函数的返回类型就是void,由这个函数可以知道,对于void 返回类型的函数,函数体中可以不出现return,这个也表明不需要这个函数返回任何值。那么,如果我们想要void 类型函数提前结束,return又不能返回值咋办?这时只要写return; 就可以不执行return后面的代码。
注意:return; 这种写法只能在void类型的函数中使用
五、函数的调用
1. 传值调用
我们再写一个加法的函数:
c获得的是a的值,d获得的是b的值,在上面我们也了解到c只是a的临时拷贝,所以当我们使用传值调用时,我们对形参的操作不会影响的实参,那么我们能不能通过形参影响到实参呢?答案肯定能,就是下面的传址调用。
2. 传址调用
什么是传址调用?顾名思义,就是在调用函数时,将实参的地址传给函数。
这里简要介绍一下两个运算符:&,*。
&:取地址运算符。可以获得该运算符右边变量的地址,例如:&a,这个表达式的值是a的地址。
*:解引用运算符。*右边必须是指针变量(指针变量的创建的例子:int * a; a为指针变量,指针的具体讲解将在指针那篇文章)。
接下来我们完成通过函数交换两个数的值得代码:
在这里,我们传的是a,b的地址,形参得到地址后,可以对地址上的内容操作,从而影响到实参。
六、函数的嵌套调用和链式访问
1. 嵌套调用
在C语言中,允许在函数中调用其他函数,称为嵌套调用。
这就是嵌套调用,在一个函数中调用其它函数。
2. 链式访问
链式访问: 把一个函数的返回值作为另一个函数的参数。
在讲到库函数时,我们以printf为例子,它的返回类型是int,表示本次打印的字符的个数。
我们来看一个典型例子:
大家可以思考一下会输出什么(请注意%d后面有一个空格)
七、函数的声明与定义
1. 函数声明
首先,在了解函数声明前,我们要知道编译器是如何编译的。编译器在编译时,是从代码的第一行往下开始一行一行的编译,那么当我们使用了一个函数,而编译器却在我们调用这个函数之前没有看到过这个函数,通俗讲就是不认识这个函数。这时,编译器就会报错。
可以看到编译器警告说:“Add”未定义,而我们实际上在下面已经定义了,这就是因为我们调用时,编译器并不认识Add,这时就会报错,所以我们为了避免报错,可以在调用之前对这个函数进行声明。
这时编译器就没有报错,这个声明就像是让编译器对这个函数混个脸熟,等我们调用时编译器就认识这个函数,就不回去报错。
声明是让编译器知道这个函数,那么需要编译器了解这个函数的哪些部分(以Add函数为例):
①函数名:Add是我们这个函数的函数名
②返回类型:Add前面的int 是函数的返回类型
③形参:Add括号中的定义的变量
但函数的具体操作,编译器通过这个声明并不能知道,这需要函数的定义。
2. 函数定义
函数的定义:函数实现功能的具体操作。
当我们将函数的定义放在调用之前,那就不需要再声明该函数
注意:我们前面了解到函数的嵌套调用,但不能在函数中嵌套定义,也就是不能再一个函数中定义另一个函数。
3. 多文件中使用函数
当我们在工作中,往往是写复杂的代码,代码量就会很多,这时我们会根据函数的功能将它们放入相应的文件中。⼀般情况下,函数的声明、类型的声明放在头⽂件(.h)中,函数的实现是放在源⽂件(.c)⽂件中。
例如:我们写个处理整数加减乘除的程序
在运行程序前,只需要将对应头文件包含进来就可以
那么,我们为什么不让声明和定义在一起呢?这就涉及到代码加密。具体操作不在这里讲解。
九、static和extern
static和extern都是C语言中的关键字
static是静态的意思
extern是声明外部变量
static可以用来修饰局部变量,全局变量,函数
1. 作用域和生命周期
在谈static和extern之前,我们先来了解一下作用域和生命周期
1️⃣作用域
作用域(Scope) ,就是变量的有效范围,就是变量可以在哪个范围以内使用。
局部变量的作用域是从定义开始,一直到定义它的这个{}作用域结束为止。
全局变量的作用域从定义开始一直到整个程序(项目)结束
2️⃣生命周期
在 C 语言中,变量的生命周期指的是该变量存在的时间段(从创建到销毁)
局部变量的生命周期从定义开始,一直到定义它的作用域结束被销毁
全局变量的生命周期是整个程序。
2. static修饰局部变量
当一个局部变量被static修饰后,它的作用域依然不变,但它的生命周期变为整个程序。
由于生命周期为整个程序,所以当我们再次执行到定义它的语句时会直接跳过,因为已经创建了该变量,也不会再次初始化
这时没有static的情况:
这是有static的情况:
结论:static更改了num的生命周期,本质上更改了num的存储类型,局部变量存储在栈区,而被static修饰后,存储在静态区,静态区的变量知道程序结束才被销毁。
3. static修饰全局变量和extern的使用
全局变量的作用域是整个程序,那么我们在一个文件定义了全局变量,在另一个文件中想使用时,可以使用extern来声明,告诉编译器要使用一个外部的变量。
那么,被static修饰的全局变量那里发生了改变呢?生命周期没有变化,但是作用域由整个程序变为定义这个全局变量的源文件,作用域缩短了。
结论:全局变量本身具有外部链接属性,但是被static修饰后,就变为了内部链接属性。所以当我们创建了一个全局变量,但不想这个变量在其他文件中使用时,我们可以用static修饰它。
4. static修饰函数
函数本身的作用域与生命周期也跟全局变量一样,直到程序结束才销毁。同样,函数再被static修饰后使得该函数的外部链接属性变为内部连接属性,只能在定义它的源文件使用,其他文件不可使用。
十、函数递归与迭代
1. 什么是递归
我们在上面说到函数的嵌套调用,可以在函数里调用其他函数。那么可不可以在函数中调用他自己呢?答案是可以,而且函数自己调用自己就被称为递归。
那么,我们不妨来像嵌套调用一样写一个递归的简单代码。
能够看到代码确实能够运行,但编译器警告说函数将导致运行时栈溢出,我们这只是为了展示递归的基本形式就是函数自己调用自己,在实际编程中,我们不能写出这样的代码,否则会导致程序崩溃。所以,递归虽然可以说是函数调用自己,但与函数调用其他函数有着较大差异,这就是我们要讲到的递归的限制条件。
首先,我们在讲递归的限制条件之前,先来聊聊递归的思想,也就是为什么使用递归。
当遇到一个大而复杂的问题时,我们思考能不能将这个问题简化,也就是将一个大的问题拆分成小的问题,然后将这个小的问题再拆分成更小的问题,如此循环往复,直到这个小的问题不能再被拆分为止,这就是递归的过程,也叫大事化小的过程。
结论:当我们使用递归时,应该清楚地知道如何大事化小,也要清楚什么时候不能再拆分,递归递归,要能递推下去,也要能回归,而我们上面这个代码就是只递推而没有回归。我们接下来细细体会是如何递推,如何回归的。
2. 递归的必要条件
根据上面的大事化小的过程,我们就导出了递归的两个必要条件:
①递归一定要存在一个限制条件,使得当满足这个条件时,递归不在继续,也就是问题已经拆分到最小的问题了,不能在拆分的时候。
②每次进行递归调用都需要朝着那个限制条件逼近,确保能够到达那个限制条件,终止递归,也就是拆分问题时一定是将大的问题往小的拆分,不能越拆分问题越大,那得不偿失。
3. 递归的练习
接下来我们通过几个简单地例子来感受递归是如何设置限制条件,如何不断逼近限制条件的。
1️⃣计算从1加到n的和
想必大家对这个题目是手到擒来,那么用递归如何写呢?
首先,我们来思考一下能不能将这个问题简化,拆分成更小的问题呢?这显然可以。
假设我们使用Add函数求得1到n的累和,那么Add(n)就是1到n的累和,接下来我们尝试拆分一下。Add(n)相当于n + Add(n-1),并且我们已经知道了n,Add(),而Add(n-1)相当于n-1 + Add(n-2),我们又将问题拆分下去,如此往复,最后就是Add(2)相当于2 + Add(1)。
如此,我们就知道了,当n = 1时,问题已经无法再拆分了,那么n = 1就是递归的限制条件,我们每次递归后都使得n - 1就可以,而每次n - 1就是在不断逼近n = 1这个限制条件(n>= 1,因为是从1 加到 n)。
下面是递归代码。
接下来我们来具体分析Add函数是如何调用自己的,并且成功计算出正确答案。
我们以上面n = 5举例。
我们在通过另一幅图来感受一下:
在了解了递归的机制后,我们再来一个简单地题目。
2️⃣正序打印一个正整数的每一位数字
例如:我们我们输入一个整数11523,输出:1 1 5 2 3.
我们如果不使用递归,就使用循坏输出:
我们可能会很自然的想要这么写,但实际上这么写,输出的这个整数的逆序输出:
如果我们想要让他正序输出,反而要多费一番手脚,但在我们了解递归之后,我们可以用递归很容易实现。
接下来我们来看一下代码是如何执行的:
在这之前,我们要先了解递归调用中,在调用他自己的前面的语句和后面的语句是如何执行的。
对于递归前面的语句,会按递推的顺序执行,例如:在计算1加到n的和的递归中,我们每次都是先进入if语句,不符合条件直接进人else语句,每次往下递推时,以当前n的值判断,不断重复执行。
对于递归后面的语句,则会按回归的顺序执行,第一次执行递归后面语句时,是以拆分到最小问题的情况下执行,正序打印整数就是典型例子。
对于递归后面的语句,也就是说只有当执行完递归这条语句后,才会执行递归后面的代码。
4. 递归导致的堆栈溢出
接下来,我们回到最开始递归的那个示例中,为什么没有限制条件或递归过多次会导致堆栈溢出?
首先,我们需要知道,每一次进入函数,编译器都会给当前函数开辟一块内存空间,当我们结束了这个函数,这块内存空间将会被释放,我们可以通过找到函数的地址证明函数本身有一块内存空间
(函数名就是函数的地址)(该空间存放函数调用过程的相关信息)
那么,当我们使用递归时,之前的那个函数会被释放吗?答案肯定不会,否则无法回归后继续执行递归后面的代码。而这就会导致一个问题,当我们递归次数过多时,内存的栈区空间就会不够而导致溢出(存放函数的空间在栈区)
结论:在递推过程中,会不断给新的函数开辟空间,只有到回归时,才会以回归的顺序回收空间,所以我们在使用递归时要注意不能使得递归的次数过多,容易导致堆栈溢出。
5. 递归与迭代
递归在我们的数学公式中随处可见,我们可以使用递归很容易就实现,例如:斐波那契数列。
公式:FIB(n) = FIB(n - 1) + FIB(n - 2) n>2; FIB(2) = 1, FIB(1) = 1;
通过这个公式我们就能使用递归轻易写出代码:
虽然递归这种技巧很好用,但却在运行时开销很大,需要很多空间,也很要时间
对应的迭代就比较高效,能节省空间和时间,但问题就是编写难度会比较大。(迭代一般指循环)
所以,对于递归和迭代的使用,我们是情况而定:
①当我们使用递归能够完成编写,编写的代码比较简洁,且没有明显问题,就可以使用递归
②如果使用递归时出现明显问题,就需要思考如何使用迭代编写。
就拿那个斐波那契数列为例:
我们使用递归编写的那个代码十分简洁明了,但对于较大点的数字,这个程序就要运行很久,不妨可以在编译器中输入50,编译器不能像以前立刻出现答案,反而要等一段时间,当数字更大时,计算时间会迅速增加,这时我们来思考一下如何使用迭代实现斐波那契数列。
公式中有三个数:FIB(n),FIB(n-1),FIB(n-2),我们假设c表示FIB(n),b表示FIB(n-1),a表示FIB(n-2),我们让a = 1,b = 1,表示FIB(1), FIB(2),c = a + b;这时c就是FIB(3),然后让a = b,b = c,使得a变为FIB(2),b变为FIB(3),再次c = a + b,如此循环下去,就能得到想要的值。
代码如下:
结论:递归虽好,但我们不能迷恋他,也要多关心关心迭代。也希望动动发财的小手点个赞