C++ 第七节课:函数进阶与内存管理
引言
同学们,经过前几节课的学习,我们已经掌握了 C++ 中函数的基本用法。今天,我们将继续深入探索函数的奥秘,学习一些更高级的函数使用技巧,并开始接触 C++ 中非常重要的内存管理概念。理解好今天的内容,对于编写更灵活、更强大的 C++ 程序至关重要。
一、回调函数 (Callback Functions)
**概念:**在编程中,回调函数就是将一个函数的指针像参数一样传递给另一个函数,这样接收方函数就可以在合适的时机“反过来调用”你传递给它的函数。
作用:
- 解耦合 (Decoupling): 回调函数允许我们分离操作的执行者和操作的具体内容。调用者不需要知道被调用者具体做什么,只需要知道如何通知它完成。
- 提升代码的灵活性和可扩展性: 通过传递不同的回调函数,我们可以让同一个函数执行不同的操作,而无需修改其内部代码。这就像你可以通过不同的“通知方式”(例如电话、短信)来接收外卖送达的消息。
- 实现事件驱动编程: 在图形界面、异步编程等场景中,回调函数常用于处理事件的发生。
示例代码:
#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
示例,可以根据不同的比较规则进行排序。 - 异步操作完成后的通知: 当一个耗时的操作(例如网络请求)完成后,通过回调函数通知程序。
- 插件机制: 允许在不修改原有代码的情况下,通过注册回调函数来扩展程序的功能。
练习与思考:
- 尝试编写一个函数,它接收一个整数数组和一个回调函数,该回调函数用于判断数组中的元素是否满足特定条件。然后,该函数返回满足条件的元素的个数。
- 思考在日常生活中,还有哪些场景可以用“回调”的思想来理解?
二、函数的递归调用 (Recursive Function Calls)
概念: 想象一下你面前放着两面镜子互相反射,你会看到一个无限延伸的影像。递归调用就像这样,一个函数在执行的过程中,直接或间接地调用了自身。为了避免无限循环,递归必须有一个明确的结束条件。
关键:
- 递归的终止条件 (Base Case): 这是递归函数不再调用自身的条件,是递归能够结束的根本保证。就像镜子迷宫的出口。
- 递归的递推关系 (Recursive Step): 定义了如何将问题分解为更小的、与原问题结构相同的子问题。就像在镜子迷宫中找到通往下一个反射点的路。
示例代码:
#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)。同时,递归可能会存在重复计算的问题,例如在计算斐波那契数列时。
- 递归与迭代的比较: 迭代通常使用循环结构实现,效率更高,占用内存更少,但某些问题的迭代实现可能比较复杂。大多数可以用递归解决的问题,也可以用迭代解决。
- 避免无限递归: 务必确保递归函数有一个明确的终止条件,并且每次递归调用都逐步接近该终止条件。
- 栈溢出的风险: 了解栈空间的限制,避免过深的递归调用。
练习与思考:
- 编写一个递归函数来计算一个整数数组的所有元素的和。
- 尝试用迭代的方式实现计算阶乘的函数。比较递归版本和迭代版本的代码,思考它们的优缺点。
- 查找资料,了解什么是“尾递归”,以及为什么尾递归可以被优化。
三、new
和 delete
, new []
, delete []
概念: 在 C++ 中,内存就像一块可以自由分配和回收的土地。当我们声明一个变量时,系统会自动在合适的地方(例如栈区)为它分配内存。但有时我们需要在程序运行的过程中动态地申请内存,这时就需要用到 new
运算符;当我们不再需要这块内存时,需要使用 delete
运算符将其归还给系统,防止“内存垃圾”堆积。
new
: 用于在堆区 (Heap) 动态地分配一块指定类型的内存空间,并返回指向该内存空间的指针。就像向操作系统申请一块土地的使用权。delete
: 用于释放由new
分配的单个对象的内存。就像归还向操作系统申请的单块土地的使用权。new []
: 用于在堆区动态地分配一块数组的内存空间,并返回指向数组首元素的指针。就像向操作系统申请一块用于建造多栋房屋的土地的使用权。delete []
: 用于释放由new []
分配的数组的内存。必须使用delete []
来释放数组内存,以确保所有数组元素的析构函数都被调用(如果元素是对象的话)。就像归还用于建造多栋房屋的土地的使用权,需要确保每栋房屋都被妥善处理。
示例代码:
#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;
}
讲解要点:
- 动态内存分配的必要性:
- 灵活地管理内存: 在程序运行时根据需要动态地申请和释放内存,避免了静态分配内存可能造成的浪费或不足。
- 创建动态数据结构: 例如链表、树等,这些数据结构的...在编译时无法确定大小,需要动态分配内存。
new
和delete
的使用方法:类型* 指针变量 = new 类型;
分配单个对象的内存。类型* 指针变量 = new 类型[大小];
分配数组的内存。delete 指针变量;
释放单个对象的内存。delete[] 指针变量;
释放数组的内存。
- 内存泄漏的概念和危害: 如果使用
new
分配了内存,但在不再使用时忘记使用delete
或delete[]
释放,就会导致内存泄漏 (Memory Leak)。 随着程序的运行,泄漏的内存会越来越多,最终可能导致程序运行缓慢甚至崩溃。 想象一下你借了别人的土地却一直不归还,最终可能会引发问题。 new
失败的情况: 当系统没有足够的内存来满足new
的请求时,会抛出一个std::bad_alloc
异常。 良好的编程习惯是使用try-catch
块来捕获这个异常并进行处理。- 悬 dangling 指针: 当
delete
或delete[]
释放了指针所指向的内存后,该指针就变成了悬 dangling 指针,它指向的内存已经无效。 访问悬 dangling 指针会导致未定义的行为,程序可能会崩溃或产生不可预测的结果。 因此,在释放内存后,通常会将指针设置为nullptr
。
练习与思考:
- 编写一个程序,使用
new
动态分配一个字符串,然后将字符串的内容复制到新分配的内存中。最后释放内存。 - 思考在哪些情况下使用动态内存分配是必要的,哪些情况下可以使用栈上的自动内存分配?
- 查找资料,了解智能指针 (Smart Pointers) 的概念,它们如何帮助管理动态分配的内存,防止内存泄漏?
四、内存中的栈区和堆区 (Stack and Heap)
概念: 计算机的内存可以想象成一个巨大的仓库,为了更好地管理这些空间,操作系统将其划分成了不同的区域。其中,栈区和堆区是 C++ 程序员需要重点理解的两个区域。
- 栈区 (Stack): 就像一个后进先出的箱子。当你调用一个函数时,该函数的局部变量、参数等信息会被放入栈中(入栈),函数执行完毕后,这些信息会被自动移除(出栈)。 栈区的内存由编译器自动管理,效率很高,但大小有限。
- 堆区 (Heap): 就像一个巨大的、可以自由分配和回收的空地。程序员可以使用
new
和delete
手动地在这片区域申请和释放内存。 堆区的大小相对较大,但需要程序员自己管理,如果管理不当容易出现内存泄漏等问题。
示例代码:
#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),当向一块堆内存中写入的数据超过了其分配的大小,就可能覆盖相邻的内存区域,导致程序出错甚至安全漏洞。 与本节课讨论的内存管理有所区别,这里指的是非法的内存写入。
练习与思考:
- 编写一个会导致栈溢出的程序。
- 思考在函数中返回指向局部变量的指针是否安全?为什么?
- 了解 C++ 中内存泄漏检测工具 (例如 Valgrind) 的基本使用方法。
五、全局变量、局部变量、static
静态变量
概念: 变量根据其声明的位置和 static
关键字的使用,拥有不同的作用域和生命周期。
- 全局变量 (Global Variable): 在所有函数外部定义的变量。它们在程序开始执行时被创建,在程序结束时被销毁,具有全局作用域,可以被程序中的任何函数访问。
- 局部变量 (Local Variable): 在函数内部或复合语句(例如
if
语句、循环语句)内部定义的变量。它们在定义时被创建,在所在的代码块执行完毕后被销毁,具有局部作用域,只能在其定义的代码块内部被访问。 static
静态变量 (Static Variable):- 局部静态变量: 在函数内部用
static
关键字声明的变量。它只在第一次执行到其定义语句时被初始化一次,之后的函数调用不会再次初始化。 它的生命周期贯穿整个程序执行期间,但作用域仍然是局部的,只能在定义它的函数内部访问。 - 全局静态变量: 在所有函数外部用
static
关键字声明的变量。它的作用域被限制在声明它的源文件中,其他源文件无法访问。这可以避免命名冲突。
- 局部静态变量: 在函数内部用
示例代码:
#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
关键字的作用:- 修饰局部变量: 延长局部变量的生命周期,使其在函数多次调用之间保持状态。
- 修饰全局变量: 限制全局变量的作用域,使其只能在当前源文件中访问。
- 修饰类的成员变量: 使类的所有对象共享同一个变量,属于类而不是类的某个特定对象。
- 修饰类的成员函数: 使成员函数不依赖于类的具体对象而存在,可以通过类名直接调用。
- 全局变量的使用注意事项: 虽然全局变量方便访问,但过度使用全局变量会降低代码的模块化程度,增加命名冲突的风险,并可能导致程序难以维护和调试。 应该谨慎使用全局变量,尽可能使用局部变量或将数据封装在类中。
练习与思考:
- 编写一个程序,演示静态局部变量在函数多次调用之间保持状态的特性。
- 思考为什么全局静态变量可以避免命名冲突?
- 查阅资料,了解 C++ 中
extern
关键字的作用,以及它与全局变量的关系。
六、函数的指针传参和引用传参
概念: 当我们调用一个函数时,需要将一些数据传递给函数进行处理。C++ 提供了三种常用的参数传递方式:值传递、指针传递和引用传递。
- 值传递 (Pass by Value): 在调用函数时,将实参的值复制一份传递给形参。函数内部对形参的修改不会影响到实参的值。就像你复印了一份文件给别人,别人在复印件上修改不会影响你的原件。
- 指针传递 (Pass by Pointer): 在调用函数时,将实参的内存地址传递给形参。形参是一个指针变量,指向实参的内存地址。函数内部可以通过解引用指针来修改实参的值。 就像你把你的房子的钥匙给了别人,别人可以通过钥匙进入你的房子并进行修改。
- 引用传递 (Pass by Reference): 在调用函数时,将实参的别名传递给形参。形参是实参的一个别名,它们指向同一块内存空间。函数内部对形参的修改会直接影响到实参的值。 就像你给你的房子起了个外号,别人叫外号和叫正式名字都是指的同一栋房子,对房子的任何操作都会反映在两个名字上。
示例代码:
#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
是命令行参数的常见形式,就是一个指向字符指针数组的指针。
练习与思考:
- 编写一个函数,使用指针传递的方式交换两个数组的元素。
- 尝试用引用传递的方式实现一个链表节点的插入操作。
- 思考指针和引用在实现上有什么不同?为什么引用必须初始化?
作业
编写一个 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 的用时