函数之值传递
形参与实参
在深入讨论"值传递"之前,我们首先需要理解"函数形参和实参"的区别。主要的差异如下:
- 定义与调用的区别:
- 形参:在函数定义时,列在函数名后面的括号中的变量称为形参。形参仅仅是一种占位符,表示在调用该函数时应传递的数据的类型和顺序。
- 实参:当我们调用一个函数时,我们传递给函数的实际值或变量称为实参。
- 存储:
- 形参:它是局部变量的特殊形式,存储在栈区。与一般函数体内的局部变量一样,形参在函数调用时进行初始化。(一般局部变量在函数调用过程中,执行语句时初始化)
- 实参:实参可以是常量、变量或表达式,具体而言又可以是局部变量、全局变量等,存储位置不定。
- 生命周期:
- 形参:它的生命周期仅限于函数调用期间。函数调用结束后,形参作为局部变量就会被销毁。
- 实参:其生命周期取决于其类型。如果实参是局部变量,它的生命周期与所在的作用域"{}"有关;若为全局变量,则从程序启动到结束等等。
综上,形参和实参在多个方面都存在显著的区别,理解它们是掌握函数传递机制的关键。
值传递原理
当一个函数被调用时,实参的值会被复制一份传递给形参,形参得到的只是实参的一个拷贝(副本)。也就是说:
通过函数调用将形参传递给另一个函数时,另一个函数的调用栈帧中会将取值复制一份创建一个新的、独立的局部变量,在函数当中任何"看起来对实参的修改",实际上都是对拷贝副本的修改,对原本实参是不会产生任何影响的,这就是C语言的值传递机制。
一个非常简单的值传递机制的演示代码如下:
void swap(int a, int b) {
int tmp = a;
a = b;
b = tmp;
printf("调用swap函数中,a = %d\n", a);
printf("调用swap函数中,b = %d\n", b);
printf("\n");
}
int main(void) {
int a = 10;
int b = 20;
printf("调用swap函数前,a = %d\n", a);
printf("调用swap函数前,b = %d\n", b);
printf("\n");
swap(a, b);
printf("调用swap函数后,a = %d\n", a);
printf("调用swap函数后,b = %d\n", b);
return 0;
}
这段代码很好的展示了C语言的值传递机制,我们还可以用一张图来描述这这个机制:
注意:
不要C语言的值传递机制,理解成局部变量的机制,值传递意味着任何实参通过函数传递,函数接收到的都只是实参的拷贝,哪怕实参本身是一个全局变量这种看起来完全可以跨函数修改的。
值传递的本质是实参传递的机制,和实参变量本身是什么类型变量没有关系!!!
下面我们看一下很好的例子:
// 定义全局变量
int g_var = 10;
int g_var2 = 20;
void swap_global(int g_var, int g_var2) {
int tmp = g_var;
g_var = g_var2;
g_var2 = tmp;
printf("调用swap函数中,g_var = %d\n", g_var);
printf("调用swap函数中,g_var2 = %d\n", g_var2);
printf("\n");
}
int main(void) {
printf("调用swap函数前,g_var = %d\n", g_var);
printf("调用swap函数前,g_var2 = %d\n", g_var2);
printf("\n");
swap_global(g_var, g_var2);
printf("调用swap函数后,g_var = %d\n", g_var);
printf("调用swap函数后,g_var2 = %d\n", g_var2);
return 0;
}
swap函数用于交换两个全局变量,但需要用函数调用将全局变量作为实参传递,修改能成功吗?
当然是失败的,因为swap函数只得到了两个全局变量的拷贝,在函数体内部修改成功了,但修改成功的是拷贝,对原本实参全局变量没有任何影响。
我们也可以画一个图描述这个过程:
当然全局变量完全可以跨函数修改,只要不使用实参传递的方式即可。比如:
// 此函数调用就会修改
void modify_g_var(){
g_var = 100;
g_var2 = 200;
}
这种修改方式就完全没问题。
总之:
在C语言中,任何通过函数直接修改基本数据类型(非指针类型)实参的方式都是不可能成功的,因为函数得到的是实参的拷贝。
关于指针的补充
在上述总结的时候,我们提到了指针,关于指针和值传递,我们需要注意以下几点:
- 在C语言中,如果想通过函数来修改实参变量的值,是完全可以实现的。但此时函数调用,要传递实参变量的指针,而不是实参变量本身。
- 无论在任何情况下,C语言都只有值传递,没有诸如"指针传递"、"引用传递"等传值方式。你只需要记住:在任何情况下,通过函数调用将实参传递,函数当中得到都只是实参的拷贝。这不会因实参变量的数据类型,种类等发生改变!!
- 在C语言中,允许数组作为函数的实参,但数组作为实参传递时比较特殊。这个特殊的行为我们将在学习指针的章节中详细探讨。
值传递的优缺点
C语言仅有值传递的这种设计具有以下优点:
- 安全。由于传递的是实参的副本,所以原始数据不会被修改。这意味着函数对参数的操作不会影响到外部的变量,避免了非预期的副作用,保护了原始数据。
- 简单直观易懂统一。相比较于C++多种传值方式并存的设计理念,C语言传值方式单一,这体现了C语言简洁统一的设计语言。
缺点也有:
- 不够灵活,功能弱小。
- 一些大型数据作为实参时,如果仍然传递拷贝,既占用大量空间,效率也很差。
当然这两个缺点完全可以用指针来弥补,后续给大家讲指针时,还会重提值传递,我们到时候再谈。
函数递归
递归的基本使用
当一个函数直接或间接地调用自己,我们称这种现象为“递归”。简而言之,递归是函数自调用的行为。
除此之外,我们还可"抠字眼"来理解递归,将递归分为"递"和"归"两部分:
递:“递”意味着“递推”,即将一个较大规模的问题逐步分解成较小的、更容易处理的子问题。
归:“归"意味着"返回"或"回归”,当我们解决了这些子问题后,会从最底层开始,逐步合并或组合这些子问题的答案,直至得出最初问题的解答。
这种“递推-回归”的思维模式是递归的核心,也是其强大之处。
递归三要素
要想合理使用递归解决实际问题,需要注意递归三要素:
- 递归体(体现“递”过程):
- 函数内部递归调用自身的部分。
- 递归体是递归思维的核心:它表示如何将一个大规模问题“递推”为较小、相似的子问题。这一分解过程持续地缩小问题规模,以便更容易处理。
- 递归的出口(体现“归”过程):
- 当子问题已经足够小或满足某种条件时,我们不再继续分解,而是开始返回答案。这些条件或情况就是递归的出口。
- 明确的递归出口是至关重要的。没有明确的出口,递归可能无限进行,直到耗尽资源并导致栈溢出。通过递归的出口,我们实现了从“递”到“归”的转换,开始逐步合并或组合子问题的答案。
- 递归的深度:
- 每次递归调用都会加深调用的层次,这可以被看作是递归“递”的深度。
- 控制递归深度是至关重要的,因为一个过深的递归不仅会增加计算复杂性,还可能导致栈溢出。合理的深度能确保我们在“递”的过程中不会过分深入,同时在“归”的过程中能够有效地返回和组合答案。
为了方便大家理解递归的三要素,我们可以举一个非常简单的例子:递归求解前n个自然数的累加。
代码示例如下:
#include <stdio.h>
int sum(int n) {
// 递归的出口
if (n == 1) {
return 1;
}
// 递归体
return n + sum(n - 1);
}
int main() {
int num = 5;
int result = sum(num);
printf("前%d个数的数是: %d\n", num, result);
return 0;
}
-
递归体:"return n + sum(n-1); "
这里的递归体体现了将大问题(如求1到n的和)分解为小问题(求1到n-1的和)的思想。对小问题的求解建立在更小问题的基础上,形成一个递归链条。
-
递归出口:"if (n == 1) { return 1; } "
当n值为1时,函数返回1而不再递归调用自身。这是递归的终止条件,确保了递归有一个明确的结束点。
-
递归深度: 对于这个函数,递归深度与输入的num值有关。
例如,当我们调用sum(5),递归深度为5,因为从sum(5)开始,会连续调用sum(4), sum(3), sum(2), 和sum(1)。每一次调用都会在栈上增加一个新的函数调用帧。
如果不断增大n的值,递归深度也会增加,这可能导致效率下降,甚至在极端情况下出现栈溢出。
递归的主要风险与应对策略
递归的使用是有很多限制的,但最需要注意的是栈溢出风险,因为一旦产生栈溢出,就会导致程序崩溃。
为了规避这一风险,我们需要特别关注以下两个核心要点:
- 明确的递归出口:
- 虽然递归体负责实现主要的任务分解,但每个递归函数都必须有一个清晰的终止条件。
- 若没有设置终止条件,函数会无限地调用自己,持续地消耗栈空间,最终导致栈溢出(StackOverflow)
- 递归深度限制:
- 有明确的递归出口的递归并不意味着完全无风险。
- 如果递归深度过大,超出可用的栈空间,即使存在递归出口也仍然会发生栈溢出。
总结
递归的优点:
- 代码简洁: 使用递归,我们可以用几行代码来表示复杂的问题,使代码更加简洁优雅。
- **直观易理解:**递归的核心思路是分解,将复杂的问题分解为更小的、相似的子问题,这天然符合人类解决问题的思路。
- **天然适用于有重复或层次结构的问题:**对于某些具有明确重复或层次结构的问题,如文件系统、树和图的相关算法,递归方法是最自然、最容易理解的选择。
递归的缺点:
- 栈溢出风险: 深度较深的递归可能导致栈溢出。
- 效率较低:递归可能导致大量的重复计算,降低效率。尤其是递归处理的子问题存在大量重叠时,这种效率低下更为明显。
- 内存消耗:由于需要维护函数调用栈,递归可能会导致更高的内存消耗。
- **难以调试:**由于递归函数在运行时会多次调用自身,可能在不同的递归层次上,这使得调试递归函数比非递归函数更困难。
- **难以思考:**虽然递归代码经常看起来很简洁,分解的核心思想也很容易理解。但到了具体问题,可能并不那么将一个问题思考用递归求解,尤其是对于初学者而言。
如何恰当地使用递归
递归是一个既强大又危险的工具,一把"伤人可能亦伤己"的利刃。于是,我们不禁思考一个问题:如何恰当地使用递归呢?
问题一:哪些场景下可以考虑使用递归?
如果一个问题很难从全局考虑,但能够找到"递推"分解的思路,那么可以考虑使用递归求解。常见的适合递归的场景包括:
- 问题的自然定义是递归的:某些问题的自然结构是分层或分级的,它们可以递归地定义。例如,斐波那契数列、阶乘、文件系统遍历等。
- 问题可以分解为相似的子问题:如果一个问题可以被分解为几个规模较小但结构相似的子问题,那么递归可能是一个合适的选择。例如,快速排序算法、汉诺塔问题等。
- 数据结构是递归的:处理像树或图这样的递归数据结构时,递归是很自然的方法。例如,二叉树的遍历、图的深度优先搜索等。
问题二:此问题合适使用递归求解吗?
递归是有明显缺点的,一个问题能够用递归求解,并不意味着就适合用递归求解。适不适合用递归,主要考虑以下两点:
- **是否存在大量重复计算:**当递归解法中存在大量的子问题重复计算,这种递归方法通常效率较低。例如,递归求解斐波那契数列。此时就不应该用递归,尤其是递归深度较深时。
- **递归的深度是不是过深:**深度过深的递归可能导致栈溢出,而且还会带来空间损耗和效率上的问题。
问题三:确定要使用递归了,那么如何写递归呢?
按照以下步骤完成:
- 确定递归函数的参数和返回值。
- 先找到递归的出口。
- 再确定递归体。
- 测试并调试。