C 生万物 | 函数的讲解与剖析【内附众多案例详解】
一、函数是什么?
首先我们来了解一下函数是什么?数学中我们常见到函数的概念。但是你了解 C 语言中的函数吗?百度百科中对函数的定义:链接
在计算机科学中,子程序(英语:Subroutine, procedure, function, routine, method,subprogram, callable unit),是一个大型程序中的某部分代码, 由一个或多个语句块组成。它负责完成某项特定任务,而且相较于其他代 码,具备相对的独立性。
一般会有输入参数并有返回值,提供对过程的封装和细节的隐藏。这些代码通常被集成为软件库。
二、C 语言中函数的分类
1、库函数
<font size = "4">==为什么会有库函数?==</font>
① 我们知道在我们学习 C 语言编程的时候,总是在一个代码编写完成之后迫不及待的想知道结果,想把这个结果打印到我们的屏幕上看看。这个时候我们会频繁的使用一个功能:将信息按照一定的格式打印到屏幕上【printf】② 在编程的过程中我们会频繁的做一些字符串的拷贝工作【strcpy】③ 在编程是我们也计算,总是会计算 n 的 k 次方这样的运算【pow】
像上面我们描述的基础功能,它们不是业务性的代码。我们在开发的过程中每个程序员都可能用的到,为了支持可移植性和提高程序的效率,所以 C 语言的基础库中提供了一系列类似的库函数,方便程序员进行软件开发。
那我们要如何去学习库函数呢,给大家介绍一个网站:www.cplusplus如果有遇到不懂的、不会使用的函数,都可以到上面去寻找
我也在上面学习了很多的库函数,总结一下,C 语言常用的库函数都有:
IO 函数
字符串操作函数
字符操作函数
内存操作函数
时间/日期函数
数学函数
其他库函数
==接下去,我会参照文档,给大家将两个常用的库函数,来教会大家如何入阅读英文文档==
再到代码中来看看它的实际应用场景
再来看看运行结果
接下去再来看一库函数【memset】
一样来看一下代码该如何书写
来看一下上面这段代码的执行结果。
除此之外【memset】还有一个很重要的功能,就是==数组的初始化==,这个我们在后面的学习中会频繁使用到
<font size = "4">注:</font><font color = "#0000ff">但是库函数必须知道的一个秘密就是:使用库函数,必须包含 #include 对应的头文件。这里对照文档来学习上面几个库函数,目的是掌握库函数的使用方法。</font>
<font size = "4">再给大家介绍两个 C 语言的官网</font><font size = "5"> en.cppreference.com【英文版】</font><font size = "5">zh.cppreference.com【中文版】</font>
2、自定义函数【⭐⭐⭐】
如果库函数能干所有的事情,那还要程序员干什么?
所以更加重要的是【自定义函数】
自定义函数和库函数一样,有函数名,返回值类型和函数参数。但是不一样的是这些都是我们自己来设计。这给程序员一个很大的发挥空间
函数的组成:
我们首先来举一个栗子🌰
写一个函数可以找出两个整数中的最大值。
然后给大家讲解一下这个函数
接下去的一个例子,如果对函数的【实参】和【形参】不太了解的小伙伴可以先去看一下下一个模块,而且还会涉及到【传值调用】和【传址调用】的概念,如果有不懂的也先去看一下再来看这个例子。因为我这些都会涉及到
接下去我们再来举一个例子,也是函数这一块最经典的案例:【数值交换】
首先来看看代码,我将交换两个数这个逻辑单独封装成了一个函数,因为它是作为一个功能出现的
来看一下运行结果。可以看到两个数并没有发生交换
那有小伙伴就很诧异,这是为什么呢?我们一起来调试分析一下
上面是笼统地看,我们通过地址再来看看
==你可以这么认为:形参实例化之后其实相当于实参的一份临时拷贝==
上面这一个,叫做【传值调用】,函数内部形参的修改是不会影响实参的,接下来我们来讲讲【传址调用】
虽然==指针==这一块我还没有讲到,但是我在初始C语言那块儿已经有讲到指针这个内容了,如果不了解的可以先去看看一下。因为真正地修改一个函数外部的实参值,我们只能使用对指针进行解引用来进行修改,然后如果你继续学下去的话还可以使用【二级指针】【C++引用】的方式,也是可以做到的,这里我就讲一级指针接收地址这一块
先看一下代码,然后我们再通过 DeBug 来展开分析一下
最后来看一下运行结果
<font size = "5">讲到这里,相信你对这个案例应该有了一个深刻的理解了</font>
三、函数的参数
1、实际参数(实参)
真实传给函数的参数,叫实参
对于实参而言,它可以是【常量】、【变量】、【表达式】、【函数】等。我们到 VS 里来测试一下
无论实参是何种类型的量,在进行函数调用时,它们都必须有确定的值,以便把这些值传送给形参
2、形式参数(形参)
形式参数是指函数名后括号中的变量,因为形式参数只有在函数被调用的过程中才实例化(分配内存单元),所以叫形式参数。形式参数当函数调用完成之后就自动销毁了。因此形式参数只在函数中有效。
所以我们可以总结出来一句话:
==【函数调用时,实参传递给形参,形参是实参的一份临时拷贝,形参的改变不影响实参】==
四、函数的调用
这里我们再来说一下函数的【传值调用】和【传址调用】,这一块在上面的案例中也涉及到了
1、传值调用
函数的形参和实参分别占有不同内存块,对形参的修改不会影响实参
2、传址调用
传址调用是把函数外部创建变量的内存地址传递给函数参数的一种调用函数的方式。这种传参方式可以让函数和函数外边的变量建立起真正的联系,也就是函数内部可以直接操作函数外部的变量。
3、专项练习
看了这么多,接下来就要来实践一下了,我们通过四道练习题来进行训练
3.1 素数判断
首先是对于素数的一个判断。【素数】又叫质数。素数,指的是“大于 1 的整数中,只能被 1 和这个数本身整除的数”
知道了规则,那代码就好写了。我们来输出一下 1~100 的所有素数。在外部写一个循环,就可以获取 1 - 100 之间的数字了,然后对于素数的求解,可以单独封装为一个函数
首先来思考需要==传入哪些参数==,很明显:只需要传入被判断的数字即可,然后就要去思考这个函数的==返回值==,可以这样设定:在函数内部若是判断出其为素数,那么就返回 1,否则就返回 0,然后在主函数外部进行一个判断即可
接下去我们来看看函数的部分
抛开其他部分,我们先来看主体,思考一下为什么这个循环的结束条件我要写成
sqrt(n) + 1
,当然这里是开区间,你也可以写成闭区间的形式<= sqrt(n)
,设想一下一个数16 = 2 * 8
,16 还可以表示成16 = 4 * 4
,那我们就可以发现,若是找到一个 16 的公因子【2】,其实就可以不做判断了,直接 return 0 即可,sqrt(n)
其实就相当于这个【4】,前面的 2 已经到了,我们就可以这个算数平方根作为分解点,不需要再往下找了再举一个例子
9 = 3 * 3
,sqrt(9) = 3
,我们在进行判断的时候,i 为 3 时其实就不需要在向下判断了,因为 9 对 3 取余为 0,因此【9】就不是一个素数了,这么说你应该可能明白了,素数判断的方法有很多种,大家主要先记住这一种即可
然后其他的部分就是对于特殊情况的判断,也就是【1】和【2】这两个数进行一个单独的判断
然后我们来看一下运行结果
3.2 闰年判断
接下去再进行一个闰年判断的练习
【闰年】公历年份是 4 的倍数,且不是 100 的倍数,为普通闰年。公历年份是整百数,且必须是 400 的倍数才是世纪闰年
一样,我们先将主函数写好。需求是输出一下 1000~2000 之间的所有闰年
然后的各方面条件和上面是一样的,便不做详解,我们来看看函数体
这一块我给出两种方法,第一种就是直接了当一些,若是能被 4 整数但是不能被 100 整数,或者可以被 400 整数的数,那就是一个闰年,此时返回 1 即可,反之返回 0,利用到了【逻辑操作符】
第二种方法就是一个 if 的嵌套判断,这里要注意的一点是对 400 取余的条件外面要写成【if】,不要写成【else if】,否则两个判断条件就只会进去一个了,那就回漏掉几个年份
来看一下运行结果
3.3 二分查找
接下去来做一道很经典的题目 ——>【二分查找】
首先我们需要设定一个有序数组,因为==二分查找只能在序列有序的情况下才可以进行==
可以看到,上面除了设定数组以外,我还求出这个数组的元素个数,因为在进行函数传参的时候需要使用到
来看如何传值以及接收返回值,首先我们需要将这个数组的【首元素地址】传入,因为对于一个数组而言,你无法将整个数组都传入函数体中,只能传入这个函数的首元素的地址,那既然是地址,就可以使用指针来进行接收,当然最具有可读性的还是数组接收数组,若是你不知道传入的这个是数组的首元素地址,那可能用指针来接受就有点晦涩难懂了
除了要传入数组的首元素地址之外,还需要传入我在上面求出来的【sz】,也就是数组的个数,这一点的话很重要,<font color = "#ff0000" >对于数组的大小一定要在外面求解完再传进去</font>,若是你在函数内部求解数组的大小时,因为传入的是数组的首元素地址,因此
sizeof(arr)
和sizeof(arr[0])
就都是一样的 4 个字节,因为整型元素都是 4 个字节的大小,所以求出的 sz 就为【1】了,此时就会发生问题。最后的话需要查找的值肯定也需要进行一个传入
对于二分查找这一块可以看看我的另一篇文章 ——> 二分法与折半插入排序
3.4 修改数值
接下去一个练习就是通过函数内部的修改带动函数外部数组的修改,这里就要使用到我们上面所说的【传址调用】。先来看看代码
在函数外部,我传入了 num 值的地址,然后在函数体的形参中使用指针来进行接收,此时对这个指针进行解引用就可以获取到外部的实参,就可以直接对其进行修改了,这里主要写成
(*px)++
,不能写成*px++
,因为【++】的优先级比【*】来得高,所以会先对这个指针变量进行一个++,然后再对其进行一个解引用的操作,但是当这个指针后移的时候,就已经变成了==野指针==,此时再去访问这个野指针就会出现问题,后面给出运行结果可以其进行一次的修改,那一定也可以进行多次的修改,我们通过运行结果来看看
但是我们上
*px
外的括号去掉再看看
五、函数的嵌套调用和链式访问
1、嵌套调用
函数和函数之间可以根据实际的需求进行组合的,也就是互相调用的
首先来看第一个例子。非常好理解,可以自己看看
==函数可以嵌套调用,但是不能嵌套定义==
2、链式访问
把一个函数的返回值作为另外一个函数的参数
这里我先介绍一下【strlen】这个函数
对于
strlen()
和sizeof()
我会在后面的【面试题】专栏中进行细致讲解然后我们到代码里面来用一下
<font color = "#ff0000" >这里要注意一点的是 strlen()去求字符串长度的时候是不算'\0'的</font>
接着再来介绍一个函数【strcat】
好,接下去我们来看看如何做到链式访问
接下去再来看一下链式访问的例子。你知道这句代码输出的结果是多少吗
来揭晓一下结果吧
那有同学就很诧异,为什么这句代码的输出是【4321】呢,我们来看一下【printf】这个函数
可以看到对于 printf 的返回值是输出字符的个数,那这就可以解释通了:对于内层的
printf("43")
输出的字符个数有 2 个,然后再看外层的printf("%d", printf("43"))
便会在输出一个 2,它的返回值就是输出了一个字符,那么整体的这个printf("%d", printf("%d", printf("43")))
就会在输出一个 1,作为返回值
六、函数的声明和定义
1、函数声明
告诉编译器有一个函数叫什么,参数是什么,返回类型是什么。但是具体是不是存在,函数声明决定不了。
函数的声明一般出现在函数的使用之前。要满足==先声明后使用==。
函数的声明一般要放在头文件中的
来看一下代码
2、函数定义
函数的定义是指函数的具体实现,交待函数的功能实现
对于上面的代码,我们可以对其做一个分解,将【add】这个功能单独放到一个.c 的文件中,然后再写一个.h 的文件去声明一下这个函数。因为在日常的项目工程中,是有很多程序员一起写代码的,但是他们不可能在一个.c 文件中一起书写,也不能等一个人写好之后另一个人再接着写,这样就会降低开发效率
所以我们就有一个==分模块编写==的说法,也就是在一个头文件中先写好函数的声明和各种库函数的引用和定义,然后每个程序员都可以创立自己的.c 文件,然后去书写自己的那段逻辑,最后将大家的代码进行一个整合,就完成了整个项目
上面就是对【add】整个功能做了一个分模块的编写。在后面学到三子棋和扫雷的时候还会教大家用这种分模块编写
七、函数递归
==接下去我们来说说函数递归,这也是函数这一块最难理解的内容==
1、什么是函数递归?
程序调用自身的编程技巧称为递归( recursion)只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量递归的主要思考方式在于:把大事化小
2、函数递归的两个必要条件
[x] 存在限制条件,当满足这个限制条件的时候,递归便不再继续。
[x] 每次递归调用之后越来越接近这个限制条件
3、题目的展开与剖析
3.1 例题 1:打印数字
==【需求】:输入 1234,打印 1 2 3 4==
在之前我们学习分支和循环的时候有做过类似的题目,那个时候我们是利用取余【%】和整除【/】倒着打印一个数字的每个数位,也就是下面这段代码
运行结果如下
但是呢,现在我们要顺着将这个数的每一个数位进行一个打印,这该怎么去做呢?我们来分析一下
我们在屏幕上通过 scanf 输入一个数,然后通过封装好的一个函数传入
print(num)
,即print(1234)
,那既然上面讲到了这个分割数字的思路,这里我们也可以使用这个思路来完成,[x] 对于
print(1234)
我们可以拆成print(123) 4
[x] 对于
print(123)
又可以拆成print(12) 3
[x] 对于
print(12)
又可以拆成print(1) 2
那
print(1234)
就被拆成了print(1) 2 3 4
,也就是当这个 num < 10 为一个个位数时,就做一个打印,否则的话就不断将其以十分之一倍得进行缩小。我们将其转化为代码的形式就【一目了然】了可以看到,这里是使用到了一个递归的逻辑,上面说到递归都是具有结束条件的,若是不符合的话就层层递归下去,知道当前传入的 num 值为个位数为止才进行一个回调
运行结果如下
可能还是有同学对于递归有点难理解,没关系,我们通过递归展开图再来看看
通过这么一幅递归展开图的剖析,相信你对函数递归的整体流程应该有了一个了解。但是还是觉得难以有这个思维,没事我们再来看一道例题
3.2 例题 2:求字符串长度
==【需求】:输入 abc,输出其长度为 3==
上面有讲到过 strlen()这个函数,其可以求出一个字符串的长度,我们首先通过这个函数来试试。运行结果后面再给出
可以看到,这个很简单,但是不要忘了包含头文件【string.h】
接下去的话我们将这个 strlen()函数改成 my_strlen(),我们自己来实现一下这个底层的逻辑。对于
int len = my_strlen(str);
,我们传入了字符数组 str 的首元素地址,那上面有讲到过对于地址要使用指针来进行接收,最后还要返回求出的长度,所以对于函数我们可以定义成这样
接下去考虑该如何去实现和这个函数。对于 str 这个字符指针现在是指向传入字符数组的首元素地址,也就是【a】,那你就要搞懂对于一个字符串来说以什么作为结束标志,没错,就是【\0】,因此<font color = "#ff00000">可以让这个字符指针不断后移,每一次对其进行解引用看看是否为【\0】即可</font>,有了思路我们就可以写出代码了
在这里的话还需要设置计数器,我们我们要去求解这个字符串的长度,因此判断到它不为【\0】的时候就 count++,然后让这个字符指针进行后移即可。
可是呢现在我要改变一下题目的要求,<font color = "#ff00000">不可以使用计数器进行求解字符串的长度</font>,那有小伙伴就很诧异了,你让我去求长度,但是连计数器都不让用,这怎么求呢,别急,我们用递归来试试
一样,我们可以使用到上面的分割思想
[x] 对于
my_strlen(abc)
我们可以拆成1 + my_strlen(bc)
[x] 对于
my_strlen(bc)
我们可以拆成1 + my_strlen(c)
[x] 对于
my_strlen(c)
我们可以拆成1 + my_strlen('\0')
那
my_strlen(abc)
就相当于是1 + 1 + 1 + 0
。也是使用字符指针去进行一个后移,若其不为【\0】时,就不断对这个字符串进行拆分,然后直到遇到【\0】时便return 0
。接下去就可以写出代码了
然后来看一下结果吧,上面三段代码的结果都是一样的,所以一起展示了
一样,我们来画一下递归展开图
看了这个相信你对递归的概念又进一步有了了解。但还是感觉有所欠缺,我们再来看一道对于递归来说很经典的题目——阶乘求解
3.3 例题 3:阶乘求解
==【需求】:输入一个数,输出其求阶乘后的结果==
对于阶乘来说可能有小伙伴没听过的,我稍作分析然后先列出一个公式。
阶乘就是从从一个数开始乘,慢慢减少,一直乘到 1 为止,举个例子<font color = "#ff0000">3! = 3 * (3 - 1) * (3 - 2) = 3 * 2 * 1 = 6</font>。再举两个特殊一点的<font color = "#0000ff"> 0! = 1,1!=1 </font>,知道了这些,我来列出一个公式你看看
也就是当【n <= 1】时,即 0 的阶乘和 1 的阶乘最后都是 1,当【n >= 2】时,最后的结果就是 n 去乘以它减 1 的阶乘,于是我们就可以得出递归的代码
对于阶乘来说,不仅可以使用递归实现,还可以使用循环的方式来是实现,这个我们在前面学习分支和循环语句的时候就有讲到过,这里就不作详解了
这里再对上面的递归实现做一个递归展开图的分析
看完这三道例题,相信你对递归的代码写法有了一个初步的认识。接下去我们再通过三道练习题来巩固一下
3.4 练习 1:斐波那契数列
==【需求】:输入一个数,输出从 1 到这个数的斐波那契数==
如果有同学对斐波那契数列不太了解的,可以先去看看——> 斐波那契数列。这里给出从 1~10 的斐波那契数列 【1 1 2 3 5 8 13 21 34 55】,可以看出对于 1,2 两个数来说都是①,后面就是数就是前两个数之和,因此我们可以列出下面的公式
然后根据这个公式写出代码
然后一样通过递归展开图来分析一下,不过对于斐波那契数列的递归展开图可能有些复杂,因为每次递归存在两个分支,这个结构如果学过数据结构的同学应该清楚是二叉树,没学过也没有关系,因为这是 C 语言的文章。下面是我的讲解,希望你可以看得懂:smile:
仔细观察其实可以发现,光是求 5!这么小的数字,就有一些重复计算,也就是【1】和【2】这两个数字。包括【3】也计算了两次,那在这里其实我们可以去打印一下,若是求 50 的阶乘话【3】会计算几次呢
3.5 练习 2:青蛙跳台阶
接下去我们再来做一个很有意思的小练习,这个和斐波那契数列很像
==① 首先来快速了解一下青蛙的跳法==
📚<font color = "#0000ff">青蛙每次可以跳一个台阶或者两个跳台阶</font>
需求是算出当青蛙跳到第 n 个台阶的时候,有几种跳法
==③ 通过画算法图来分析一下==
可以看出,对于第 n 个台阶,青蛙的跳法变化规律为:1——>2——>3——>5——>8,很明显,呈现的就是我们上面所讲到过的【斐波那契数列】,那这就好办了
==④ 来看看代码如何书写==
首先来看看递归版的写法
运行结果
接下去再来看看迭代版的写法
运行结果
对于青蛙跳台阶和斐波那契数列很是类似,因此不做过多分析,可以自己去谢谢代码画图感受一下
3.6 练习 3:汉诺塔问题
汉诺塔是很经典的递归问题,我们一起来看一下
==① 首先来了解一下【汉诺塔】是什么东西==
汉诺塔(Tower of Hanoi),又称河内塔,是一个源于印度古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着 64 片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘
==② 接下去快速了解一下它的规则==
📚<font color = "#0000ff">规则一:每次只能移动一片盘片</font>📚<font color = "#0000ff">规则二:每次移动完后要保证小盘片在大盘片的上面</font>
==③ 然后通过动画来看看盘片的移动过程==
==④ 再来看看代码如何书写==
【运行结果】
==⑤ 马上来讲讲代码的逻辑==
可以看到,在主函数中,我们以三个盘片为例,然后将汉诺塔判断移动的过程封装成了一个函数的形式,因为它要进行递归展开,所以这一块是毋庸置疑的,肯定是一个单独的函数逻辑
然后我们来看看移动盘片函数的内部逻辑,若是当前盘片数量不为 1 个的时候,那就需要移动,否则的话就要去执行一个递归的逻辑,具体的递归逻辑我化成了一个递归展开图,如下图所示
==⑥ 最后通过代码来画出递归展开图==
相信在看完这幅图后的你一定对汉诺塔的移动原理有了一个深入的理解
八、函数栈帧的创建的销毁
本内容请看这篇文章——> https://xie.infoq.cn/article/3a8c79d7daf180e380700f316
九、总结与提炼
好,我们来总结一下本文所学习的内容
在本文中,我们首先了解了什么是函数,然后看了 C 语言中的函数分为哪两哪类,除了给大家详细介绍几个库函数之外,还说明了如何去自己写一个自定义函数,在这其中我们讲到了函数调用的一个非常经典案例——【交换数值】
对于这个案例,也需要使用到后面
形参
与实参
的概念,以及传值调用
与传址调用
的区别,想要在一个函数中真正通过形参的修改使得外部的实参得到一个修改,就需要传入实参的地址,此时就可以与外界建立起一个真正的连接知道了如何去自定义函数,我们通过几个专项练习进行了巩固,加深了对函数传参以及调用的认知和理解。
在说完函数的嵌套调用和链式访问之后,知道了原来函数还可以进行套娃:heart_eyes:有了嵌套调用的基本概念后,我们就正式进入了函数最难的一节——==函数递归==,在这一模块中,我们了解了什么是函数递归,知道了如何使用递归去巧妙地求解实际问题,在【题目的展开与剖析】中,还带着大家做了许多的例题与练习,对函数递归来说最重要的还是
画递归展开图
,可以更好地帮助理解
以上就是本文所要展示的所有内容,感谢您的阅读,如有问题请于评论区留言或者私信我:four_leaf_clover:
版权声明: 本文为 InfoQ 作者【Fire_Shield】的原创文章。
原文链接:【http://xie.infoq.cn/article/c0ad42d57b6b4c9f50a955a81】。文章转载请联系作者。
评论