LV01-07-C语言-函数
本文主要是C语言基础——函数相关笔记,若笔记中有错误或者不合适的地方,欢迎批评指正😃。
点击查看使用工具及版本
Windows | windows11 |
Ubuntu | Ubuntu16.04的64位版本 |
VMware® Workstation 16 Pro | 16.2.3 build-19376536 |
SecureCRT | Version 8.7.2 (x64 build 2214) - 正式版-2020年5月14日 |
开发板 | 正点原子 i.MX6ULL Linux阿尔法开发板 |
uboot | NXP官方提供的uboot,NXP提供的版本为uboot-imx-rel_imx_4.1.15_2.1.0_ga(使用的uboot版本为U-Boot 2016.03) |
linux内核 | linux-4.15(NXP官方提供) |
STM32开发板 | 正点原子战舰V3(STM32F103ZET6) |
点击查看本文参考资料
参考方向 | 参考原文 |
--- | --- |
1.什么是函数?
函数是一个完成特定功能的代码模块,其程序代码独立,通常要求有返回值,也可以是空值。
函数的本质其实就是一段可以重复使用的代码,这段代码是提前编写好了的,我们使用的时候直接调取就可以。函数的实现人员将函数设置为一个“黑盒子”,隐藏实现细节,并对外部提供尽可能简单的接口,因此对于函数调用方来说,只需要关心如何提供参数,根据函数的功能可以得到什么样的结果,而函内部是如何工作的,这并不重要。
我们平时见到的函数,分为库函数和自定义函数。
C 语言自带的函数称为库函数( Library Function )。库( Library )是编程中的一个基本概念, C 语言自带的库称为标准库( Standard Library ,例如我们常用的 stdio.h ),其他公司或个人开发的库称为第三方库( Third-Party Library )。
自定义函数就是我们自己编写的,方便自己使用的函数。
【注意】 C 语言中的函数在数目上没有上限,但是在 C 语言程序中,有且仅有一个名为 main 的函数。
2.主函数 main
我们接触到的第一个函数,其实就是 main ,这个函数被称为主函数,程序的执行总是从 main 开始,是所有程序运行的入口。
2.1 main 函数的形式
最新的 C99 标准中, main 函数有两种形式,一种带参数,一种不带参数。
1 | /* 无参数形式 */ |
1 | /* 带参数形式 */ |
其实我们也有可能会看到这种的 void main() ,但是似乎它并没有在哪种标准中被提及。有些编译器认为这样是正确的,有一些可能会认为这是错误的,所以为了程序可移植性,最好还是使用标准中的形式比较好。
2.2 main 函数的参数
上边我们看到,有一种带参数的主函数形式,那么这两个参数是什么呢?
1 | /* 带参数形式 */ |
argc | 整型变量,表示了命令行中参数的个数(注意:文件名本身也算一个参数) |
argv | 字符串型变量,它是一个指向字符串的指针数组。命令行中的每个字符串都会被存储到内存中,并且分配一个指针指向它。按照惯例,这个指针数组被称为argv(argument value),系统会使用空格把各个字符串格开。一般情况下,argv[0]是程序本身的名称,后边依次是传入的参数。 |
main 函数不能被其它函数调用, 因此不可能在程序内部取得实际值。那么,在何处把参数值赋予 main 函数呢?
实际上, main 函数的参数值是从操作系统命令行上获得的。当我们要运行一个可执行文件时,在命令行键入文件名,再输入实际参数即可把这些实参传送到 main 的参数中去。例如,在 linux 的 Shell 终端下可以这样:
1 | ./file_name arg1 arg2 ... |
点击查看实例
1 |
|
然后在命令行执行:
1 | gcc test.c -Wall # 编译程序 |
会看到终端中有如下输出信息:
1 | argc = 5 |
【注意】
(1)各个参数之间是通过空格隔开,我们要是想输入带空格的参数,可以使用 “ “ 包裹。
(2)在命令行的输入都将作为字符串形式存储于内存中。所以,即便参数是数字,也会被当做字符串,确实需要使用数字,还需注意做一下转换。
3.自定义函数
3.1函数定义
函数定义的一般形式如下:
1 | <数据类型> <函数名称>( <形式参数> ) |
数据类型 | 整个函数的返回值类型,可以是C语言中的任意数据类型,例如 int、float、char 等。可以包括存储类型说明符、数据类型说明符以及时间域说明符。如果没有返回值应该写为 void 型。 |
函数名称 | 函数名称是一个标识符,需要符合标识符的命名规则。 |
形式参数 | 形式参数列表,简称形参。形参可以是任意类型的变量,各参数之间用逗号(",")分隔开。在进行函数调用的时候将会赋予这些形式参数实际的值。例如, dataType functionName( dataType1 param1, dataType2 param2 , ... ) |
return | 函数的返回值,后边表达式的值要求必须和函数名前边的数据类型保持一致。如果前边数据类型为void,那么可以写为 return; 或者全部省略,表示没有返回值 |
{ } | 花括号 { } 中的内容为函数体,函数体内有若干条语句以实现特定的功能。 |
【注意】
(1)在函数体中,表达式里边使用的所有变量必须事先已有说明,否则不可以使用呢。
(2)形参可以缺省说明的变量名称,但类型不能缺省。例如,
1 | double Power(double x, int n) |
(3)函数不可以嵌套定义,就是说,不能在一个函数中定义另外一个函数,必须在所有函数之外定义另外一个函数。 main() 也是一个函数定义,也不能在 main() 函数内部定义新函数。例如,
1 | /* 下边的做法是错误的 */ |
(4) return 语句是提前结束函数的唯一办法。
(5)可以在不同的函数中使用相同的变量名,它们表示不同的数据,分配不同的内存,互不干扰,也不会发生混淆。
(6)函数返回值若为整型,那么在函数定义时可以省略数据类型。但是这样做的话,很有可能会报警告,所以一般还是写上更加规范点。
3.2函数声明
函数定义是对函数功能的确立,包括函数名、函数返回值类型、形参列表、函数体,是一个完整、独立的函数。
函数声明是为了把函数名、返回值类型以及形参类型、个数和顺序通知编译系统,以便在调用该函数时,编译系统进行对照检查,包括函数名是否正确、传递参数的类型、个数是否与形参一致。如若出现不对应的情况,编译会有语法错误。
在 C 语言中,函数声明称为函数原型( function prototype )。用函数原型是 ANSIC 的一个重要特点。它的作用主要是利用它在程序的编译阶段对调用函数的合法性进行全面检查。函数原型的一般形式如下:
1 | <数据类型> <函数名称>( <形式参数> ); |
例如,
1 | double Power(double x, int n); |
推荐一个网站,它提供了所有 C 语言标准函数的原型,并给出了详细的介绍和使用示例:
C library | http://www.cplusplus.com/reference/clibrary/ |
【注意】
(1)如果函数调用前,没有对函数作声明,且同一源文件的前面出现了该函数的定义,那么编译器就会记住它的参数数量和类型以及函数的返回值类型,即把它作为函数的声明,并将函数返回值的类型默认为 int 型。
(2)如果在同一源文件的前面没有该函数的定义,则需要提供该函数的函数原型。用户自定义的函数原型通常可以一起写在头文件中,通过头文件引用的方式来声明。
(3)编译器实际上并不检查参数名,参数名可以任意改变。所以上边的参数名可以省略。
(4)如果函数的声明中带有 static 关键字,那么就表示告诉编译器这个函数只能在当前文件中被调用;如果是函数声明中带有 extern 关键字,表示告诉编译器这个函数是在别的源文件中被定义的。
3.3函数调用
我们定义了函数,接下来就是使用了啊,一般使用形式如下:
1 | <函数名称>( <实际参数> ); |
函数名称 | 就是定义函数的时候的函数名。 |
实际参数 | 可以简称为实参,在使用函数时,调用函数传递给被调用函数的数据。需要确切的数据。如果有形参的话,实参与形参的个数应该相等,类型应该一致。 |
【注意】
(1)如果函数在定义的时候,没有形参,那么调用的时候也不需要传入实际参数,调用形式如下:
1 | <函数名称>(); |
(2)函数调用可以作为一个运算量出现在表达式中,也可以单独形成一个语句。对于无返回值的函数来讲,只能形成一个函数调用语句。
(3)函数调用支持嵌套调用。
如果一个函数 fun1() 在定义或调用过程中出现了对另外一个函数 fun2() 的调用,那么我们就称 fun1() 为主调函数或主函数,称 fun2() 为被调函数。
当主调函数遇到被调函数时,主调函数会暂停, CPU 转而执行被调函数的代码;被调函数执行完毕后再返回主调函数,主调函数根据刚才的状态继续往下执行。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | This is fun1! |
3.4函数地址
函数是一块代码,那自然要在内存中存储,那它的地址怎么获取呢?
函数的名称就代表了函数的地址,我们可以通过函数名打印函数存放的地址空间首地址。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | This is fun1! |
3.5形参与实参
形参 | 在函数定义中出现的参数可以看做是一个占位符,它没有数据,只能等到函数被调用时接收传递进来的数据,所以称为形式参数,简称形参。 |
实参 | 函数被调用时给出的参数是实实在在的数据,会被函数内部的代码使用,所以称为实际参数,简称实参。 |
两者的功能是传递数据,发生函数调用时,实参的值会传递给形参。
- 两者的区别与联系
(1)形参变量只有在函数被调用时才会分配内存,函数调用结束后,立刻释放所分配的内存空间,所以形参只有在函数内部有效,不能在函数外部使用。调用结束回到主调函数(调用该函数的函数)后则不能使用形参中的变量。
(2)实参可以是常量、变量、表达式、函数等,无论实参是何种类型的数据,在进行函数调用时,它们都必须有确定的值,以便把这些值传送给形参,所以应该提前用赋值、输入等办法使实参获得确定的值。
(3)实参和形参在数量上、类型上、顺序上必须严格一致,否则会发生类型不匹配的错误。当然,如果能够进行自动类型转换,或者进行了强制类型转换,那么实参类型也可以不同于形参类型。
(4)函数调用中发生的数据传递是单向的,只能把实参的值传递给形参,而不能把形参的值反向地传递给实参。一旦完成数据的传递,实参和形参就再也没有任何关系了。所以,在函数调用过程中,形参的值发生改变并不会影响实参。
(5) 形参和实参可以同名,但它们之间是相互独立的,互不影响,因为实参在函数外部有效,而形参在函数内部有效。
3.6变量作用域
前边的时候就提到了作用域的概念,但是由于没有学习函数,无法进行验证,接下来就来更加深入了解一下变量的作用域问题。
3.6.1全局变量与局部变量
- 局部变量
定义在函数内部的变量称为局部变量( Local Variable ),它的作用域仅限于函数内部, 离开该函数后就是无效的,再使用就会报错。
点击查看关于 main 函数的说明
在 main 函数中定义的变量也是局部变量,只能在 main 函数中使用。 main 函数中也不能使用其它函数中定义的变量。 main 函数也是一个函数,具有与其他函数平等的地位。
形参变量、在函数体内定义的变量都是局部变量。实参给形参传值的过程其实就是给局部变量赋值的过程。在语句块中也可定义变量,它的作用域只限于当前语句块,也相当于局部变量。例如在 while 循环, for 循环中定义的变量。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
若保存两条会报错的语句,执行完编译命令后,会有如下报错信息:
点击查看报错信息
1 | test.c: In function ‘main’: |
若注释掉会报错的两句,重新执行上边两条接着我们会在终端中看到以下输出信息:
1 | ----- start fun1!----- |
【注意】
(1)局部变量定义后,若未初始化,则值随机,并且直接编译可能会有警告提示。
(2)语句块中定义局部变量,不仅仅可以在 while 循环, for 循环中定义, C 语言也云心出现单独的 {} ,这也是一个作用域。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | block n: 40 |
- 全局变量
在所有函数外部定义的变量称为全局变量( Global Variable ),它的作用域默认是整个程序,也就是所有的源文件,包括 .c 和 .h 文件。
全局变量和局部变量可以同名,但是在局部范围内全局变量会被“屏蔽”,不再起作用。也就是,变量的使用遵循就近原则,如果在当前作用域中存在同名变量,就不会向更大的作用域中去寻找变量。
【注意】
(1)全局变量定义后若未初始化,则自动赋值空值。
(2) C 语言规定,只能从小的作用域向大的作用域中去寻找变量。
(3)在一个函数内部修改全局变量的值会影响其它函数,全局变量的值在函数内部被修改后并不会自动恢复,它会一直保留该值,直到下次被修改。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | global = 0, &global = 0x56016f5a5014 |
3.6.2 static 关键字
- 静态局部变量
前边也有提到静态局部变量,加上该关键字的局部变量拥有更长的生存周期,但是当函数结束的时候此变量存储空间虽然不会释放,原来的值也会保留,但是便无法再使用。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
若保存两条会报错的语句,执行完编译命令后,会有如下报错信息:
点击查看报错信息
1 | test.c: In function ‘main’: |
若注释掉会报错的两句,重新执行上边两条接着我们会在终端中看到以下输出信息:
1 | ----- First fun1!----- |
- 静态的全局变量
那在全局变量前边加上该关键字呢?这样就会把这个全局变量的作用域限制在当前文件中,这里先用一下后边会学到的多文件编程,具体的可以看下一节。
点击查看实例
1 |
|
1 |
|
在终端执行以下命令:
1 | gcc *.c -Wall # 编译程序 |
会在终端看到以下提示信息:
1 | /tmp/ccRSRi58.o:在函数‘main’中: |
3.7参数传递
主要有三种传递参数的方式,分别是值传递,地址传递和全局变量。
3.7.1值传递
值传递,其实就是进行复制传递。调用函数将实参传递给被调用函数,被调用函数将创建同类型的形参并用实参初始化。形参是新开辟的存储空间,因此,在函数中改变形参的值,不会影响到实参。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | x = 3, &x = 0x7fff151a19bc |
上述例子中实参与形参关系如下图所示:
可以发现,实际参数 x 和 y 的值并没有受到任何影响。
3.7.2地址传递
那我们想要在函数中修改传入参数的数据,就可以使用地址传递。
地址传递方式是按地址传递,实参为变量的地址,而形参为同类型的指针。被调用函数中对形参的操作,将直接改变实参的值(被调用函数对指针的目标操作,相当于对实参本身的操作)
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | x = 3, &x = 0x7fffee759b50 |
上述例子中实参与形参关系如下图所示:
可以发现,实际参数 x 和 y 的值也被交换了。
3.7.3全局变量传参
前边我们知道,全局变量一旦定义,会在程序的任何地方可见,但是,全局变量的值可能会在任何一个函数中被修改,一经修改,就会影响其他所有使用它的地方,而且使用全局变量传递参数的先后吮吸不同也可能会影响最终结果。所以一般不建议使用这种传参方式。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | ----- start fun1!----- |
4.函数与数组
很多时候,我们处理数据并不是单一的处理一个或者几个数据,这个时候我们就可以使用数组来传参,便于数据处理。
4.1形参为数组
实参为数组名(也可以说是数组的指针),形参为数组名(本质是一个指针变量)。
这种方式传入数组时,形参并没有赋值实参所有的元素,而是复制了实参的首地址,这也就意味着我们传入的参数实际上是一个地址。这也说明了我们在函数中对数组元素进行操作,原来数组中的元素会相应发生变化。
点击查看实例
【注意】形参是数组形式时,本质是同级别的指针。例如,
1 | void selectSort(int arr[], int n); |
- 形式参数是一个未定义大小的数组
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | a = 1 7 3 2 5 |
- 形式参数是一个已定义大小的数组
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | a = 1 7 3 2 5 |
这里为什么不通过 sizeof 计算数组大小呢,是因为它会报以下警告:
1 | warning: ‘sizeof’ on array function parameter ‘arr’ will return size of ‘int *’ [-Wsizeof-array-argument] |
函数的形参即便是一个已经定义了大小的数组,但是这个形参的数组名被认为是一个指针类型了。
4.2形参为指针
既然传入的参数是一个地址,那么当然可以用指针来接收地址了。一般形式如下:
1 | dataType functionName(dataType *arg1, int length) |
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | a = 1 7 3 2 5 |
4.3二维数组传参
二维数组也是类似的传参方式。
点击查看实例
- 形式参数是一个未定义大小的二维数组
第一维的大小可以不指定,第二维的大小必须指定。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | a[0][0] = 1 a[0][1] = 3 a[0][2] = 2 |
- 形式参数是一个行指针
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | a[0][0] = 1 a[0][1] = 3 a[0][2] = 2 |
5.指针函数
在 C 语言中允许一个函数的返回值是一个指针(即地址),这种返回指针值的函数称为指针函数。
5.1指针函数的定义
指针函数定义的一般形式如下:
1 | <数据类型> *<函数名称>( <形式参数> ) |
【注意】
(1)比起普通的自定义函数,在函数名称前多了一个 * 。
(2)返回值与一般自定义函数不同,指针函数的返回值必须是一个地址量。可以是全局变量的地址、 static 变量的地址、字符串常量的地址或者堆的地址。
不可以返回局部变量的地址,因为局部变量在函数运行完的时候,它所占用的内存被释放掉了,我们不能在主调函数中再访问该局部变量的地址,访问一段已经释放的内存,是一种非法操作。
【示例分析】
1 | int *pfunc(int, int); |
(1) * 运算符的优先级低于 () ,所以 pfunc 先与 () 结合,说明这是一个函数,也就可以写成这样
1 | int *( pfunc(int, int) ); |
(2) pfunc 是一个函数,括号里边是两个 int 类型的形参列表,这说明 pfunc 函数带有两个 int 类型的形参,使用的时候需要传入两个 int 类型的实参。
(3)再看前边的 * ,说明这个函数的返回值是一个指针变量。
(4) * 前边的 int 表明返回的指针变量是 int 类型的。
总的来说,就是定义了一个名为 pfunc 的函数,函数有两个 int 类型的形参,函数返回一个指向整型数据的指针变量。
5.2指针函数的应用
函数库中有字符串拼接函数,我们是不是可以考虑使用指针函数自己实现一个呢?
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息:
1 | str[50] = Hello world!#welcome, str2[] = here! |
5.3指针函数与函数指针
首先,还是上边的例子,一个指针函数:
1 | int *pfunc(int, int); |
对于这个例子,我们前边是这样分析的:
点击查看分析过程
(1) * 运算符的优先级低于 () ,所以 pfunc 先与 () 结合,说明这是一个函数,也就可以写成这样
1 | int *(pfunc(int, int)); |
(2) pfunc 是一个函数,括号里边是两个 int 类型的形参列表,这说明 pfunc 函数带有两个 int 类型的形参,使用的时候需要传入两个 int 类型的实参。
(3)再看前边的 * ,说明这个函数的返回值是一个指针变量。
(4) * 前边的 int 表明返回的指针变量是 int 类型的。
总的来说,就是定义了一个名为 pfunc 的函数,函数有两个 int 类型的形参,函数返回一个指向整型数据的指针变量。
前边学了函数指针,怎么变成一个函数指针?像下边这样既可:
1 | int (*pfunc)(int, int); |
点击查看分析过程
(1)这里还是先看 pfunc ,通过 () 强行与 * 相结合,这就意味着 pfunc 是一个指针变量。
(2)接着后边是 (int, int) ,这说明这个指针变量指向了一个函数,函数可以带两个 int 类型的参数。
(3)最前边的 int 表明,函数的返回值是 int 类型。
总的来说,就是定义了一个名为 pfunc 的指针变量,这个指针变量可以指向返回值为 int 类型,且带有两个 int 类型参数的函数。
【结论】根据上边的分析,两个语句,仅仅是一个 () 的区别,所代表的含义就完全不同了,由对两个语句的分析可知,函数指针本质上还是一个指针,它可以指向一个函数;而指针函数本质上是一个函数,它可以返回一个指针。
5.4函数指针作为返回值?
上边分析完函数指针和指针函数后,想到了点变态的东西,就是,函数指针既然是一个指针,而指针函数既然是一个函数,返回值为指针,那么它俩结合一下,简直不敢想象,太可怕了,但是后边还真遇到了这样的函数。既然后边会遇到,那就在这里总结一下,分析一下吗,彻底搞明白好啦。
自己写,目前似乎还写不出来,直接找个例子分析一下吧:
1 | int (*func(int))(int *, int); |
(1)先看名称,也就是 func ,由于 * 的优先级低于 () ,所以 func 与 () 结合,说明这是一个函数,所以也就可以写成这样了:
1 | int ( *(func(int) )(int *, int); |
(2) () 里边的 int 表示函数 func 带有一个 int 类型的形参。
(3)接着就是 * ,这表示这个函数的返回值是一个指针变量,然后就该最后的一个括号了,也就是
1 | int ( *(func(int) )( int *, int ); |
(4)我们已经分析出来第一个 () 中是一个指针变量,那我们用 *p 暂时代替一下:
1 | int (*p)( int *, int ); |
这样是不是就清楚多了呢?前边的 *p 是一个指针变量,后边的 () 表示这是一个函数,也就是说 p 指向了一个函数。
(5)最后的 () 里边表示这个函数有两个参数,一个是 int 类型指针,传输参数的时候需要传一个指针变量,另一个是 int 类型变量。结合 (4) 就是有一个指针 p 指向一个带有两个参数的函数。
(6)最前边的 int 表示 p 指向的函数的返回值为 int 类型。
总的来说就是,定义了一个指针函数,函数名为 func ,函数带有一个 int 类型的参数,这个函数的返回值是一个指针变量,函数返回的指针变量又是一个函数指针,它可以指向一个带有两个参数的且返回值为 int 类型的函数。这就是前边说的,定义了一个指针函数,指针函数返回一个可以指向函数指针。
对于我来说,简直是难以理解,甚至可以说是,几乎理解不了,那有什么办法简化嘛?当然有啦,哈哈哈。还记得 typedef 吧,我看我的笔记似乎是记在用户自定义类型那了,但是问题不大,我们接下来就看一看这个关键字是如何简化上边的,额。。。就这样吧。
上边我们分析到第 (4) 步的时候,我们用了一个指针 p 代替了 func(int) ,所以就成了下边的这个样子:
1 | int (*p)(int *, int); |
其实 p 就是一个函数指针变量,可以指向一个函数带有两个参数的返回值为 int 类型的函数,我们使用 typedef 声明一下,将他变为一个类型:
1 | typedef int (*p)(int *, int); |
这样就相当于,我们定义了一个函数指针 p 的类型,它可以直接取代 int (xxx)(int *, int) 类型的函数指针,于是,上边的指针函数返回的函数指针就可以用新定义的类型 p 来定义了:
1 | p func(int); |
p 就代表返回值的类型,就等价于 int (xxx)(int *, int) 。我是这样的理解的,也有可能理解的不到位,后边有什么问题的话会再修正。
6.递归函数
一个函数在它的函数体内调用它自身称为递归调用,这种函数称为递归函数。
执行递归函数的时候这个函数将反复调用其自身,每调用一次就进入新的一层,当最内层的函数执行完毕后,再一层一层地由里到外退出。
递归函数调用的执行过程分为两个阶段。
- 递推阶段:从原问题出发,按递归公式递推从未知到已知,最终达到递归终止条件。
- 回归阶段:按递归终止条件求出结果,逆向逐步代入递归公式,回归到原问题求解。
6.1常见的两个问题
6.1.1阶乘问题
在数学中,经常会看到阶乘:
那么我们用 C 语言怎么实现呢?
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
接着我们会在终端中看到以下输出信息(根据提示,手动输入一个数字):
1 | Input a number: 5 |
接下来,我们可以分析一下, 5! 是怎么被求出来的。
点击查看分析过程
- 递推阶段
层数 | 实参/形参 | 调用形式 | 需要计算的表达式 | 需要等待的结果 |
1 | n=5 | factorial(5) | factorial(4) * 5 | factorial(4) 的结果 |
2 | n=4 | factorial(4) | factorial(3) * 4 | factorial(3) 的结果 |
3 | n=3 | factorial(3) | factorial(2) * 3 | factorial(2) 的结果 |
4 | n=2 | factorial(2) | factorial(1) * 2 | factorial(1) 的结果 |
5 | n=1 | factorial(1) | 1 | 无 |
- 回归阶段
层数 | 调用形式 | 需要计算的表达式 | 内层函数的返回值 | 表达式的值 (当次调用的结果) |
5 | factorial(1) | 1 | 无 | 1 |
4 | factorial(2) | factorial(1) * 2 | factorial(1) 的返回值,也就是 1 | 2 |
3 | factorial(3) | factorial(2) * 3 | factorial(2) 的返回值,也就是 2 | 6 |
2 | factorial(4) | factorial(3) * 4 | factorial(3) 的返回值,也就是 6 | 24 |
1 | factorial(5) | factorial(4) * 5 | factorial(4) 的返回值,也就是 24 | 120 |
6.1.2斐波那契数列
斐波那契数列是一个双层递归问题,它在一层中会调用自身两次。
早研究这个数列的是斐波那契( Leonardo Fibonacci ),他当时是为了描述如下情况的兔子生长数目:
- 第一个月初有一对刚诞生的兔子
- 第二个月之后(第三个月初)它们可以生育
- 每月每对可生育的兔子会诞生下一对新兔子
- 兔子永不死去
第一个月小兔子没有繁殖能力,所以还是一对。两个月后,生下一对小兔对数共有两对。三个月以后,老兔子又生下一对,因为小兔子还没有繁殖能力,所以一共是三对。
于是按照这样的规律,得到一串的数字:斐波那契数列: 1 , 1 , 2 , 3 , 5 , 8 , 13 , 21 , 34 , 55 , 89 , 144 , 233 , …… ,也就是说第一项和第二项是 1 ,之后的每一项为之前两项的和。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
按照提示输入一个数字, 接着我们会在终端中看到以下输出信息:
1 | Input a number: 6 |
6.2递归的条件
每一个递归函数都应该只进行有限次的递归调用,如果无限制递归,那么最终将会使栈空间溢出。
要想让递归函数逐层进入再逐层退出,需要解决两个方面的问题:
- 存在限制条件,当符合这个条件时递归便不再继续。例如,上边的 factorial() ,当形参 n 等于 0 或 1 时,递归就结束。
- 每次递归调用之后越来越接近这个限制条件。例如,上边的 factorial() ,每次递归调用的实参为 n - 1 ,这会使得形参 n 的值逐渐减小,越来越趋近于 1 或 0 。
6.3递归函数缺点
6.3.1内存占用
在程序占用的整个内存中,有一块内存区域叫做栈( Stack ),它是专门用来给函数分配内存的,每次调用函数,都会将相关数据压入栈中,包括局部变量、局部数组、形参、寄存器、冗余数据等。
栈是针对线程来说的,每个线程都拥有一个栈。对每个线程来说,栈能使用的内存是有限的,一般是 1M~8M ,这在编译时就已经决定了,程序运行期间不能再改变。如果程序使用的栈内存超出最大值,就会发生栈溢出( Stack Overflow )错误。
点击查看常见编译器栈内存指定值
1 | VC/VS 下,默认是 1M |
【注意】这只是默认的栈内存大小,我们也可以通过参数来修改栈内存的大小。
发生函数调用时会将相关数据压入栈中,函数调用结束会释放这一部分内存。
对于递归函数,它的内部嵌套了对自身的调用,除非等到最内层的函数调用结束,否则外层的所有函数都不会调用结束。也就是说,外层函数暂停,一直处于等待状态,它要等待所有的内层函数调用完成后,它自己才能调用完成,完成后才能释放相应的内存。每一层的递归调用都会在栈上分配一块内存,有多少层递归调用就分配多少块相似的内存,所有内存加起来的总和是相当恐怖的,很容易超过栈内存的大小限制,这个时候就会导致程序崩溃。
6.3.2时间成本
每次调用函数都会在栈上分配内存,函数调用结束后再释放这一部分内存,内存的分配和释放都是需要时间的。
每次调用函数还会多次修改寄存器的值,函数调用结束后还需要找到上层函数的位置再继续执行,这也是需要时间的。
斐波那契数列计算的时候递归 n 次,时间复杂度 O(2^n) ,可以很好的展示时间成本。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
按照提示输入一个数字, 接着我们会在终端中看到以下输出信息:
1 | Input a number: 50 |
42 秒的时间,这个时间成本有些过高了。
6.4用迭代代替?
递归函数的内存成本和时间成本是函数实现原理层面的缺陷,无法优化。但是呢,很多能用递归解决的问题也能用迭代来解决。迭代,其实就是就是循环。与递归函数相比,迭代不但没有额外的内存成本,也没有额外的时间成本。
点击查看实例
1 |
|
然后在终端运行以下命令:
1 | gcc test.c -Wall # 编译程序 |
按照提示输入一个数字, 接着我们会在终端中看到以下输出信息:
1 | Please input a number: 50 |
这个时间差距是显而易见的。