Skip to content

C++ 第七节课:函数进阶与内存管理


引言

同学们,经过前几节课的学习,我们已经掌握了 C++ 中函数的基本用法。今天,我们将继续深入探索函数的奥秘,学习一些更高级的函数使用技巧,并开始接触 C++ 中非常重要的内存管理概念。理解好今天的内容,对于编写更灵活、更强大的 C++ 程序至关重要。

一、回调函数 (Callback Functions)

**概念:**在编程中,回调函数就是将一个函数的指针像参数一样传递给另一个函数,这样接收方函数就可以在合适的时机“反过来调用”你传递给它的函数。

作用:

  • 解耦合 (Decoupling): 回调函数允许我们分离操作的执行者和操作的具体内容。调用者不需要知道被调用者具体做什么,只需要知道如何通知它完成。
  • 提升代码的灵活性和可扩展性: 通过传递不同的回调函数,我们可以让同一个函数执行不同的操作,而无需修改其内部代码。这就像你可以通过不同的“通知方式”(例如电话、短信)来接收外卖送达的消息。
  • 实现事件驱动编程: 在图形界面、异步编程等场景中,回调函数常用于处理事件的发生。

示例代码:

cpp
#include <iostream>
#include <vector>
#include <algorithm>

// 回调函数:打印消息
void printMessage(int num) {
  std::cout << "回调函数被调用,数字为: " << num << std::endl;
}

// 回调函数:打印数字的平方
void printSquare(int num) {
  std::cout << "回调函数被调用,数字的平方为: " << num * num << std::endl;
}

// 接收回调函数指针作为参数的函数
void callFunction(void (*callback)(int), int num) {
  std::cout << "callFunction 正在执行,准备调用回调函数..." << std::endl;
  callback(num); // 调用传入的回调函数
  std::cout << "callFunction 执行完毕。" << std::endl;
}

// 一个更实际的例子:使用回调函数进行自定义排序
bool compareAscending(int a, int b) {
  return a < b;
}

bool compareDescending(int a, int b) {
  return a > b;
}

void sortNumbers(std::vector<int>& nums, bool (*compare)(int, int)) {
  std::sort(nums.begin(), nums.end(), compare);
}

int main() {
  // 将 printMessage 函数的指针传递给 callFunction
  callFunction(printMessage, 10);
  std::cout << std::endl;
  // 将 printSquare 函数的指针传递给 callFunction
  callFunction(printSquare, 5);
  std::cout << std::endl;

  // 使用回调函数进行排序
  std::vector<int> numbers = {3, 1, 4, 1, 5, 9, 2, 6};
  std::cout << "排序前:";
  for (int num : numbers) {
    std::cout << num << " ";
  }
  std::cout << std::endl;

  sortNumbers(numbers, compareAscending);
  std::cout << "升序排序后:";
  for (int num : numbers) {
    std::cout << num << " ";
  }
  std::cout << std::endl;

  sortNumbers(numbers, compareDescending);
  std::cout << "降序排序后:";
  for (int num : numbers) {
    std::cout << num << " ";
  }
  std::cout << std::endl;

  return 0;
}

讲解要点:

  • 函数指针的声明方式: void (*callback)(int) 声明了一个指向返回值为 void,接受一个 int 类型参数的函数的指针。
  • 如何将函数指针作为参数传递: 直接将函数名作为参数传递即可,函数名在大多数情况下会被隐式转换为函数指针。
  • 回调函数的实际应用场景:
    • 事件处理: 例如,在图形用户界面 (GUI) 编程中,按钮点击事件的处理函数就是一个回调函数。
    • 排序算法定制: 如上面的 sortNumbers 示例,可以根据不同的比较规则进行排序。
    • 异步操作完成后的通知: 当一个耗时的操作(例如网络请求)完成后,通过回调函数通知程序。
    • 插件机制: 允许在不修改原有代码的情况下,通过注册回调函数来扩展程序的功能。

练习与思考:

  1. 尝试编写一个函数,它接收一个整数数组和一个回调函数,该回调函数用于判断数组中的元素是否满足特定条件。然后,该函数返回满足条件的元素的个数。
  2. 思考在日常生活中,还有哪些场景可以用“回调”的思想来理解?

二、函数的递归调用 (Recursive Function Calls)

概念: 想象一下你面前放着两面镜子互相反射,你会看到一个无限延伸的影像。递归调用就像这样,一个函数在执行的过程中,直接或间接地调用了自身。为了避免无限循环,递归必须有一个明确的结束条件。

关键:

  • 递归的终止条件 (Base Case): 这是递归函数不再调用自身的条件,是递归能够结束的根本保证。就像镜子迷宫的出口。
  • 递归的递推关系 (Recursive Step): 定义了如何将问题分解为更小的、与原问题结构相同的子问题。就像在镜子迷宫中找到通往下一个反射点的路。

示例代码:

cpp
#include <iostream>

// 计算阶乘
int factorial(int n) {
  std::cout << "计算 " << n << " 的阶乘" << std::endl;
  // 递归终止条件
  if (n == 0) {
    std::cout << "到达终止条件,返回 1" << std::endl;
    return 1;
  }
  // 递归调用
  int result = n * factorial(n - 1);
  std::cout << n << " 的阶乘计算完成,结果为 " << result << std::endl;
  return result;
}

// 使用递归计算斐波那契数列
int fibonacci(int n) {
  if (n <= 1) {
    return n;
  }
  return fibonacci(n - 1) + fibonacci(n - 2);
}

int main() {
  int num = 5;
  std::cout << num << " 的阶乘是: " << factorial(num) << std::endl;
  std::cout << std::endl;

  std::cout << "斐波那契数列第 10 项是: " << fibonacci(10) << std::endl;

  return 0;
}

讲解要点:

  • 递归的优缺点:
    • 优点: 某些问题用递归解决思路清晰、代码简洁,易于理解。
    • 缺点: 每次递归调用都需要保存现场信息(例如函数参数、局部变量等),占用栈空间,如果递归深度过大,可能导致栈溢出 (Stack Overflow)。同时,递归可能会存在重复计算的问题,例如在计算斐波那契数列时。
  • 递归与迭代的比较: 迭代通常使用循环结构实现,效率更高,占用内存更少,但某些问题的迭代实现可能比较复杂。大多数可以用递归解决的问题,也可以用迭代解决。
  • 避免无限递归: 务必确保递归函数有一个明确的终止条件,并且每次递归调用都逐步接近该终止条件。
  • 栈溢出的风险: 了解栈空间的限制,避免过深的递归调用。

练习与思考:

  1. 编写一个递归函数来计算一个整数数组的所有元素的和。
  2. 尝试用迭代的方式实现计算阶乘的函数。比较递归版本和迭代版本的代码,思考它们的优缺点。
  3. 查找资料,了解什么是“尾递归”,以及为什么尾递归可以被优化。

三、newdelete, new [], delete []

概念: 在 C++ 中,内存就像一块可以自由分配和回收的土地。当我们声明一个变量时,系统会自动在合适的地方(例如栈区)为它分配内存。但有时我们需要在程序运行的过程中动态地申请内存,这时就需要用到 new 运算符;当我们不再需要这块内存时,需要使用 delete 运算符将其归还给系统,防止“内存垃圾”堆积。

  • new 用于在堆区 (Heap) 动态地分配一块指定类型的内存空间,并返回指向该内存空间的指针。就像向操作系统申请一块土地的使用权。
  • delete 用于释放由 new 分配的单个对象的内存。就像归还向操作系统申请的单块土地的使用权。
  • new [] 用于在堆区动态地分配一块数组的内存空间,并返回指向数组首元素的指针。就像向操作系统申请一块用于建造多栋房屋的土地的使用权。
  • delete [] 用于释放由 new [] 分配的数组的内存。必须使用 delete [] 来释放数组内存,以确保所有数组元素的析构函数都被调用(如果元素是对象的话)。就像归还用于建造多栋房屋的土地的使用权,需要确保每栋房屋都被妥善处理。

示例代码:

cpp
#include <iostream>

int main() {
  // 使用 new 分配一个 int 类型的内存空间
  int* ptr = new int;
  *ptr = 10;
  std::cout << "ptr 指向的值: " << *ptr << std::endl;
  // 使用 delete 释放内存
  delete ptr;
  ptr = nullptr; // 释放后将指针置空,防止悬 dangling 指针

  std::cout << std::endl;

  // 使用 new [] 分配一个包含 5 个 int 类型的数组
  int* arr = new int[5];
  for (int i = 0; i < 5; ++i) {
    arr[i] = i * 2;
  }
  std::cout << "动态分配的数组元素:";
  for (int i = 0; i < 5; ++i) {
    std::cout << arr[i] << " ";
  }
  std::cout << std::endl;
  // 使用 delete [] 释放数组内存
  delete[] arr;
  arr = nullptr; // 释放后将指针置空

  return 0;
}

讲解要点:

  • 动态内存分配的必要性:
    • 灵活地管理内存: 在程序运行时根据需要动态地申请和释放内存,避免了静态分配内存可能造成的浪费或不足。
    • 创建动态数据结构: 例如链表、树等,这些数据结构的...在编译时无法确定大小,需要动态分配内存。
  • newdelete 的使用方法:
    • 类型* 指针变量 = new 类型; 分配单个对象的内存。
    • 类型* 指针变量 = new 类型[大小]; 分配数组的内存。
    • delete 指针变量; 释放单个对象的内存。
    • delete[] 指针变量; 释放数组的内存。
  • 内存泄漏的概念和危害: 如果使用 new 分配了内存,但在不再使用时忘记使用 deletedelete[] 释放,就会导致内存泄漏 (Memory Leak)。 随着程序的运行,泄漏的内存会越来越多,最终可能导致程序运行缓慢甚至崩溃。 想象一下你借了别人的土地却一直不归还,最终可能会引发问题。
  • new 失败的情况: 当系统没有足够的内存来满足 new 的请求时,会抛出一个 std::bad_alloc 异常。 良好的编程习惯是使用 try-catch 块来捕获这个异常并进行处理。
  • 悬 dangling 指针:deletedelete[] 释放了指针所指向的内存后,该指针就变成了悬 dangling 指针,它指向的内存已经无效。 访问悬 dangling 指针会导致未定义的行为,程序可能会崩溃或产生不可预测的结果。 因此,在释放内存后,通常会将指针设置为 nullptr

练习与思考:

  1. 编写一个程序,使用 new 动态分配一个字符串,然后将字符串的内容复制到新分配的内存中。最后释放内存。
  2. 思考在哪些情况下使用动态内存分配是必要的,哪些情况下可以使用栈上的自动内存分配?
  3. 查找资料,了解智能指针 (Smart Pointers) 的概念,它们如何帮助管理动态分配的内存,防止内存泄漏?

四、内存中的栈区和堆区 (Stack and Heap)

概念: 计算机的内存可以想象成一个巨大的仓库,为了更好地管理这些空间,操作系统将其划分成了不同的区域。其中,栈区和堆区是 C++ 程序员需要重点理解的两个区域。

  • 栈区 (Stack): 就像一个后进先出的箱子。当你调用一个函数时,该函数的局部变量、参数等信息会被放入栈中(入栈),函数执行完毕后,这些信息会被自动移除(出栈)。 栈区的内存由编译器自动管理,效率很高,但大小有限。
  • 堆区 (Heap): 就像一个巨大的、可以自由分配和回收的空地。程序员可以使用 newdelete 手动地在这片区域申请和释放内存。 堆区的大小相对较大,但需要程序员自己管理,如果管理不当容易出现内存泄漏等问题。

示例代码:

cpp
#include <iostream>

int* createOnHeap() {
  int* num = new int(100); // 在堆区分配内存
  return num;
}

void functionOnStack() {
  int localVar = 50; // 在栈区分配内存
  std::cout << "栈区变量 localVar 的地址: " << &localVar << std::endl;
}

int main() {
  // 栈区变量
  int a = 10;
  std::cout << "栈区变量 a 的地址: " << &a << std::endl;

  // 堆区变量
  int* b = new int;
  *b = 20;
  std::cout << "堆区变量 b 的地址: " << b << std::endl;

  functionOnStack();

  int* heapVar = createOnHeap();
  std::cout << "堆区变量 heapVar 的地址: " << heapVar << std::endl;
  std::cout << "堆区变量 heapVar 指向的值: " << *heapVar << std::endl;
  delete heapVar;
  heapVar = nullptr;

  return 0;
}

讲解要点:

  • 栈区和堆区的区别:
特征栈区 (Stack)堆区 (Heap)
分配和释放编译器自动分配和释放程序员手动分配和释放 (new/delete)
存储内容局部变量、函数参数、函数调用信息动态分配的内存
大小限制有限,由编译器或操作系统预先设定相对较大,受系统可用内存限制
分配速度相对较慢
管理方式自动管理手动管理
  • 栈区和堆区的内存分配方式:
    • 栈区: 内存分配和释放就像叠盘子一样,后进先出。
    • 堆区: 内存分配和释放更加灵活,可以在任意时刻申请和释放任意大小的内存块。操作系统维护着一个空闲内存列表,当 new 请求内存时,系统会找到合适的空闲块分配出去。
  • 栈溢出和堆溢出:
    • 栈溢出 (Stack Overflow): 当函数调用层级过深(例如过多的递归调用)或者局部变量占用过多内存时,可能导致栈空间不足,发生栈溢出。
    • 堆溢出 (Heap Overflow): 通常指缓冲区溢出 (Buffer Overflow),当向一块堆内存中写入的数据超过了其分配的大小,就可能覆盖相邻的内存区域,导致程序出错甚至安全漏洞。 与本节课讨论的内存管理有所区别,这里指的是非法的内存写入。

练习与思考:

  1. 编写一个会导致栈溢出的程序。
  2. 思考在函数中返回指向局部变量的指针是否安全?为什么?
  3. 了解 C++ 中内存泄漏检测工具 (例如 Valgrind) 的基本使用方法。

五、全局变量、局部变量、static 静态变量

概念: 变量根据其声明的位置和 static 关键字的使用,拥有不同的作用域和生命周期。

  • 全局变量 (Global Variable): 在所有函数外部定义的变量。它们在程序开始执行时被创建,在程序结束时被销毁,具有全局作用域,可以被程序中的任何函数访问。
  • 局部变量 (Local Variable): 在函数内部或复合语句(例如 if 语句、循环语句)内部定义的变量。它们在定义时被创建,在所在的代码块执行完毕后被销毁,具有局部作用域,只能在其定义的代码块内部被访问。
  • static 静态变量 (Static Variable):
    • 局部静态变量: 在函数内部用 static 关键字声明的变量。它只在第一次执行到其定义语句时被初始化一次,之后的函数调用不会再次初始化。 它的生命周期贯穿整个程序执行期间,但作用域仍然是局部的,只能在定义它的函数内部访问。
    • 全局静态变量: 在所有函数外部用 static 关键字声明的变量。它的作用域被限制在声明它的源文件中,其他源文件无法访问。这可以避免命名冲突。

示例代码:

cpp
#include <iostream>

// 全局变量
int globalVar = 10;

void func() {
  // 局部变量
  int localVar = 20;
  // static 静态局部变量
  static int staticVar = 30;

  std::cout << "函数 func 被调用" << std::endl;
  std::cout << "全局变量: " << globalVar << ",地址: " << &globalVar << std::endl;
  std::cout << "局部变量: " << localVar << ",地址: " << &localVar << std::endl;
  std::cout << "静态变量: " << staticVar << ",地址: " << &staticVar << std::endl;

  localVar++;
  staticVar++;
}

int main() {
  std::cout << "main 函数中的全局变量: " << globalVar << std::endl;

  func();
  func();

  return 0;
}

讲解要点:

  • 三种变量的作用域和生命周期:
变量类型作用域生命周期初始化时机
全局变量整个程序程序开始到程序结束程序启动时
局部变量定义它的代码块定义时创建,代码块结束时销毁每次进入代码块时
静态局部变量定义它的函数程序开始到程序结束第一次执行到定义语句时
静态全局变量声明它的源文件程序开始到程序结束程序启动时
  • static 关键字的作用:
    • 修饰局部变量: 延长局部变量的生命周期,使其在函数多次调用之间保持状态。
    • 修饰全局变量: 限制全局变量的作用域,使其只能在当前源文件中访问。
    • 修饰类的成员变量: 使类的所有对象共享同一个变量,属于类而不是类的某个特定对象。
    • 修饰类的成员函数: 使成员函数不依赖于类的具体对象而存在,可以通过类名直接调用。
  • 全局变量的使用注意事项: 虽然全局变量方便访问,但过度使用全局变量会降低代码的模块化程度,增加命名冲突的风险,并可能导致程序难以维护和调试。 应该谨慎使用全局变量,尽可能使用局部变量或将数据封装在类中。

练习与思考:

  1. 编写一个程序,演示静态局部变量在函数多次调用之间保持状态的特性。
  2. 思考为什么全局静态变量可以避免命名冲突?
  3. 查阅资料,了解 C++ 中 extern 关键字的作用,以及它与全局变量的关系。

六、函数的指针传参和引用传参

概念: 当我们调用一个函数时,需要将一些数据传递给函数进行处理。C++ 提供了三种常用的参数传递方式:值传递、指针传递和引用传递。

  • 值传递 (Pass by Value): 在调用函数时,将实参的值复制一份传递给形参。函数内部对形参的修改不会影响到实参的值。就像你复印了一份文件给别人,别人在复印件上修改不会影响你的原件。
  • 指针传递 (Pass by Pointer): 在调用函数时,将实参的内存地址传递给形参。形参是一个指针变量,指向实参的内存地址。函数内部可以通过解引用指针来修改实参的值。 就像你把你的房子的钥匙给了别人,别人可以通过钥匙进入你的房子并进行修改。
  • 引用传递 (Pass by Reference): 在调用函数时,将实参的别名传递给形参。形参是实参的一个别名,它们指向同一块内存空间。函数内部对形参的修改会直接影响到实参的值。 就像你给你的房子起了个外号,别人叫外号和叫正式名字都是指的同一栋房子,对房子的任何操作都会反映在两个名字上。

示例代码:

cpp
#include <iostream>

// 值传递
void swapByValue(int a, int b) {
  std::cout << "值传递函数内部:交换前 a = " << a << ", b = " << b << std::endl;
  int temp = a;
  a = b;
  b = temp;
  std::cout << "值传递函数内部:交换后 a = " << a << ", b = " << b << std::endl;
}

// 指针传递
void swapByPointer(int* a, int* b) {
  std::cout << "指针传递函数内部:交换前 *a = " << *a << ", *b = " << *b << std::endl;
  int temp = *a;
  *a = *b;
  *b = temp;
  std::cout << "指针传递函数内部:交换后 *a = " << *a << ", *b = " << *b << std::endl;
}

// 引用传递
void swapByReference(int& a, int& b) {
  std::cout << "引用传递函数内部:交换前 a = " << a << ", b = " << b << std::endl;
  int temp = a;
  a = b;
  b = temp;
  std::cout << "引用传递函数内部:交换后 a = " << a << ", b = " << b << std::endl;
}

int main() {
  int x = 10, y = 20;

  std::cout << "交换前:x = " << x << ", y = " << y << std::endl;

  // 值传递
  swapByValue(x, y);
  std::cout << "值传递后:x = " << x << ", y = " << y << std::endl;

  // 指针传递
  swapByPointer(&x, &y);
  std::cout << "指针传递后:x = " << x << ", y = " << y << std::endl;

  // 引用传递
  swapByReference(x, y);
  std::cout << "引用传递后:x = " << x << ", y = " << y << std::endl;

  return 0;
}

讲解要点:

  • 三种传参方式的区别:
传参方式传递内容是否会修改实参效率 (一般情况下)使用场景
值传递实参的值低 (需要复制)不需要修改实参,传递简单数据类型
指针传递实参的地址需要修改实参,传递大型数据结构,动态内存管理
引用传递实参的别名高 (无需复制)需要修改实参,语法更简洁
  • 指针和引用的概念:
    • 指针: 一个存储内存地址的变量。可以通过解引用操作符 (*) 访问指针所指向的内存。
    • 引用: 一个变量的别名,它和被引用的变量指向同一块内存空间。引用在声明时必须初始化,并且一旦绑定就不能重新绑定到其他变量。
  • 二级指针的应用场景: 二级指针是指向指针的指针。 常用于以下场景:
    • 修改指针本身: 例如,在一个函数中动态分配内存并需要修改调用者传递进来的指针变量,使其指向新分配的内存。
    • 处理指针数组或指针的指针: 例如,char **argv 是命令行参数的常见形式,就是一个指向字符指针数组的指针。

练习与思考:

  1. 编写一个函数,使用指针传递的方式交换两个数组的元素。
  2. 尝试用引用传递的方式实现一个链表节点的插入操作。
  3. 思考指针和引用在实现上有什么不同?为什么引用必须初始化?

作业

编写一个 C++ 函数,使用递归方法计算斐波那契数列的第 n 项。

斐波那契数列定义:

  • F(0) = 0
  • F(1) = 1
  • F(n) = F(n-1) + F(n-2) (n >= 2)

同学们要同时使用 Python 也一样同样逻辑的实现,我们求 fibnacci(20), 运行 1000000 次,看出 Python用时和 C++的用时, 统计 C++和 Python 的用时