Lecture 16 (Week 8 - Thursday): RAII、智能指针与构建项目 (RAII, Smart Pointers & Building C++ Projects)
Lecture 16 (Week 8 - Thursday): RAII、智能指针与构建项目 (RAII, Smart Pointers & Building C++ Projects)
概述
本讲分三部分。首先从异常(try/catch/throw)入手:异常会带来大量”代码路径”,一旦在 new 与 delete 之间抛出异常,内存就会泄漏。答案是 RAII(Resource Acquisition Is Initialization,资源获取即初始化)——资源在构造函数中获取、在析构函数中释放,无论是否抛异常,析构函数都保证执行。其次介绍三大智能指针 std::unique_ptr(唯一所有权、不可拷贝)、std::shared_ptr(引用计数、可共享)、std::weak_ptr(不增加引用计数、打破循环引用),以及工厂函数 std::make_unique/std::make_shared。最后是构建工具链:编译命令、Makefile 与 CMake/CMakeLists.txt,让多文件项目不再靠手敲命令。
核心特性与语法详解
1. 异常:try / catch / throw
- 定义与目的:异常是”错误发生时”的处理机制——错误被 throw 抛出,代码用 try/catch 捕获并继续运行,而不是直接终止。结构类似 if/else if/else 链。
- 核心语法:
try { // 可能抛异常的代码 } catch (const std::runtime_error& e1) { // 类似 "if" // 处理第一种错误 } catch (const std::exception& e2) { // 类似 "else if" // 处理第二种错误 } catch (...) { // 类似 "else" // catch-all:兜底捕获一切 } - 设计意图与最佳实践:异常类型应尽量具体(
catch (const std::exception&)比catch (...)信息更多);捕获后要么处理、要么重新抛出。异常的代价是控制流不再线性——幻灯片统计一行return Pet(...)的代码有至少 23 条可能路径(拷贝构造、临时 string 构造、重载运算符、返回字符串拷贝都可能抛)。这意味着”new 之后、delete 之前”任何一步抛异常,delete 就可能被跳过 → 内存泄漏。
2. RAII:Resource Acquisition Is Initialization
- 定义与目的:Bjarne Stroustrup 提出的思想:所有资源应在构造函数中获取,在析构函数中释放。这样资源生命周期与对象作用域绑定:对象创建即可用,对象销毁必然释放——无论中途是否抛异常(栈展开时析构函数必被调用)。
- 核心语法(反例 vs 正例):
// 反例:ifstream 在代码中打开/关闭,异常时可能漏关 void bad() { std::ifstream file; file.open("data.txt"); // 资源在"代码中"获取 // ... 若这里抛异常,file 永远不会 close file.close(); // 手工释放 } // 正例:RAII 类在构造时打开、析构时关闭 void good() { std::ifstream file("data.txt"); // 构造 = 获取资源 // ... 无论发生什么,file 离开作用域时析构自动 close } - 设计意图与最佳实践:RAII 消除”半有效状态”:对象要么不存在,要么完全可用;资源要么没获取,要么保证释放。
std::lock_guard是 RAII 的典范——构造时获取锁,析构(出作用域)时自动释放,锁永远不会因为异常而”忘记解锁”。智能指针就是把 RAII 应用到内存管理的产物。
3. std::unique_ptr<T>:唯一所有权
- 定义与目的:独占管理一块堆内存的”包装指针”:构造(或
make_unique)时拥有资源,析构时自动delete。不可拷贝(拷贝构造/拷贝赋值被= delete),只能移动(所有权转移)。这正是 Lecture 13/14 的 SMF 与移动语义的实际应用。 - 核心语法:
#include <memory> std::unique_ptr<T> p = std::make_unique<T>(args...); // 首选! auto q = std::move(p); // 移动转移所有权,p 变空 T* raw = p.get(); // 取裸指针(不转移所有权) p->member; (*p).member; // 像裸指针一样用 - 设计意图与最佳实践:为什么不可拷贝?如果两个 unique_ptr 指向同一内存,先析构者 delete 后,另一个就成了悬垂指针(幻灯片场景)。移动则始终保证”同一时刻只有唯一 owner”。用
std::make_unique<T>而不是new(后面详解)。
4. std::shared_ptr<T>:引用计数共享所有权
- 定义与目的:多个 shared_ptr 可共享同一块内存,内部维护引用计数(reference count):每拷贝一次计数 +1,每析构一个 -1;计数归零时才真正
delete底层资源。解决”unique_ptr 不能拷贝”的共享需求。 - 核心语法:
std::shared_ptr<T> a = std::make_shared<T>(args...); std::shared_ptr<T> b = a; // 拷贝:计数 +1(现在 2) b.reset(); // b 释放:计数 -1(现在 1) a.reset(); // 计数归零 → 真正 delete long n = a.use_count(); // 当前引用计数 - 设计意图与最佳实践:计数增减是原子的(线程安全);但循环引用是致命陷阱:A 持有指向 B 的 shared_ptr、B 持有指向 A 的 shared_ptr 时,两者计数永远到不了 0,内存永不释放——这就是 weak_ptr 要解决的。
5. std::weak_ptr<T>:打破循环引用
- 定义与目的:观察 shared_ptr 管理的对象但不增加引用计数(不拥有所有权)。典型用途:打破 A↔B 循环引用。因为不计数,它指向的对象可能随时被销毁,使用时需
lock()升级为 shared_ptr 再访问。 - 核心语法:
std::weak_ptr<T> w = a; // 从 shared_ptr 构造,计数不变 if (auto s = w.lock()) { // lock() 返回 shared_ptr;对象已死则返回空 s->use(); // 安全访问 } else { // 对象已被销毁 } - 设计意图与最佳实践:
weak_ptr永远不能直接解引用(没有operator*/operator->)——必须先lock(),因为对象可能已不存在。它不是”第二所有权”,而是”不拥有所有权的观察者”。
6. std::make_unique / std::make_shared
- 定义与目的:智能指针的推荐工厂函数,替代裸
new。 - 核心语法:
auto p = std::make_unique<T>(ctorArgs...); // C++14 起 auto s = std::make_shared<T>(ctorArgs...); // 反面教材(避免): std::shared_ptr<T> s2(new T(...)); // 两步:先 new 再包装 - 设计意图与最佳实践:两个理由——(1)异常安全:
f(std::shared_ptr<A>(new A), g())中若g()先于包装抛异常,new A出来的裸指针就泄漏了;make_shared一步到位无此窗口。(2)效率:make_shared把对象与计数块放进同一次分配;make_unique让代码统一(”用了 make_unique 就也用 make_shared”),避免new与智能指针混用。
7. 构建项目:编译命令、Makefile、CMake
- 定义与目的:C++ 源码要先翻译成机器码(编译器)才能运行;多文件项目需要把多个
.cpp一起编译。手工敲命令不现实,于是有了 make(构建系统,读 Makefile) 和 CMake(构建系统生成器,读 CMakeLists.txt,生成 Makefile) 两层工具。 - 核心语法:
# 编译命令:g++ -std=<标准> <源文件...> -o <可执行文件名> g++ -std=c++20 main.cpp user.cpp -o main ./main# Makefile CXX = g++ # 编译器 CXXFLAGS = -std=c++20 # 编译选项 SRCS = $(wildcard *.cpp) # 自动收集所有 .cpp TARGET = main all: $(CXX) $(CXXFLAGS) $(SRCS) -o $(TARGET) clean: rm -f $(TARGET)# CMakeLists.txt cmake_minimum_required(VERSION 3.10) project(cs106l_classes) set(CMAKE_CXX_STANDARD 20) # 指定 C++20 file(GLOB SRC_FILES "*.cpp") # 通配收集源文件 add_executable(main ${SRC_FILES}) # 生成可执行文件 main - 设计意图与最佳实践:make 的优点是增量编译(只重编改动过的文件)和集中管理编译参数;CMake 在 Makefile 之上再加一层抽象(跨平台、可生成不同构建系统)。CMake 标准流程:
mkdir build && cd build cmake .. # 用根目录的 CMakeLists.txt 生成 Makefile make # 编译 ./main # 运行
代码示例与逐步解说(核心)
示例 1:异常基础——throw 与 catch(C++11)
// C++11
#include <iostream>
#include <stdexcept>
double divide(double a, double b) {
if (b == 0) throw std::runtime_error("division by zero"); // 抛出异常
return a / b;
}
int main() {
try {
std::cout << divide(10, 2) << '\n'; // 5(正常)
std::cout << divide(10, 0) << '\n'; // 抛出异常,跳到 catch
std::cout << "never printed\n";
} catch (const std::runtime_error& e) { // 按类型捕获
std::cout << "Caught: " << e.what() << '\n';
}
std::cout << "Program continues...\n"; // 捕获后继续执行
}
- 代码做什么:
divide(10, 0)抛出std::runtime_error;try块立即中断,控制流跳到匹配的catch,打印错误信息后程序继续运行。 - 特性机制解说:
throw会展开栈(stack unwinding)——从抛出点向上,逐层销毁已构造的局部对象并调用其析构函数,直到找到匹配的catch。这正是 RAII 能保证异常安全的机制基础:即使抛出异常,局部对象的析构函数也一定会执行。异常匹配是按类型(含继承)进行的,catch (const std::exception&)能捕获所有派生异常,catch (...)兜底一切。若没有 catch,程序调用std::terminate终止。
示例 2:异常导致内存泄漏(C++11)
// C++11:演示为什么"裸 new + 手工 delete"在异常面前如此脆弱
#include <iostream>
#include <stdexcept>
void process(bool fail) {
int* data = new int[1000]; // 手工分配堆内存
if (fail) {
throw std::runtime_error("boom"); // 异常在这里抛出!
// delete[] data; // ❌ 永远不会执行 → 泄漏 1000 个 int
}
delete[] data; // 只有不抛异常才执行
}
int main() {
try {
process(true);
} catch (const std::exception& e) {
std::cout << "Caught: " << e.what() << '\n'; // 捕获了,但内存已泄漏
}
}
- 代码做什么:
process(true)在new之后抛异常,delete[]被跳过,程序”捕获成功”却泄漏了 4KB 内存。 - 特性机制解说:裸指针不是 RAII 对象——它没有析构函数,栈展开时没有任何机制帮它释放内存。幻灯片强调”这不只是指针的问题”:文件句柄、锁、数据库连接……一切”需要获取后释放”的资源在异常下都可能泄漏。解决思路就是给资源套一层 RAII 包装:资源在构造时获取、在析构时释放,析构由栈展开保证执行。
std::lock_guard(构造加锁、析构解锁)和智能指针(构造持有、析构释放)都是这个思路的实例。
示例 3:自定义 RAII 类(C++11)
// C++11:手写一个 RAII 文件类,展示"析构必执行"
#include <iostream>
#include <stdexcept>
class File {
public:
explicit File(const char* name) {
std::cout << "Opening " << name << '\n';
// 真实代码:fopen / std::ifstream 打开资源
}
~File() {
std::cout << "Closing file (always runs!)\n";
// 真实代码:fclose / 关闭资源
}
};
void useFile(bool fail) {
File f("data.txt"); // 构造 = 获取资源
if (fail) {
throw std::runtime_error("error while using file");
}
// 资源使用完毕
} // ← 无论是否抛异常,f 的析构函数都会在这里执行
int main() {
try {
useFile(true); // 抛异常 → 栈展开 → f 析构 → catch
} catch (const std::exception& e) {
std::cout << "Caught: " << e.what() << '\n';
}
useFile(false); // 正常路径同样析构
}
- 代码做什么:
File在构造时”打开”、析构时”关闭”。useFile(true)抛异常,但f的析构仍被执行(栈展开),输出 “Closing file (always runs!)”;正常路径也一样。 - 特性机制解说:这就是 RAII 的全部奥义——资源生命周期 = 对象生命周期。构造函数里获取资源,对象诞生即”完全可用”(无半有效状态);析构函数里释放资源,对象死亡即”资源已还”。异常安全来自语言保证:栈展开时所有已构造的局部对象的析构函数必然被调用。对比示例 2:裸
new的指针在栈展开时”没人管”,而 RAII 对象永远有人管。std::lock_guard正是这个模式在锁上的应用——critical section 里抛异常,锁也会被自动释放,绝不死锁。
示例 4:std::unique_ptr 链表——RAII 自动释放(改写自 A7 的 ListNode 示例)(C++14,make_unique 需 C++14)
// C++14(std::make_unique 自 C++14 起可用)
#include <iostream>
#include <memory>
struct Node {
int value;
std::unique_ptr<Node> next; // 递归持有下一个节点
explicit Node(int v) : value(v) {}
~Node() { std::cout << "Destroying node " << value << '\n'; }
};
int main() {
auto head = std::make_unique<Node>(1);
head->next = std::make_unique<Node>(2);
head->next->next = std::make_unique<Node>(3);
// std::unique_ptr<Node> copy = head; // ❌ 编译错误:unique_ptr 不可拷贝
std::unique_ptr<Node> moved = std::move(head); // ✅ 移动转移所有权
std::cout << "head is " << (head ? "non-null" : "null") << '\n'; // null
if (moved) std::cout << "moved->value = " << moved->value << '\n'; // 1
} // moved 析构 → 递归销毁 1 → 2 → 3,全程无手工 delete!
- 代码做什么:用
make_unique建一条三节点链表,演示移动转移所有权;main 结束时moved析构,整条链表递归释放,打印三次 “Destroying node”。 - 特性机制解说:
unique_ptr的析构调用delete,而Node的析构会销毁成员next(另一个 unique_ptr),后者又触发下一个Node的析构——递归释放整条链。不可拷贝是设计核心:若允许拷贝,两个指针指向同一节点,先析构者 delete 后另一个悬垂(幻灯片”original destructor is called after the copy happens”场景)。移动保持唯一性:只是把所有权从head转给moved,head变空指针。A7 提醒:这种递归析构对很长的链表会消耗调用栈(每次析构嵌套一层),可能栈溢出。
示例 5:shared_ptr 引用计数与 weak_ptr 打破循环(C++11)
// C++11:循环引用 = 泄漏;weak_ptr 打破循环
#include <iostream>
#include <memory>
struct A;
struct B;
struct A { std::shared_ptr<B> b; ~A() { std::cout << "~A\n"; } };
struct B { std::shared_ptr<A> a; ~B() { std::cout << "~B\n"; } }; // 循环!
struct C;
struct D;
struct C { std::shared_ptr<D> d; ~C() { std::cout << "~C\n"; } };
struct D { std::weak_ptr<C> c; ~D() { std::cout << "~D\n"; } }; // weak 打破循环
void badCycle() {
auto pa = std::make_shared<A>();
auto pb = std::make_shared<B>();
pa->b = pb;
pb->a = pa; // A 与 B 互相持有 shared_ptr → 计数都 ≥1,永不释放
} // 函数结束:什么析构都不打印 → 内存泄漏!
void goodCycle() {
auto pc = std::make_shared<C>();
auto pd = std::make_shared<D>();
pc->d = pd;
pd->c = pc; // weak_ptr 不增加引用计数
} // 正常释放:打印 ~C 与 ~D
int main() {
std::cout << "bad:\n";
badCycle();
std::cout << "good:\n";
goodCycle();
}
- 代码做什么:
badCycle中 A↔B 互相持有 shared_ptr,函数结束时两者引用计数都降不到 0,析构函数永远不被调用(泄漏);goodCycle中 D 用weak_ptr持有 C,不计数,全部正常释放。 - 特性机制解说:shared_ptr 内部有一个引用计数(常与对象一起分配在”控制块”里):每份拷贝 +1,每个析构 -1,归零才
delete底层对象。循环引用时:pa与pb->a各持一份指向 A 的计数(2),pb与pa->b各持一份指向 B 的计数(2)。函数结束时pa、pb各 -1,计数仍为 1——谁也到不了 0,谁也不释放。weak_ptr旁观不计数:pd->c是 weak_ptr,指向 C 的计数只有pc一份;pc析构 → C 计数归零 →~C→ 销毁成员d(shared_ptr)→ D 计数归零 →~D。使用 weak_ptr 时要注意:它没有operator->,必须lock()得到 shared_ptr 才能访问(对象可能已被销毁,lock 返回空)。
示例 6:make_shared 的异常安全(C++11)
// C++11
#include <iostream>
#include <memory>
#include <stdexcept>
struct Widget { Widget(int) {} };
void helper() { throw std::runtime_error("boom"); }
int main() {
// 危险写法:先 new 再包装,若 helper() 先抛异常,new 的内存没人接管 → 泄漏
// std::shared_ptr<Widget> w(new Widget(1), helper()); // 求值顺序不定,可能泄漏
// 安全写法:make_shared 一步完成,要么都成功要么都失败
try {
auto w = std::make_shared<Widget>(1);
helper(); // 若这里抛异常,w 的析构会正常释放 Widget
std::cout << "OK\n";
} catch (const std::exception& e) {
std::cout << "Caught: " << e.what() << '\n';
}
}
- 代码做什么:演示
make_shared与 RAII 组合的异常安全性:即使helper()抛异常,w作为局部对象仍会在栈展开时释放其管理的 Widget。 - 特性机制解说:
std::shared_ptr<Widget>(new Widget(1), helper())有两个独立步骤(先new、再构造 shared_ptr 接管),C++ 允许求值顺序导致”helper() 先抛、new 出的裸指针无人接管”的泄漏窗口。make_shared把分配与包装合成一个原子操作,堵死这个窗口;同时它把对象与控制块放进同一次分配,比”new + shared_ptr 构造”少一次堆分配。这就是”永远用 make_unique / make_shared,不要用裸 new”的完整理由。
示例 7:构建工具链——编译命令、Makefile、CMake
# ① 直接编译(多文件)
g++ -std=c++20 main.cpp user.cpp -o main
./main
# ② CMake 标准工作流(Makefile 与 CMakeLists.txt 的完整写法见上文"核心特性与语法详解"第 7 节)
mkdir build && cd build # 在项目内建 build 目录(生成物集中存放)
cmake .. # 用根目录的 CMakeLists.txt 生成 Makefile
make # 编译
./main # 运行
- 代码做什么:两段命令演示”直接编译”与”CMake 标准工作流”(Makefile/CMakeLists.txt 内容见上文,此处不再重复)。
- 特性机制解说:
g++是编译器,-std=c++20指定语言标准,-o main指定输出名。make 是构建系统:读 Makefile,按目标(target)执行命令,并能增量编译——只重新编译自上次构建以来修改过的文件(幻灯片强调这是 make 的核心优势)。CMake 是构建系统生成器:读 CMakeLists.txt(更高层的抽象),生成 Makefile(或其他构建系统的文件)。CMake 的file(GLOB SRC_FILES "*.cpp")等价于 Makefile 的$(wildcard *.cpp)——自动收集源文件,新增 .cpp 无需改构建脚本。真实世界如 TensorFlow Core 有 2000+ 源文件,显然需要这样的工具链而不是手敲命令。
与旧标准(如C++98)的对比
- 智能指针在 C++98 的处境:C++98 标准库只有
std::auto_ptr(<memory>)。它的”拷贝”语义其实是转移所有权(拷贝构造会把源指针置空),行为反直觉、无法放进标准容器(vector<auto_ptr<T>>会出问题),也没有移动语义支撑。C++11 用std::unique_ptr取代它,auto_ptr在 C++17 被正式移除。 - C++98 的资源管理:只有裸指针 + 手工
new/delete,异常安全完全靠程序员自律(示例 2 的泄漏在 C++98 同样存在且无解);锁、文件等资源同样手工管理,异常路径极易泄漏。RAII 思想在 C++98 就存在(std::string、std::vector就是 RAII 类),但直到 C++11 才把它系统化应用到指针上。 std::make_shared是 C++11 的,std::make_unique是 C++14 的(make_unique是 C++14 才进入标准库,此前常用自定义版本)。C++98 只能new+ 手工包装。- 构建工具:make 和 CMake 都是老工具(Make 自 1976 年,CMake 自 2000 年),不是 C++ 标准的一部分;区别在于 CMake 的
set(CMAKE_CXX_STANDARD 20)让”指定 C++ 版本”变得显式、可跨平台。C++20 起还有官方的模块(modules)与包管理器(如 CMake 的 FetchContent)在演进,但课程仍以 Makefile/CMake 为教学主线。
关键要点
- 异常:
try/catch/throw让错误可捕获、程序可继续;但异常带来大量隐式代码路径,任何”获取后未释放”的资源都可能泄漏。 - RAII:资源在构造函数获取、析构函数释放;栈展开保证析构必执行,因此 RAII 是 C++ 异常安全的基石(
lock_guard、智能指针、string/vector都是 RAII)。 unique_ptr:唯一所有权、不可拷贝、可移动;析构自动delete;永远用std::make_unique。shared_ptr:引用计数共享所有权,计数归零才释放;weak_ptr旁观不计数,用来打破循环引用;永远用std::make_shared(异常安全 + 一次分配)。- 构建:
g++ -std=c++XX files -o exe直接编译;多文件项目用 Makefile(make,增量编译);再往上用 CMake(CMakeLists.txt → 生成 Makefile →cmake .. && make)。
常见陷阱与注意事项
- 循环引用导致内存泄漏:A↔B 互相持有
shared_ptr,引用计数永远到不了 0,析构永不执行。打破方法:让其中一方(通常是被依赖/生命周期更短的一方)持有weak_ptr。 weak_ptr直接解引用:weak_ptr没有operator*/operator->,必须先lock()检查对象是否还活着;对象可能已被销毁,lock()返回空 shared_ptr。- 用裸
new而不是make_unique/make_shared:既有异常安全窗口(先 new 后包装,中间抛异常就泄漏),又多一次分配;记住”new 与 delete 每出现一次,就是一次手动管理的机会”。 - 忘记
noexcept(呼应 L14):unique_ptr 的移动操作若可能抛异常,容器会退化为拷贝。 - 手工
delete智能指针管理的内存:delete p.get();是双重释放;p.release()后不接管则是泄漏——尽量不要触碰.get()/.release()返回的裸指针的所有权。 - Makefile 的 tab 陷阱:
all:下的命令必须以制表符(Tab)开头,空格会导致make报错 “missing separator”;CMake 记得在 build 目录里执行cmake ..,不要把生成物散落在源码目录。
关联作业提示
本讲与 A7: Unique Pointer(assign7/)直接相关——你要亲手实现一个简化版 cs106l::unique_ptr,正是本讲三个主题的集大成:
- 实现 RAII:
~unique_ptr()里delete ptr;(本讲”析构释放资源”);short answer Q1 问的正是”用 RAII 管理内存相比手工 new/delete 的好处”(自动释放、异常安全、无泄漏)。 - 实现不可拷贝 + 可移动:
unique_ptr(const unique_ptr&) = delete;与operator=(const unique_ptr&) = delete;(L13 的= delete),unique_ptr(unique_ptr&& other)与operator=(unique_ptr&& other)用 L14 的”偷指针 + 置空源对象”套路实现(short answer Q2 问不置空会怎样——双重释放)。 - Part 2 的链表:
head->next = std::make_unique<ListNode<int>>(2)的递归结构正是本讲示例 4;create_list里用node->next = std::move(head)转移所有权(L14 的std::move)。short answer Q3 问”递归析构对长链表的隐患”——本讲示例 4 的机制解说里提到了栈深度问题。 - 构建命令:
g++ -std=c++20 main.cpp -o main(A7 单文件)与 A5 的g++ -std=c++20 main.cpp user.cpp -o main(多文件)就是本讲编译命令一节的内容;若项目变大,可以用 Makefile 或 CMake 来管理。
另外,L16 与本讲之外的两讲也有关联:std::optional(L15)在 A6 中使用;A5 的 User 类本质上是”手动管理裸指针数组的 RAII 类”(析构释放、拷贝深拷贝),理解 RAII 能帮你写对它的析构函数。
