Day 6

函数之值传递

形参与实参

在深入讨论"值传递"之前,我们首先需要理解"函数形参和实参"的区别。主要的差异如下:

  1. 定义与调用的区别
    1. 形参:在函数定义时,列在函数名后面的括号中的变量称为形参。形参仅仅是一种占位符,表示在调用该函数时应传递的数据的类型和顺序。
    2. 实参:当我们调用一个函数时,我们传递给函数的实际值或变量称为实参。
  2. 存储
    1. 形参:它是局部变量的特殊形式,存储在栈区。与一般函数体内的局部变量一样,形参在函数调用时进行初始化。(一般局部变量在函数调用过程中,执行语句时初始化)
    2. 实参:实参可以是常量、变量或表达式,具体而言又可以是局部变量、全局变量等,存储位置不定。
  3. 生命周期
    1. 形参:它的生命周期仅限于函数调用期间。函数调用结束后,形参作为局部变量就会被销毁。
    2. 实参:其生命周期取决于其类型。如果实参是局部变量,它的生命周期与所在的作用域"{}"有关;若为全局变量,则从程序启动到结束等等。

综上,形参和实参在多个方面都存在显著的区别,理解它们是掌握函数传递机制的关键。

值传递原理

当一个函数被调用时,实参的值会被复制一份传递给形参,形参得到的只是实参的一个拷贝(副本)。也就是说:

通过函数调用将形参传递给另一个函数时,另一个函数的调用栈帧中会将取值复制一份创建一个新的、独立的局部变量,在函数当中任何"看起来对实参的修改",实际上都是对拷贝副本的修改,对原本实参是不会产生任何影响的,这就是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语言中,任何通过函数直接修改基本数据类型(非指针类型)实参的方式都是不可能成功的,因为函数得到的是实参的拷贝。

关于指针的补充

在上述总结的时候,我们提到了指针,关于指针和值传递,我们需要注意以下几点:

  1. 在C语言中,如果想通过函数来修改实参变量的值,是完全可以实现的。但此时函数调用,要传递实参变量的指针,而不是实参变量本身。
  2. 无论在任何情况下,C语言都只有值传递,没有诸如"指针传递"、"引用传递"等传值方式。你只需要记住:在任何情况下,通过函数调用将实参传递,函数当中得到都只是实参的拷贝。这不会因实参变量的数据类型,种类等发生改变!!
  3. 在C语言中,允许数组作为函数的实参,但数组作为实参传递时比较特殊。这个特殊的行为我们将在学习指针的章节中详细探讨。

值传递的优缺点

C语言仅有值传递的这种设计具有以下优点:

  1. 安全。由于传递的是实参的副本,所以原始数据不会被修改。这意味着函数对参数的操作不会影响到外部的变量,避免了非预期的副作用,保护了原始数据。
  2. 简单直观易懂统一。相比较于C++多种传值方式并存的设计理念,C语言传值方式单一,这体现了C语言简洁统一的设计语言。

缺点也有:

  1. 不够灵活,功能弱小。
  2. 一些大型数据作为实参时,如果仍然传递拷贝,既占用大量空间,效率也很差。

当然这两个缺点完全可以用指针来弥补,后续给大家讲指针时,还会重提值传递,我们到时候再谈。

函数递归

递归的基本使用

当一个函数直接或间接地调用自己,我们称这种现象为“递归”。简而言之,递归是函数自调用的行为。

除此之外,我们还可"抠字眼"来理解递归,将递归分为"递"和"归"两部分:

递:“递”意味着“递推”,即将一个较大规模的问题逐步分解成较小的、更容易处理的子问题。

归:“归"意味着"返回"或"回归”,当我们解决了这些子问题后,会从最底层开始,逐步合并或组合这些子问题的答案,直至得出最初问题的解答。

这种“递推-回归”的思维模式是递归的核心,也是其强大之处。

递归三要素

要想合理使用递归解决实际问题,需要注意递归三要素:

  1. 递归体(体现“递”过程):
    1. 函数内部递归调用自身的部分。
    2. 递归体是递归思维的核心:它表示如何将一个大规模问题“递推”为较小、相似的子问题。这一分解过程持续地缩小问题规模,以便更容易处理。
  2. 递归的出口(体现“归”过程):
    1. 当子问题已经足够小或满足某种条件时,我们不再继续分解,而是开始返回答案。这些条件或情况就是递归的出口。
    2. 明确的递归出口是至关重要的。没有明确的出口,递归可能无限进行,直到耗尽资源并导致栈溢出。通过递归的出口,我们实现了从“递”到“归”的转换,开始逐步合并或组合子问题的答案。
  3. 递归的深度:
    1. 每次递归调用都会加深调用的层次,这可以被看作是递归“递”的深度。
    2. 控制递归深度是至关重要的,因为一个过深的递归不仅会增加计算复杂性,还可能导致栈溢出。合理的深度能确保我们在“递”的过程中不会过分深入,同时在“归”的过程中能够有效地返回和组合答案。

为了方便大家理解递归的三要素,我们可以举一个非常简单的例子:递归求解前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;
}
  1. 递归体:"return n + sum(n-1); "

    这里的递归体体现了将大问题(如求1到n的和)分解为小问题(求1到n-1的和)的思想。对小问题的求解建立在更小问题的基础上,形成一个递归链条。

  2. 递归出口:"if (n == 1) { return 1; } "

    当n值为1时,函数返回1而不再递归调用自身。这是递归的终止条件,确保了递归有一个明确的结束点。

  3. 递归深度: 对于这个函数,递归深度与输入的num值有关。

    例如,当我们调用sum(5),递归深度为5,因为从sum(5)开始,会连续调用sum(4), sum(3), sum(2), 和sum(1)。每一次调用都会在栈上增加一个新的函数调用帧。

    如果不断增大n的值,递归深度也会增加,这可能导致效率下降,甚至在极端情况下出现栈溢出。

递归的主要风险与应对策略

递归的使用是有很多限制的,但最需要注意的是栈溢出风险,因为一旦产生栈溢出,就会导致程序崩溃。

为了规避这一风险,我们需要特别关注以下两个核心要点:

  1. 明确的递归出口
    1. 虽然递归体负责实现主要的任务分解,但每个递归函数都必须有一个清晰的终止条件。
    2. 若没有设置终止条件,函数会无限地调用自己,持续地消耗栈空间,最终导致栈溢出(StackOverflow)
  2. 递归深度限制
    1. 有明确的递归出口的递归并不意味着完全无风险。
    2. 如果递归深度过大,超出可用的栈空间,即使存在递归出口也仍然会发生栈溢出。

总结

递归的优点:

  1. 代码简洁: 使用递归,我们可以用几行代码来表示复杂的问题,使代码更加简洁优雅。
  2. **直观易理解:**递归的核心思路是分解,将复杂的问题分解为更小的、相似的子问题,这天然符合人类解决问题的思路。
  3. **天然适用于有重复或层次结构的问题:**对于某些具有明确重复或层次结构的问题,如文件系统、树和图的相关算法,递归方法是最自然、最容易理解的选择。

递归的缺点:

  1. 栈溢出风险: 深度较深的递归可能导致栈溢出。
  2. 效率较低:递归可能导致大量的重复计算,降低效率。尤其是递归处理的子问题存在大量重叠时,这种效率低下更为明显。
  3. 内存消耗:由于需要维护函数调用栈,递归可能会导致更高的内存消耗。
  4. **难以调试:**由于递归函数在运行时会多次调用自身,可能在不同的递归层次上,这使得调试递归函数比非递归函数更困难。
  5. **难以思考:**虽然递归代码经常看起来很简洁,分解的核心思想也很容易理解。但到了具体问题,可能并不那么将一个问题思考用递归求解,尤其是对于初学者而言。

如何恰当地使用递归

递归是一个既强大又危险的工具,一把"伤人可能亦伤己"的利刃。于是,我们不禁思考一个问题:如何恰当地使用递归呢?

问题一:哪些场景下可以考虑使用递归?

如果一个问题很难从全局考虑,但能够找到"递推"分解的思路,那么可以考虑使用递归求解。常见的适合递归的场景包括:

  1. 问题的自然定义是递归的:某些问题的自然结构是分层或分级的,它们可以递归地定义。例如,斐波那契数列、阶乘、文件系统遍历等。
  2. 问题可以分解为相似的子问题:如果一个问题可以被分解为几个规模较小但结构相似的子问题,那么递归可能是一个合适的选择。例如,快速排序算法、汉诺塔问题等。
  3. 数据结构是递归的:处理像树或图这样的递归数据结构时,递归是很自然的方法。例如,二叉树的遍历、图的深度优先搜索等。

问题二:此问题合适使用递归求解吗?

递归是有明显缺点的,一个问题能够用递归求解,并不意味着就适合用递归求解。适不适合用递归,主要考虑以下两点:

  1. **是否存在大量重复计算:**当递归解法中存在大量的子问题重复计算,这种递归方法通常效率较低。例如,递归求解斐波那契数列。此时就不应该用递归,尤其是递归深度较深时。
  2. **递归的深度是不是过深:**深度过深的递归可能导致栈溢出,而且还会带来空间损耗和效率上的问题。

问题三:确定要使用递归了,那么如何写递归呢?

按照以下步骤完成:

  1. 确定递归函数的参数和返回值。
  2. 先找到递归的出口。
  3. 再确定递归体。
  4. 测试并调试。

汉诺塔问题

  • 20
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
C:\Users\admin\Desktop\前端开发\Node.js\day6\code\api_server\node_modules\mysql\lib\protocol\Parser.js:437 throw err; // Rethrow non-MySQL errors ^ Error: secretOrPrivateKey must have a value at module.exports [as sign] (C:\Users\admin\Desktop\前端开发\Node.js\day6\code\api_server\node_modules\jsonwebtoken\sign.js:107:20) at Query.<anonymous> (C:\Users\admin\Desktop\前端开发\Node.js\day6\code\api_server\router_handler\2user.js:49:26) at Query.<anonymous> (C:\Users\admin\Desktop\前端开发\Node.js\day6\code\api_server\node_modules\mysql\lib\Connection.js:526:10) at Query._callback (C:\Users\admin\Desktop\前端开发\Node.js\day6\code\api_server\node_modules\mysql\lib\Connection.js:488:16) at Sequence.end (C:\Users\admin\Desktop\前端开发\Node.js\day6\code\api_server\node_modules\mysql\lib\protocol\sequences\Sequence.js:83:24) at Query._handleFinalResultPacket (C:\Users\admin\Desktop\前端开发\Node.js\day6\code\api_server\node_modules\mysql\lib\protocol\sequences\Query.js:149:8) at Query.EofPacket (C:\Users\admin\Desktop\前端开发\Node.js\day6\code\api_server\node_modules\mysql\lib\protocol\sequences\Query.js:133:8) at Protocol._parsePacket (C:\Users\admin\Desktop\前端开发\Node.js\day6\code\api_server\node_modules\mysql\lib\protocol\Protocol.js:291:23) at Parser._parsePacket (C:\Users\admin\Desktop\前端开发\Node.js\day6\code\api_server\node_modules\mysql\lib\protocol\Parser.js:433:10) at Parser.write (C:\Users\admin\Desktop\前端开发\Node.js\day6\code\api_server\node_modules\mysql\lib\protocol\Parser.js:43:10) Node.js v18.12.1
06-08

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

如是我闻艺

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值