C++ / 2026-08-01
CS106L 第 16 讲:RAII、智能指针与项目构建
从资源生命周期出发,掌握 RAII、智能指针以及 Make 与 CMake 项目构建。
本课程依据 CS106L Lecture 16: RAII, Smart Pointers, Building Projects 重构。
本节课真正要解决的问题
前几节课中,我们已经学习了对象、特殊成员函数、移动语义,以及如何用 std::optional 避免无效状态。
现在,我们要面对另一个更加隐蔽的问题:
一个程序不仅要正确地“使用资源”,还必须确保资源最终会被释放。
这里的资源不只是动态内存。文件、线程锁、网络连接和数据库连接,都有类似的生命周期:
获取资源
↓
使用资源
↓
释放资源
如果程序永远按照从上到下的顺序执行,我们或许可以在最后手动写一个释放操作。
但真实程序可能:
- 提前
return; - 在中途抛出异常;
- 在多个分支中结束;
- 调用另一个可能失败的函数;
- 在维护时加入新的退出路径。
于是,“记得在最后释放资源”会逐渐变成一件非常不可靠的事。
本节课将依次解决三个问题:
- 异常为什么会让手动资源管理变得脆弱;
- RAII 如何把资源生命周期绑定到对象生命周期;
- 智能指针如何把 RAII 应用于动态内存;
- 当项目由多个文件组成时,如何使用 Make 和 CMake 构建程序。
学习本节所需的前置知识
你需要大致知道:
- 局部变量有作用域;
- 构造函数在对象创建时执行;
- 析构函数在对象销毁时执行;
new可以在动态存储区中创建对象;delete可以释放由new创建的对象;- 指针可以通过
*和->访问目标对象; - 对象可以复制,也可以移动;
- 类的成员对象会随着外层对象一起销毁。
本节会重新解释这些知识如何共同构成 RAII,因此不要求你已经熟练掌握资源管理。
从上一节的类型安全自然走向资源安全
上一节课中的 std::optional<T> 解决了这样一个问题:
一个值有时存在,有时不存在,怎样在类型层面明确表示这种状态?
RAII 解决的是一个相似但范围更大的问题:
一个对象是否拥有某项资源?谁负责释放它?什么时候释放?
如果这些责任只存在于程序员脑子里,代码就很容易出错。
如果我们能够把责任写进类型:
对象存在
↓
资源一定已被正确获取
对象销毁
↓
资源一定被正确释放
那么资源管理就不再依赖“记得调用某个函数”,而是由语言的对象生命周期帮助我们完成。
一、一个看似只有三个分支的函数,究竟有多少条执行路径?
考虑课件中的函数:
std::string returnNameCheckPawsome(Pet p) {
if (p.type() == "Dog" || p.firstName() == "Fluffy") {
std::cout << p.firstName() << ' '
<< p.lastName()
<< " is paw-some!\n";
}
return p.firstName() + " " + p.lastName();
}
这里只展示了 Pet 的使用接口,暂时省略类的完整定义。后面的综合项目会给出可直接编译的完整版本。
从普通的分支结构来看,我们可能认为这里只有几种情况:
进入函数
↓
判断条件
↙ ↘
真 假
↘ ↙
返回姓名
似乎只有两三条执行路径。
但是,这段代码中几乎每一项操作都可能包含函数调用:
- 按值传入
Pet p,可能需要调用复制构造函数; p.type()是函数调用;p.firstName()是函数调用;p.lastName()是函数调用;==可能是重载运算符;<<是重载运算符;- 字符串
+是重载运算符; - 创建字符串临时对象可能需要分配内存;
- 返回字符串可能涉及构造、移动或复制。
这些操作中的某一项如果失败,就可能抛出异常。
于是,函数除了正常的 if 分支和 return 路径,还隐藏着大量“执行到一半突然离开”的异常路径。
课件将这些潜在异常出口统计为“至少 23 条路径”,其目的不是给出一个严格适用于所有编译器的控制流定理,而是强调:
一段看起来很短的 C++ 代码,也可能存在大量隐藏的退出位置。
更严格地说,这个数字依赖很多假设:
Pet的函数是否真的可能抛出异常;- 运算符如何实现;
std::cout是否启用了异常;- 编译器是否进行复制消除;
- 字符串操作是否需要分配内存;
- 短路求值让哪些表达式根本不会执行。
因此,不必死记数字 23。真正需要记住的是:
不能只在肉眼可见的
return前考虑资源清理,异常也能让函数在中途退出。
二、异常是怎样改变程序执行方向的?
2.1 什么是异常?
异常(exception)是 C++ 表示和传播错误的一种机制。
普通函数调用通常按照下面的方式返回:
调用函数
↓
函数正常执行
↓
通过 return 返回调用者
异常发生时,流程会变成:
调用函数
↓
执行到 throw
↓
立即停止当前正常流程
↓
寻找能够处理该异常的 catch
抛出异常使用 throw:
throw std::runtime_error("something went wrong");
处理异常使用 try 和 catch:
try {
// 可能抛出异常的代码
} catch (const std::runtime_error& error) {
// 处理 runtime_error
}
2.2 一个完整的异常示例
下面的程序检查年龄是否合法:
#include <iostream>
#include <stdexcept>
void checkAge(int age) {
if (age < 18) {
throw std::invalid_argument(
"you must be at least 18 years old"
);
}
std::cout << "Access granted.\n";
}
int main() {
try {
checkAge(15);
std::cout << "This line is inside try.\n";
} catch (const std::invalid_argument& error) {
std::cout << "Access denied.\n";
std::cout << "Reason: " << error.what() << '\n';
}
std::cout << "Program continues after catch.\n";
}
编译:
g++ -std=c++20 main.cpp -o main
输出:
Access denied.
Reason: you must be at least 18 years old
Program continues after catch.
执行过程如下。
首先,main() 进入 try:
age = 15
然后调用:
checkAge(15);
在 checkAge() 中:
age < 18
结果为 true,于是执行:
throw std::invalid_argument(...);
此时发生三件事:
checkAge()后面的代码不再执行;try中checkAge(15)后面的输出语句也不再执行;- 程序开始寻找匹配的
catch。
异常类型是 std::invalid_argument,因此下面的处理器可以接住它:
catch (const std::invalid_argument& error)
处理完成后,程序继续执行整个 try-catch 结构之后的代码。
2.3 catch 不是普通的 else
课件为了建立直觉,将多个 catch 类比成:
第一个 catch 类似 if
第二个 catch 类似 else if
最后的全捕获 类似 else
这个类比可以帮助理解匹配顺序,但不能完全等同。
正式语法是:
try {
// 可能抛出异常
} catch (const std::invalid_argument& error) {
// 处理 invalid_argument
} catch (const std::runtime_error& error) {
// 处理 runtime_error
} catch (const std::exception& error) {
// 处理其他标准异常
} catch (...) {
// 捕获其他所有类型
}
课件中的简化写法:
catch {
}
并不是合法的 C++ 全捕获语法。
正确写法必须包含三个点:
catch (...) {
}
另外,通常应当通过常量引用捕获异常:
catch (const std::exception& error)
而不是:
catch (std::exception error)
通过值捕获可能产生额外复制,还可能发生对象切片。
2.4 处理器的顺序很重要
假设 std::invalid_argument 属于更一般的 std::exception 类型体系。
下面的顺序合理:
try {
// ...
} catch (const std::invalid_argument& error) {
// 更具体的类型
} catch (const std::exception& error) {
// 更一般的类型
}
如果把一般类型放在前面:
try {
// ...
} catch (const std::exception& error) {
// 会先匹配这里
} catch (const std::invalid_argument& error) {
// 这里无法得到机会
}
更具体的处理器就可能永远不会被执行。
三、异常传播时,局部对象会发生什么?
异常并不一定在抛出它的函数中被处理。
考虑调用关系:
main()
└── outer()
└── inner()
└── throw
如果 inner() 没有对应的 catch,异常会离开 inner(),继续寻找调用者中的处理器。
这个过程称为栈展开(stack unwinding)。
在展开过程中,已经成功构造的局部对象会被销毁。
3.1 用程序观察栈展开
#include <iostream>
#include <stdexcept>
#include <string>
class Marker {
public:
explicit Marker(const std::string& name)
: name_(name) {
std::cout << "Construct " << name_ << '\n';
}
~Marker() {
std::cout << "Destroy " << name_ << '\n';
}
private:
std::string name_;
};
void inner() {
Marker inner_marker{"inner object"};
throw std::runtime_error("error from inner");
}
void outer() {
Marker outer_marker{"outer object"};
inner();
}
int main() {
try {
outer();
} catch (const std::exception& error) {
std::cout << "Caught: " << error.what() << '\n';
}
}
输出:
Construct outer object
Construct inner object
Destroy inner object
Destroy outer object
Caught: error from inner
执行顺序为:
进入 outer()
创建 outer_marker
进入 inner()
创建 inner_marker
inner() 抛出异常
销毁 inner_marker
异常离开 inner()
销毁 outer_marker
异常到达 main() 的 catch
这里出现了一个极其重要的性质:
即使函数没有执行到结尾,局部对象的析构函数仍会在栈展开时执行。
这正是 RAII 能够工作的基础。
3.2 哪些东西不会自动清理?
局部对象会自动销毁,但普通指针指向的动态对象不会因为指针离开作用域而自动执行 delete。
考虑:
Resource* pointer = new Resource;
这里实际上存在两个不同的东西:
局部指针变量 pointer
│
▼
动态创建的 Resource 对象
当作用域结束时:
pointer这个指针变量会消失;- 它指向的动态对象不会自动销毁。
如果没有执行:
delete pointer;
动态对象就会泄漏。
练习:预测栈展开时的输出
#include <iostream>
#include <stdexcept>
class Item {
public:
explicit Item(int id) : id_(id) {
std::cout << "create " << id_ << '\n';
}
~Item() {
std::cout << "destroy " << id_ << '\n';
}
private:
int id_;
};
void work() {
Item first{1};
{
Item second{2};
throw std::runtime_error("failed");
}
}
int main() {
try {
work();
} catch (const std::exception&) {
std::cout << "caught\n";
}
}
答案与解释
输出为:
create 1
create 2
destroy 2
destroy 1
caught
first 先创建,second 后创建。
异常发生时,局部对象按照与构造相反的顺序销毁:
first 创建
second 创建
异常发生
second 销毁
first 销毁
catch 要等到 work() 中需要清理的对象全部销毁后才开始执行。
四、为什么手动 new 和 delete 经不起异常?
考虑下面的资源类:
#include <iostream>
#include <stdexcept>
class Resource {
public:
Resource() {
++live_count_;
std::cout << "Resource acquired.\n";
}
~Resource() {
--live_count_;
std::cout << "Resource released.\n";
}
static int liveCount() {
return live_count_;
}
private:
static inline int live_count_ = 0;
};
void riskyOperation() {
throw std::runtime_error("operation failed");
}
void unsafeFunction() {
Resource* resource = new Resource;
riskyOperation();
delete resource;
}
int main() {
try {
unsafeFunction();
} catch (const std::exception& error) {
std::cout << "Caught: " << error.what() << '\n';
}
std::cout << "Live resources: "
<< Resource::liveCount() << '\n';
}
输出:
Resource acquired.
Caught: operation failed
Live resources: 1
没有出现:
Resource released.
执行过程为:
new Resource
↓
动态对象创建成功
↓
riskyOperation() 抛出异常
↓
unsafeFunction() 立即退出
↓
delete resource 被跳过
虽然局部指针变量 resource 会消失,但它指向的对象仍然存在。
这就是内存泄漏(memory leak)。
4.1 课件中的原始指针示例需要修正的地方
课件使用了类似下面的结构:
Pet* p = new Pet(petId);
// 使用 p
delete p;
其教学目的非常明确:
如果
new和delete之间抛出异常,delete就不会执行。
不过,课件截图中对指针成员的访问写成了类似:
p.type()
如果 p 的类型是 Pet*,正确语法应当是:
p->type()
或者:
(*p).type()
此外,必须确保删除的是同一个指针变量:
delete p;
不能出现对象名称前后不一致的情况。
这些语法修正不会改变原示例的教学重点:手动资源释放很容易被异常绕过。
4.2 这不只是内存问题
很多资源都具有成对操作:
| 资源 | 获取操作 | 释放操作 |
|---|---|---|
| 动态内存 | new |
delete |
| 文件 | open |
close |
| 互斥锁 | lock |
unlock |
| 网络套接字 | socket |
close |
| 数据库连接 | connect |
disconnect |
| 系统句柄 | 创建或获取句柄 | 关闭句柄 |
共同问题是:
获取资源
↓
执行许多可能失败的操作
↓
释放资源
只要中间存在一条异常路径,最后的释放操作就可能被跳过。
4.3 可以在每个地方都写 try-catch 吗?
我们当然可以这样写:
Resource* resource = new Resource;
try {
riskyOperation();
} catch (...) {
delete resource;
throw;
}
delete resource;
这里的:
throw;
表示重新抛出当前异常。
这段代码能够处理当前这个简单例子,但会产生新的问题:
- 每获取一种资源都要重复写清理代码;
- 多个资源的清理顺序容易出错;
- 新增一个
return时可能忘记清理; - 新增一个异常点时可能忘记更新处理逻辑;
- 清理代码会淹没真正的业务逻辑;
- 多层嵌套后非常难读。
我们真正需要的不是“在每一条路径上手动清理”,而是:
无论从哪一条路径离开作用域,都自动执行清理。
这自然引出了 RAII。
五、RAII:把资源生命周期绑定到对象生命周期
RAII 的全称是:
资源获取即初始化(Resource Acquisition Is Initialization)
这个名称第一次看到可能有些拗口。它想表达的是:
让一个对象在构造成功时就拥有资源,并在对象析构时自动释放资源。
可以把 RAII 对象理解成资源的管理者:
RAII 对象
┌─────────────────────────┐
│ 持有资源的句柄 │
│ 构造时获取资源 │
│ 析构时释放资源 │
└─────────────────────────┘
只要管理对象是局部对象:
进入作用域
↓
构造管理对象
↓
资源被获取
↓
使用资源
↓
离开作用域
↓
析构管理对象
↓
资源被释放
离开作用域可以由很多原因造成:
- 正常执行到右花括号;
- 执行
return; - 执行
break或continue后离开局部作用域; - 抛出异常并进行栈展开。
这些情况下,已成功构造的局部对象都会析构。
5.1 对课件定义的更准确理解
课件将 RAII 概括为:
- 类使用的资源应当在构造函数中获取;
- 类使用的资源应当在析构函数中释放。
作为初学阶段的直觉,这样理解是合适的。
更准确地说:
一个类所拥有的资源,应当由对象生命周期管理。
“使用资源”和“拥有资源”不是同一个概念。
例如:
void print(const Pet& pet);
print() 使用了 pet,但并不拥有 pet,因此不负责销毁它。
而智能指针可能拥有动态对象,因此必须负责最终释放它。
5.2 为什么 RAII 能避免“半有效状态”?
考虑一个需要手动初始化的类:
class FileReader {
public:
FileReader() = default;
void open(const std::string& filename);
void read();
};
对象刚创建时:
FileReader reader;
它还没有文件可读。
此时调用:
reader.read();
对象可能处于无效状态。
其生命周期变成:
对象已存在
↓
但资源尚未获取
↓
必须记得调用 open()
↓
对象才真正可用
这就是一种半有效状态(half-valid state)。
更好的设计是:
class FileReader {
public:
explicit FileReader(const std::string& filename);
void read();
};
创建时就提供文件名:
FileReader reader{"data.txt"};
如果构造成功,说明对象已经可用。
如果文件无法打开,构造函数可以抛出异常,使这个无效对象根本不会成功存在。
于是我们得到一个更清楚的不变量:
FileReader 对象存在
⇔
它持有一个可用的文件资源
5.3 RAII 的三个主要收益
对象构造成功后立即可用
不需要额外记住:
object.initialize();
资源释放不依赖手动调用
不需要在所有路径中重复:
object.close();
异常发生时仍然能够清理资源
异常展开会调用局部对象的析构函数。
5.4 “无论如何都会析构”需要一点边界
课件使用了“No matter what”来强调析构函数的可靠性。
在普通 C++ 控制流中,可以理解为:
- 正常离开作用域会析构;
return会析构;- 异常栈展开会析构。
但它并不是说任何宇宙级情况都能执行析构。例如:
- 调用
std::abort(); - 操作系统强制终止进程;
- 程序断电;
- 某些致命崩溃;
- 在异常展开期间另一个析构函数又抛出异常并导致终止。
因此,RAII 保证的是正常语言控制流和异常展开中的确定性清理。
析构函数通常不应抛出异常。
六、文件流本身就是 RAII 对象
课件展示了类似下面的代码:
void printFile() {
std::ifstream input;
input.open("hamlet.txt");
std::string line;
while (std::getline(input, line)) {
std::cout << line << std::endl;
}
input.close();
}
课件想指出的问题是:
- 获取和释放操作散落在普通代码中;
- 如果循环中抛出异常,
input.close()可能被跳过; - 对象先创建、后打开,中间存在未打开状态。
不过这里需要做一个准确性修正:
std::ifstream本身已经是 RAII 类型。
即使没有手动执行:
input.close();
当 input 析构时,它也会关闭文件。
所以这段代码不会因为异常而永久忘记关闭文件。
但是,我们仍然可以写得更符合 RAII 的设计:
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
void printFile() {
std::ifstream input{"hamlet.txt"};
if (!input) {
throw std::runtime_error("failed to open hamlet.txt");
}
std::string line;
while (std::getline(input, line)) {
std::cout << line << '\n';
}
}
int main() {
try {
printFile();
} catch (const std::exception& error) {
std::cerr << "Error: " << error.what() << '\n';
}
}
这里没有显式调用:
input.close();
执行过程为:
构造 input
↓
构造函数尝试打开文件
↓
循环读取
↓
函数正常结束或异常退出
↓
input 析构
↓
文件自动关闭
使用:
std::ifstream input{"hamlet.txt"};
比先默认构造再调用 open() 更容易保证对象创建后立即处于预期状态。
练习:是否必须调用 close()?
判断下面的程序是否会在异常发生时遗忘关闭文件:
void processFile() {
std::ifstream input{"data.txt"};
std::string line;
while (std::getline(input, line)) {
processLine(line);
}
}
假设 processLine() 可能抛出异常。
答案与解释
不会因为缺少显式 close() 而遗忘关闭文件。
input 是局部的 std::ifstream 对象。
当 processLine() 抛出异常时,程序离开 processFile(),栈展开会调用 input 的析构函数。析构函数负责关闭它持有的文件。
显式 close() 仍然有一种用途:希望在对象析构前提前释放文件资源。但对于普通的函数局部文件流,通常没有必要手动调用。
七、线程锁为什么尤其需要 RAII?
多个线程共同访问数据时,通常需要互斥锁(mutex)。
一种手动写法是:
database_lock.lock();
// 修改共享数据
database_lock.unlock();
lock() 与 unlock() 之间的区域称为临界区(critical section)。
lock()
↓
┌───────────────────────┐
│ 只有持有锁的线程能进入 │
│ 修改共享数据 │
└───────────────────────┘
↓
unlock()
如果临界区中抛出异常:
database_lock.lock();
modifyDatabase(); // 抛出异常
database_lock.unlock();
最后一行会被跳过。
锁将一直保持锁定状态,其他线程可能永远无法继续,程序出现死锁。
课件的原始代码中还存在变量名不一致的问题:获取的是 databaseLock,释放时却写成了另一个名称。正确的手动形式必须对同一个互斥锁调用 lock() 和 unlock()。
但即使名称正确,异常问题仍然存在。
7.1 使用 std::lock_guard
标准库提供了 RAII 锁管理器:
std::lock_guard<std::mutex>
它的行为是:
构造 lock_guard
↓
锁定 mutex
析构 lock_guard
↓
解锁 mutex
完整示例:
#include <iostream>
#include <map>
#include <mutex>
#include <stdexcept>
void cleanDatabase(
std::mutex& database_lock,
std::map<int, int>& database
) {
std::lock_guard<std::mutex> guard{database_lock};
database.clear();
throw std::runtime_error("database validation failed");
}
int main() {
std::mutex database_lock;
std::map<int, int> database{
{1, 10},
{2, 20}
};
try {
cleanDatabase(database_lock, database);
} catch (const std::exception& error) {
std::cout << "Caught: " << error.what() << '\n';
}
{
std::lock_guard<std::mutex> guard{database_lock};
std::cout << "The mutex can be locked again.\n";
}
}
输出:
Caught: database validation failed
The mutex can be locked again.
在 cleanDatabase() 中:
std::lock_guard<std::mutex> guard{database_lock};
创建 guard 时,database_lock 被锁定。
异常发生时,guard 离开作用域,它的析构函数解锁互斥锁。
不需要显式写:
database_lock.unlock();
7.2 RAII 负责清理,但不负责逻辑回滚
上面的程序中,异常发生前已经执行:
database.clear();
所以锁虽然被正确释放,数据库内容却已经被清空。
RAII 能保证:
锁会释放
文件会关闭
内存会回收
但不能自动保证:
业务数据恢复到操作前的状态
事务回滚是另一个设计问题。
这一区别很重要:
RAII 保证资源生命周期正确,不保证所有业务操作都具有原子性。
练习:下面哪段代码具有异常安全的解锁行为?
代码 A:
mutex.lock();
modify();
mutex.unlock();
代码 B:
std::lock_guard<std::mutex> guard{mutex};
modify();
假设 modify() 可能抛出异常。
答案与解释
代码 B 具有异常安全的解锁行为。
代码 A 中,如果 modify() 抛出异常:
mutex.unlock();
不会执行。
代码 B 中,异常会导致 guard 析构,析构函数负责解锁。
不过,代码 B 只保证锁被释放,不保证 modify() 已经完成的部分修改会被撤销。
八、把 RAII 应用于动态内存:智能指针
对于线程锁,我们使用:
RAII for locks
↓
std::lock_guard
对于动态内存,我们也可以创建一个管理对象:
RAII for heap memory
↓
智能指针
智能指针(smart pointer)不是“会思考的地址”。
它是一个普通 C++ 类,只不过这个类内部保存了指针,并在析构时按照规则释放动态对象。
智能指针对象
┌────────────────────────┐
│ 保存目标对象的地址 │
│ 记录所有权信息 │
│ 析构时执行相应清理 │
└────────────┬───────────┘
│
▼
动态创建的对象
因此,智能指针本质上是:
动态内存的 RAII 包装器。
使用智能指针需要:
#include <memory>
现代 C++ 主要提供三种相关类型:
std::unique_ptr<T>std::shared_ptr<T>std::weak_ptr<T>
它们最大的区别不是语法,而是所有权规则。
九、std::unique_ptr:只能有一个所有者
9.1 唯一所有权是什么意思?
std::unique_ptr<T> 表示:
当前动态对象只有一个负责销毁它的所有者。
关系可以画成:
unique_ptr
│
│ 唯一拥有
▼
T 对象
当这个 unique_ptr 被销毁时,它拥有的对象也会被销毁。
9.2 用 std::make_unique 创建对象
完整示例:
#include <iostream>
#include <memory>
class Node {
public:
explicit Node(int value)
: value_(value) {
std::cout << "Node constructed: "
<< value_ << '\n';
}
~Node() {
std::cout << "Node destroyed: "
<< value_ << '\n';
}
int value() const noexcept {
return value_;
}
private:
int value_;
};
int main() {
std::unique_ptr<Node> node =
std::make_unique<Node>(42);
std::cout << node->value() << '\n';
}
输出:
Node constructed: 42
42
Node destroyed: 42
执行过程:
std::make_unique<Node>(42)
↓
动态创建 Node
↓
构造 unique_ptr,令其拥有 Node
↓
通过 node->value() 使用对象
↓
main 结束
↓
node 析构
↓
自动 delete Node
不需要手动写:
delete node;
实际上,node 不是普通的 Node*,不能这样删除。
9.3 课件中直接使用 new 的写法
课件先展示了:
std::unique_ptr<Node> node{new Node};
这在语法上可以工作。
但是现代 C++ 更推荐:
std::unique_ptr<Node> node =
std::make_unique<Node>();
或者使用 auto:
auto node = std::make_unique<Node>();
原因包括:
- 不需要显式出现
new; - 所有权从创建时就清晰;
- 代码更短;
- 更容易保持异常安全;
- 不容易在复杂表达式中遗失裸指针;
- 与
std::make_shared的写法一致。
因此,除非有特殊需求,优先使用:
std::make_unique<T>(构造参数...)
9.4 unique_ptr 为什么不能复制?
下面的代码不能编译:
auto first = std::make_unique<Node>(7);
std::unique_ptr<Node> second = first;
原因不是编译器故意限制我们,而是复制会破坏“唯一所有权”的含义。
假设复制被允许:
first ────→ Node
second ───→ Node
现在两个指针都认为自己负责销毁同一个对象。
如果 first 先析构:
first 析构
↓
Node 被 delete
此时:
second ───→ 已经被释放的内存
second 变成悬空指针。
如果 second 最后再次删除对象,还会发生重复释放。
因此,unique_ptr 禁止复制。
9.5 唯一所有权可以移动
虽然不能复制,但可以移动所有权:
#include <iostream>
#include <memory>
#include <utility>
class Node {
public:
explicit Node(int value)
: value_(value) {
std::cout << "Node(" << value_
<< ") constructed\n";
}
~Node() {
std::cout << "Node(" << value_
<< ") destroyed\n";
}
int value() const noexcept {
return value_;
}
private:
int value_;
};
int main() {
std::unique_ptr<Node> first =
std::make_unique<Node>(7);
std::unique_ptr<Node> second =
std::move(first);
std::cout << std::boolalpha;
std::cout << "first is empty: "
<< (first == nullptr) << '\n';
std::cout << "second value: "
<< second->value() << '\n';
}
输出:
Node(7) constructed
first is empty: true
second value: 7
Node(7) destroyed
移动前:
first ───→ Node(7)
second 不存在
移动后:
first 为空
second ──→ Node(7)
Node 没有被复制,只是所有权从 first 转移到了 second。
9.6 unique_ptr 如何解决异常泄漏?
把之前不安全的函数改写为:
#include <iostream>
#include <memory>
#include <stdexcept>
class Resource {
public:
Resource() {
std::cout << "Resource acquired.\n";
}
~Resource() {
std::cout << "Resource released.\n";
}
};
void riskyOperation() {
throw std::runtime_error("operation failed");
}
void safeFunction() {
auto resource = std::make_unique<Resource>();
riskyOperation();
}
int main() {
try {
safeFunction();
} catch (const std::exception& error) {
std::cout << "Caught: "
<< error.what() << '\n';
}
}
输出:
Resource acquired.
Resource released.
Caught: operation failed
异常发生时:
riskyOperation() 抛出
↓
safeFunction() 开始栈展开
↓
resource 析构
↓
Resource 自动删除
↓
异常到达 catch
这就是动态内存上的 RAII。
9.7 unique_ptr 的常见接口
访问对象:
pointer->member();
解引用:
T& object = *pointer;
判断是否为空:
if (pointer) {
}
主动释放当前对象并变为空:
pointer.reset();
获取内部的裸指针,但不转移所有权:
T* raw = pointer.get();
通过 get() 获得的地址通常只是借用。
不能执行:
delete raw;
否则智能指针稍后还会再次删除它。
release() 会放弃所有权并返回裸指针:
T* raw = pointer.release();
使用后,调用者必须自己负责 delete。这会重新带回手动管理,因此通常不应随意使用。
练习:哪些 unique_ptr 操作能够编译?
auto a = std::make_unique<int>(10);
判断下面每一行是否合法:
auto b = a;
auto c = std::move(a);
std::cout << *c << '\n';
std::cout << *a << '\n';
答案与解释
下面的复制不合法:
auto b = a;
因为 unique_ptr 的复制构造函数被禁用。
下面的移动合法:
auto c = std::move(a);
移动后,c 获得对象所有权,a 变为空。
因此:
std::cout << *c << '\n';
合法并输出:
10
但是:
std::cout << *a << '\n';
虽然从纯语法角度看表达式可以通过编译,但运行时对空智能指针解引用属于错误行为,会导致未定义行为。
应当先判断:
if (a) {
std::cout << *a << '\n';
}
十、std::shared_ptr:多个对象共同拥有资源
10.1 为什么需要共享所有权?
有时一个动态对象确实需要被多个部分共同拥有。
例如:
窗口系统 ───┐
缓存系统 ───┼──→ 同一个大型资源对象
任务系统 ───┘
只要其中任意一个所有者仍在使用资源,资源就不能被销毁。
这时不能简单使用可复制的裸指针,因为我们不知道最后由谁执行 delete。
std::shared_ptr<T> 表示共享所有权。
10.2 shared_ptr 的引用计数
每个共享对象通常会关联一个控制块(control block)。
概念结构如下:
shared_ptr A ─────┐
│
shared_ptr B ─────┼────→ T 对象
│
shared_ptr C ─────┘
控制块
┌─────────────────────┐
│ 强引用计数:3 │
│ 弱引用相关信息 │
│ 删除器 │
│ 可能的分配器信息 │
└─────────────────────┘
复制 shared_ptr 时,强引用计数增加。
销毁一个 shared_ptr 时,强引用计数减少。
当强引用计数变成零时,目标对象被销毁。
10.3 一个完整的引用计数示例
#include <iostream>
#include <memory>
class Node {
public:
explicit Node(int value)
: value_(value) {
std::cout << "Node constructed.\n";
}
~Node() {
std::cout << "Node destroyed.\n";
}
private:
int value_;
};
int main() {
auto first = std::make_shared<Node>(42);
std::cout << "count: "
<< first.use_count() << '\n';
{
std::shared_ptr<Node> second = first;
std::cout << "count: "
<< first.use_count() << '\n';
}
std::cout << "count: "
<< first.use_count() << '\n';
}
输出:
Node constructed.
count: 1
count: 2
count: 1
Node destroyed.
状态变化:
创建 first
first ──→ Node
强引用计数 = 1
进入内部作用域并复制:
first ──┐
├──→ Node
second ──┘
强引用计数 = 2
内部作用域结束:
second 析构
first ──→ Node
强引用计数 = 1
main() 结束:
first 析构
强引用计数 = 0
Node 被销毁
10.4 对课件表述的准确修正
课件中有类似说法:
shared_ptr可以复制,并在底层内存离开作用域时析构。
更准确的说法是:
当最后一个拥有该对象的
shared_ptr被销毁或放弃所有权时,目标对象被销毁。
动态对象本身没有普通局部变量意义上的“离开作用域”。
真正离开作用域的是一个个 shared_ptr 管理对象。
10.5 使用 std::make_shared
课件展示了直接使用 new:
std::shared_ptr<T> pointer{new T};
现代 C++ 通常应写成:
auto pointer = std::make_shared<T>();
带构造参数时:
auto pointer =
std::make_shared<Node>(42);
make_shared 通常可以更高效地安排目标对象和控制块的内存,并且避免把刚刚创建的裸指针暴露在普通代码中。
10.6 不要从同一个裸指针创建两个独立的 shared_ptr
下面的代码非常危险:
Node* raw = new Node(42);
std::shared_ptr<Node> first{raw};
std::shared_ptr<Node> second{raw};
first 和 second 不知道彼此的存在。
它们会创建两个不同的控制块:
first → 控制块 A → raw
second → 控制块 B → raw
两个控制块都认为自己最终应该删除 raw。
结果会发生重复释放。
正确方式是先创建一个 shared_ptr,再复制它:
auto first = std::make_shared<Node>(42);
auto second = first;
现在二者共享同一个控制块。
10.7 什么时候不该使用 shared_ptr?
shared_ptr 不是“比 unique_ptr 更强的高级版本”。
共享所有权会带来:
- 控制块;
- 引用计数更新;
- 更复杂的生命周期;
- 循环引用风险;
- 更难回答“谁真正应该拥有对象”。
默认选择应当是:
只有一个所有者
↓
unique_ptr
只有当程序语义确实需要多个独立所有者时,才使用:
多个所有者共同延长生命周期
↓
shared_ptr
use_count() 适合学习和调试,但通常不应成为复杂业务逻辑的核心判断依据。
练习:预测引用计数
auto a = std::make_shared<int>(5);
std::shared_ptr<int> b = a;
{
std::shared_ptr<int> c = b;
std::cout << a.use_count() << '\n';
}
std::cout << a.use_count() << '\n';
b.reset();
std::cout << a.use_count() << '\n';
答案与解释
输出:
3
2
1
创建 a 后:
强引用计数 = 1
复制给 b:
强引用计数 = 2
内部作用域创建 c:
强引用计数 = 3
所以第一次输出 3。
内部作用域结束,c 析构:
强引用计数 = 2
所以第二次输出 2。
执行:
b.reset();
b 放弃所有权:
强引用计数 = 1
所以第三次输出 1。
十一、std::weak_ptr:观察对象,但不拥有对象
11.1 为什么还需要第三种指针?
假设某个组件希望查看一个共享对象,但不希望因为自己的存在而延长对象生命周期。
例如:
资源管理器真正拥有对象
监控界面只想观察对象
如果监控界面也保存 shared_ptr,它就成为了所有者。
即使资源管理器已经不再需要对象,只要监控界面的 shared_ptr 还存在,对象就不会销毁。
这时可以使用:
std::weak_ptr<T>
weak_ptr 表示:
我知道这个共享对象,但我不参与拥有它。
11.2 weak_ptr 不增加强引用计数
#include <iostream>
#include <memory>
int main() {
auto owner = std::make_shared<int>(42);
std::weak_ptr<int> observer = owner;
std::cout << owner.use_count() << '\n';
}
输出:
1
虽然 observer 指向同一共享对象,但它不增加强引用计数。
11.3 为什么不能直接解引用 weak_ptr?
观察对象可能已经被销毁。
因此,不能直接写:
*observer
也不能直接写:
observer->member()
必须先调用:
observer.lock()
lock() 会尝试创建一个临时的 shared_ptr。
如果对象仍然存在,得到有效的 shared_ptr。
如果对象已经销毁,得到空的 shared_ptr。
完整示例:
#include <iostream>
#include <memory>
int main() {
std::weak_ptr<int> observer;
{
auto owner = std::make_shared<int>(42);
observer = owner;
if (auto locked = observer.lock()) {
std::cout << "Value: "
<< *locked << '\n';
}
}
if (auto locked = observer.lock()) {
std::cout << *locked << '\n';
} else {
std::cout << "Object no longer exists.\n";
}
}
输出:
Value: 42
Object no longer exists.
在内部作用域中:
owner 存在
强引用计数 = 1
observer 只观察
内部作用域结束后:
owner 析构
强引用计数 = 0
int 对象销毁
observer 仍存在,但已过期
也可以检查:
observer.expired()
但通常直接使用 lock() 更安全,因为检查和获取在同一个操作中完成。
十二、循环引用:shared_ptr 无法独自解决的问题
12.1 两个对象互相拥有
考虑类 A 和类 B:
#include <iostream>
#include <memory>
class B;
class A {
public:
std::shared_ptr<B> ptr_to_b;
~A() {
std::cout << "A destroyed\n";
}
};
class B {
public:
std::shared_ptr<A> ptr_to_a;
~B() {
std::cout << "B destroyed\n";
}
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->ptr_to_b = b;
b->ptr_to_a = a;
}
程序结束时,没有析构输出。
为什么?
建立关系后:
局部变量 a ───────────→ A
│
│ shared_ptr
▼
局部变量 b ───────────→ B
│
│ shared_ptr
└──────────→ A
更清楚地画:
a ──→ A ──shared──→ B
↑ │
└──shared──────┘
此时:
A 的强引用计数 = 2
局部变量 a
B::ptr_to_a
B 的强引用计数 = 2
局部变量 b
A::ptr_to_b
main() 结束时,局部变量按相反顺序销毁。
销毁局部变量 b 后:
B 的强引用计数从 2 变成 1
因为 A 仍然拥有 B。
销毁局部变量 a 后:
A 的强引用计数从 2 变成 1
因为 B 仍然拥有 A。
最终:
A 等待 B 释放自己
B 等待 A 释放自己
引用计数永远无法变成零。
这就是循环引用(circular reference)。
12.2 用 weak_ptr 打破所有权环
如果设计上:
A真正拥有B;B只是需要找到A;B不应该负责延长A的生命周期;
那么可以把 B 中的指针改为 weak_ptr:
#include <iostream>
#include <memory>
class B;
class A {
public:
std::shared_ptr<B> ptr_to_b;
~A() {
std::cout << "A destroyed\n";
}
};
class B {
public:
std::weak_ptr<A> ptr_to_a;
~B() {
std::cout << "B destroyed\n";
}
void visitA() {
if (auto a = ptr_to_a.lock()) {
std::cout << "A is still alive.\n";
} else {
std::cout << "A no longer exists.\n";
}
}
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->ptr_to_b = b;
b->ptr_to_a = a;
b->visitA();
}
输出:
A is still alive.
A destroyed
B destroyed
现在关系为:
A ──shared──→ B
↑ │
└────weak─────┘
B 到 A 的边不增加强引用计数。
局部变量销毁时:
- 局部
b释放一份对B的所有权; - 局部
a释放最后一份对A的所有权; A析构;A::ptr_to_b析构,释放最后一份对B的所有权;B析构。
循环被打破。
12.3 所有权图的直觉
可以把对象关系画成一张图。
shared_ptr 是强所有权边:
A ──strong──→ B
只要强边仍然存在,目标对象就必须继续存活。
weak_ptr 是观察边:
B ──weak──→ A
观察边允许目标对象在没有强所有者时销毁。
理想情况下,强所有权关系不应该形成环。
如果业务关系天然存在环,就需要选择至少一条不代表所有权的边,将其改为弱引用。
练习:应该把哪条边改成 weak_ptr?
有两个对象:
Company
└── 拥有 Employee
Employee
└── 需要知道自己属于哪家 Company
目前两个方向都使用 shared_ptr:
class Company {
public:
std::shared_ptr<Employee> employee;
};
class Employee {
public:
std::shared_ptr<Company> company;
};
应当怎样修改?
答案与解释
通常可以让 Company 拥有 Employee:
class Company {
public:
std::shared_ptr<Employee> employee;
};
而 Employee 只观察所属公司:
class Employee {
public:
std::weak_ptr<Company> company;
};
关系变为:
Company ──shared──→ Employee
Company ←──weak──── Employee
公司决定员工对象的主要生命周期,员工不应仅因为保存了“所属公司”信息就阻止公司销毁。
实际项目中也可能采用其他所有权设计。关键不是机械规定哪个方向必须弱,而是先回答:
谁真正负责让谁继续存活?
十三、四种指针角色的完整对比
裸指针、unique_ptr、shared_ptr 和 weak_ptr 并不是互相替代的四个等级。
它们表达的是不同关系。
13.1 裸指针和引用:通常表示借用
void printPet(const Pet& pet);
这里的函数只借用 pet。
它不拥有对象,不负责删除对象。
裸指针也可以表示可空借用:
void inspectPet(const Pet* pet);
使用者必须保证目标对象在借用期间仍然存在。
13.2 unique_ptr:独占所有权
只有一个对象负责目标生命周期
适用于:
- 树节点拥有子节点;
- 一个管理器独占某项资源;
- 工厂函数返回新对象;
- 对象不能被复制,但可以转移所有权。
13.3 shared_ptr:共享所有权
多个独立对象都需要延长目标生命周期
适用于:
- 无法确定最后由哪个组件停止使用;
- 异步任务共享同一对象;
- 某些图结构中的共同所有节点。
不能只因为“以后可能需要复制”就使用 shared_ptr。
13.4 weak_ptr:观察共享对象
需要找到对象
但不负责让对象继续存活
适用于:
- 缓存观察者;
- 父子对象的反向引用;
- 打破
shared_ptr循环; - 事件订阅关系中的非拥有观察。
| 类型 | 是否拥有对象 | 能否复制 | 能否移动 | 目标何时销毁 |
|---|---|---|---|---|
裸指针 T* |
通常不明确 | 可以 | 可以 | 由其他所有者决定 |
unique_ptr<T> |
独占拥有 | 不可以 | 可以 | 唯一所有者销毁或重置时 |
shared_ptr<T> |
共享拥有 | 可以 | 可以 | 最后一个强所有者消失时 |
weak_ptr<T> |
不拥有 | 可以 | 可以 | 不影响目标销毁时间 |
选择时可以使用下面的顺序:
这个变量需要拥有对象吗?
│
├── 不需要
│ └── 引用、裸指针,或 weak_ptr
│
└── 需要
│
├── 是否只有一个所有者?
│ └── unique_ptr
│
└── 是否确实有多个共同所有者?
└── shared_ptr
十四、智能指针常见错误
14.1 默认使用 shared_ptr
错误直觉:
shared_ptr可以复制,所以最省事。
问题是它模糊了所有权,还可能产生循环引用。
更好的默认选择是:
std::unique_ptr<T>
只有确定需要共享所有权时再换成 shared_ptr。
14.2 对 unique_ptr 使用 delete
错误:
auto pointer = std::make_unique<int>(10);
delete pointer.get();
这会提前删除对象。
稍后 pointer 析构时,还会再次尝试删除同一地址。
14.3 使用移动后的 unique_ptr
auto first = std::make_unique<int>(10);
auto second = std::move(first);
std::cout << *first;
移动后 first 通常为空,不能解引用。
应当使用:
std::cout << *second;
14.4 把 weak_ptr 当作普通指针
错误:
std::weak_ptr<Node> observer = owner;
observer->work();
weak_ptr 没有这种直接访问方式。
正确:
if (auto node = observer.lock()) {
node->work();
}
14.5 让 shared_ptr 形成环
每个对象的引用计数都大于零,不代表程序中仍然有真正可访问它们的根所有者。
需要分析所有权图,并用 weak_ptr 表示非拥有关系。
十五、从一个源文件走向真正的 C++ 项目
到目前为止,示例通常只有一个文件:
main.cpp
编译命令是:
g++ -std=c++20 main.cpp -o main
其中:
g++ 编译器驱动命令
-std=c++20 使用 C++20 标准
main.cpp 源文件
-o main 将最终可执行文件命名为 main
运行:
./main
这里的:
./
表示当前目录。
15.1 源代码是怎样变成可执行文件的?
课件使用了下面的简化流程:
C++ 源代码
↓
编译器
↓
机器能够运行的程序
对于入门理解,这个流程足够。
更完整一些,可以写成:
源文件和头文件
↓
预处理
↓
翻译单元
↓
编译与汇编
↓
目标文件 .o
↓
链接
↓
可执行文件
假设项目结构为:
main.cpp
Pet.cpp
Pet.h
可以分开执行:
g++ -std=c++20 -c main.cpp -o main.o
g++ -std=c++20 -c Pet.cpp -o Pet.o
g++ main.o Pet.o -o pet_app
前两条命令只编译,不链接:
main.cpp → main.o
Pet.cpp → Pet.o
第三条命令链接:
main.o + Pet.o → pet_app
也可以让 g++ 一次完成:
g++ -std=c++20 main.cpp Pet.cpp -o pet_app
15.2 什么是翻译单元?
预处理器处理完 #include 后,一个 .cpp 文件及其展开的头文件内容形成一个翻译单元(translation unit)。
例如:
#include "Pet.h"
不是把 Pet.h 独立编译成程序。
它大致表示:
把 Pet.h 中的声明提供给当前 .cpp
每个 .cpp 通常独立编译成一个 .o 文件,最后由链接器组合。
15.3 编译错误和链接错误不同
编译错误
编译器无法理解某个翻译单元:
int value = ;
或者:
pet.unknownFunction();
链接错误
声明存在,所以每个 .cpp 都能编译,但最终找不到定义:
int calculate();
调用了:
calculate();
却没有任何 .cpp 提供:
int calculate() {
return 42;
}
常见错误信息包含:
undefined reference
这通常意味着:
- 忘记实现函数;
- 忘记把某个
.cpp加入编译命令; - 声明与定义的参数类型不一致;
- 命名空间不一致。
十六、为什么不能永远手写编译命令?
对于三个文件,可以写:
g++ -std=c++20 main.cpp Pet.cpp -o pet_app
但大型项目可能有成百上千个源文件。
课件用 TensorFlow Core 中大量 C++ 源文件作为规模示例。此时手写一条包含所有文件的命令会非常痛苦。
更重要的是,如果只修改了一个文件,我们通常只希望:
重新编译发生变化的部分
+
重新链接
而不是重新编译整个项目。
构建系统(build system)用于描述:
- 项目有哪些源文件;
- 哪些文件依赖哪些头文件;
- 使用哪个编译器;
- 使用哪些编译选项;
- 如何生成可执行文件;
- 哪些目标需要重新构建。
十七、Make 和 Makefile
make 是一个读取构建规则并执行命令的工具。
规则通常保存在:
Makefile
基本结构是:
目标: 依赖
生成目标所需的命令
注意:
命令行开头通常必须是真正的 Tab 字符,而不是若干空格。
17.1 课件中的简化 Makefile
课件展示了类似下面的文件:
CXX = g++
CXXFLAGS = -std=c++20
SRCS = $(wildcard *.cpp)
TARGET = main
all:
$(CXX) $(CXXFLAGS) $(SRCS) -o $(TARGET)
clean:
rm -f $(TARGET)
含义为:
CXX = g++
定义编译器变量。
CXXFLAGS = -std=c++20
定义编译选项。
SRCS = $(wildcard *.cpp)
找到当前目录下所有 .cpp 文件。
TARGET = main
最终可执行文件名为 main。
运行:
make
会执行:
g++ -std=c++20 所有cpp文件 -o main
运行:
make clean
会删除:
main
17.2 这个简化版本不能充分利用增量构建
课件同时强调 Make 能追踪发生变化的文件。
这个能力是 Make 的重要特性,但上面的简化 Makefile 并没有真正把每个 .cpp 分别编译成 .o 文件。
而且:
all:
没有对应的真实输出文件,也没有列出依赖,因此通常每次运行都会再次执行整条编译命令。
它适合作为最初的 Makefile 演示,但没有完整体现增量编译。
17.3 一个能够增量编译的 Makefile
假设项目包含:
main.cpp
Pet.cpp
Pet.h
可以写:
CXX := g++
CXXFLAGS := -std=c++20 -Wall -Wextra -Wpedantic
TARGET := pet_app
OBJS := main.o Pet.o
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CXX) $(OBJS) -o $(TARGET)
main.o: main.cpp Pet.h
$(CXX) $(CXXFLAGS) -c main.cpp -o main.o
Pet.o: Pet.cpp Pet.h
$(CXX) $(CXXFLAGS) -c Pet.cpp -o Pet.o
clean:
rm -f $(TARGET) $(OBJS)
构建关系为:
main.cpp + Pet.h
↓
main.o
Pet.cpp + Pet.h
↓
Pet.o
main.o + Pet.o
↓
pet_app
第一次运行:
make
会执行:
编译 main.cpp
编译 Pet.cpp
链接 pet_app
如果只修改 Pet.cpp:
Pet.cpp 比 Pet.o 新
Make 会:
重新生成 Pet.o
重新链接 pet_app
但不需要重新编译 main.cpp。
如果修改 Pet.h,由于两个 .o 都依赖它:
重新生成 main.o
重新生成 Pet.o
重新链接 pet_app
17.4 Makefile 中的目标与依赖
这条规则:
Pet.o: Pet.cpp Pet.h
$(CXX) $(CXXFLAGS) -c Pet.cpp -o Pet.o
表示:
目标:Pet.o
依赖:Pet.cpp 和 Pet.h
只要任意依赖比目标新,或者目标不存在,命令就需要执行。
这就是 Make 增量构建的核心直觉。
练习:修改文件后,Make 会重建什么?
使用上面的 Makefile。
情况一:只修改 main.cpp。
情况二:只修改 Pet.h。
情况三:只修改已经生成的可执行文件 pet_app,源文件都没变。
答案与解释
情况一:
重新生成 main.o
重新链接 pet_app
Pet.o 不需要重新生成。
情况二:
重新生成 main.o
重新生成 Pet.o
重新链接 pet_app
因为两个目标文件都声明依赖 Pet.h。
情况三:
源文件和目标文件都没有变旧,通常不需要重新编译或链接。
不过,实际判断取决于文件时间戳。如果手动修改可执行文件使其时间更新,Make 可能认为它比依赖更新,从而不重建它。
十八、CMake:生成构建系统
Makefile 能够精确控制构建,但大型跨平台项目直接维护 Makefile 会逐渐复杂。
CMake 是构建系统生成器(build system generator)。
它的关系可以理解为:
CMakeLists.txt
↓
CMake
↓
Makefile、Ninja 文件或其他构建系统文件
↓
真正执行编译
因此,CMake 通常不是直接代替编译器。
它帮助生成适合当前平台和构建工具的规则。
18.1 课件中的 CMakeLists.txt
课件展示了:
cmake_minimum_required(VERSION 3.10)
project(cs106l_classes)
set(CMAKE_CXX_STANDARD 20)
file(GLOB SRC_FILES "*.cpp")
add_executable(main ${SRC_FILES})
逐项解释如下。
cmake_minimum_required(VERSION 3.10)
要求 CMake 版本至少为 3.10。
project(cs106l_classes)
定义项目名称。
set(CMAKE_CXX_STANDARD 20)
要求使用 C++20 语言标准。
这里需要修正课件旁注中的一个说法:
这条命令不是把编译器设置成某个编译器,而是设置 C++ 语言标准版本。
编译器可能仍然是:
- GCC;
- Clang;
- MSVC;
- 其他受支持编译器。
file(GLOB SRC_FILES "*.cpp")
搜索当前目录中的 .cpp 文件并存入 SRC_FILES。
add_executable(main ${SRC_FILES})
使用这些源文件创建名为 main 的可执行目标。
18.2 file(GLOB) 的便利与限制
file(GLOB ...) 对教学小项目很方便:
file(GLOB SRC_FILES "*.cpp")
但大型项目通常更推荐明确列出源文件:
add_executable(main
main.cpp
Pet.cpp
)
原因是:
- 项目结构更明确;
- 新增或删除源文件时更容易审查;
- 某些情况下新增文件后,CMake 不一定自动重新配置;
- 不会意外把测试文件或临时文件加入正式目标。
因此,课件写法能够工作,但不是所有项目中的最佳实践。
18.3 一个更明确的 CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(pet_app LANGUAGES CXX)
add_executable(pet_app
main.cpp
Pet.cpp
)
target_compile_features(pet_app PRIVATE cxx_std_20)
target_compile_options(pet_app PRIVATE
-Wall
-Wextra
-Wpedantic
)
这里:
project(pet_app LANGUAGES CXX)
说明项目使用 C++。
add_executable(pet_app ...)
创建可执行目标。
target_compile_features(
pet_app
PRIVATE
cxx_std_20
)
要求 pet_app 使用 C++20。
PRIVATE 表示这个要求用于当前目标自身。
18.4 使用 CMake 的现代命令
假设项目根目录中有:
CMakeLists.txt
main.cpp
Pet.cpp
Pet.h
可以直接执行:
cmake -S . -B build
含义为:
-S . 源代码目录是当前目录
-B build 构建文件放入 build 目录
然后构建:
cmake --build build
运行:
./build/pet_app
项目结构变为:
project/
├── CMakeLists.txt
├── main.cpp
├── Pet.cpp
├── Pet.h
└── build/
├── CMakeCache.txt
├── 构建系统文件
├── 目标文件
└── pet_app
这种方式称为目录外构建(out-of-source build)。
生成文件集中在 build/ 中,不会污染源代码目录。
清理时可以直接删除:
rm -rf build
18.5 课件中的传统操作流程
课件给出的流程为:
mkdir build
cd build
cmake ..
make
./main
它同样表达了目录外构建。
其中:
cmake ..
表示:
使用上一级目录中的 CMakeLists.txt
如果 CMake 生成的是 Makefile,接着运行:
make
现代写法:
cmake --build build
不依赖底层一定是 Make,也适用于 CMake 生成的其他构建系统。
18.6 Make 与 CMake 的关系
g++
负责编译和链接
Makefile
描述目标、依赖和构建命令
make
读取 Makefile 并执行需要的命令
CMakeLists.txt
用更高层方式描述项目
CMake
根据 CMakeLists.txt 生成实际构建系统
可以画成:
CMakeLists.txt
↓
CMake
↓
Makefile
↓
make
↓
g++
↓
可执行文件
不过 CMake 也可以生成 Ninja 等其他构建系统,因此 Makefile 只是其中一种后端。
十九、综合练习:构建一个异常安全的宠物程序
现在把本节内容串起来。
目标是完成一个多文件程序:
pet_project/
├── Pet.h
├── Pet.cpp
├── main.cpp
├── pet.txt
├── Makefile
└── CMakeLists.txt
程序需要:
- 从文件中读取宠物类型、名字和姓氏;
- 使用
std::ifstream自动管理文件; - 使用
std::unique_ptr<Pet>表示唯一所有权; - 文件读取失败时抛出异常;
- 在
main()中捕获并报告异常; - 支持直接使用
g++、Make 和 CMake 构建。
输入文件格式为三行:
Dog
Fluffy
Wang
预期输出:
Fluffy Wang is paw-some!
Display name: Fluffy Wang
参考实现
Pet.h
#pragma once
#include <memory>
#include <string>
class Pet {
public:
Pet(const std::string& type,
const std::string& first_name,
const std::string& last_name);
const std::string& type() const noexcept;
const std::string& firstName() const noexcept;
const std::string& lastName() const noexcept;
private:
std::string type_;
std::string first_name_;
std::string last_name_;
};
std::unique_ptr<Pet> loadPet(
const std::string& filename
);
std::string displayName(const Pet& pet);
Pet.cpp
#include "Pet.h"
#include <fstream>
#include <iostream>
#include <stdexcept>
Pet::Pet(const std::string& type,
const std::string& first_name,
const std::string& last_name)
: type_(type),
first_name_(first_name),
last_name_(last_name) {}
const std::string& Pet::type() const noexcept {
return type_;
}
const std::string& Pet::firstName() const noexcept {
return first_name_;
}
const std::string& Pet::lastName() const noexcept {
return last_name_;
}
std::unique_ptr<Pet> loadPet(
const std::string& filename
) {
std::ifstream input{filename};
if (!input) {
throw std::runtime_error(
"cannot open file: " + filename
);
}
std::string type;
std::string first_name;
std::string last_name;
if (!std::getline(input, type) ||
!std::getline(input, first_name) ||
!std::getline(input, last_name)) {
throw std::runtime_error(
"pet file must contain at least three lines"
);
}
return std::make_unique<Pet>(
type,
first_name,
last_name
);
}
std::string displayName(const Pet& pet) {
if (pet.type() == "Dog" ||
pet.firstName() == "Fluffy") {
std::cout
<< pet.firstName()
<< ' '
<< pet.lastName()
<< " is paw-some!\n";
}
return pet.firstName()
+ " "
+ pet.lastName();
}
main.cpp
#include "Pet.h"
#include <exception>
#include <iostream>
#include <memory>
#include <string>
int main() {
try {
std::unique_ptr<Pet> pet =
loadPet("pet.txt");
const std::string name =
displayName(*pet);
std::cout
<< "Display name: "
<< name
<< '\n';
} catch (const std::exception& error) {
std::cerr
<< "Error: "
<< error.what()
<< '\n';
return 1;
}
return 0;
}
pet.txt
Dog
Fluffy
Wang
直接编译
g++ -std=c++20 -Wall -Wextra -Wpedantic \
main.cpp Pet.cpp -o pet_app
运行:
./pet_app
Makefile
CXX := g++
CXXFLAGS := -std=c++20 -Wall -Wextra -Wpedantic
TARGET := pet_app
OBJS := main.o Pet.o
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CXX) $(OBJS) -o $(TARGET)
main.o: main.cpp Pet.h
$(CXX) $(CXXFLAGS) -c main.cpp -o main.o
Pet.o: Pet.cpp Pet.h
$(CXX) $(CXXFLAGS) -c Pet.cpp -o Pet.o
clean:
rm -f $(TARGET) $(OBJS)
构建:
make
运行:
./pet_app
清理:
make clean
CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(pet_app LANGUAGES CXX)
add_executable(pet_app
main.cpp
Pet.cpp
)
target_compile_features(
pet_app
PRIVATE
cxx_std_20
)
target_compile_options(
pet_app
PRIVATE
-Wall
-Wextra
-Wpedantic
)
构建:
cmake -S . -B build
cmake --build build
运行:
./build/pet_app
二十、综合程序是怎样一步步执行的?
20.1 编译阶段
main.cpp 包含:
#include "Pet.h"
所以编译器知道:
Pet类有哪些成员函数;loadPet()的参数和返回类型;displayName()的参数和返回类型。
Pet.cpp 提供这些函数的定义。
编译阶段分别生成:
main.cpp → main.o
Pet.cpp → Pet.o
链接阶段组合:
main.o + Pet.o → pet_app
如果忘记把 Pet.cpp 加入构建,main.cpp 可能正常编译,但链接时会出现 undefined reference。
20.2 进入 main()
执行:
std::unique_ptr<Pet> pet =
loadPet("pet.txt");
此时先调用 loadPet()。
20.3 创建文件流
std::ifstream input{filename};
创建局部对象 input,并尝试打开文件。
如果打开失败:
if (!input) {
throw std::runtime_error(...);
}
异常离开 loadPet()。
input 是已经构造成功的局部对象,因此会析构并清理自身状态。
异常到达 main() 的:
catch (const std::exception& error)
20.4 读取三行数据
如果文件成功打开:
std::string type;
std::string first_name;
std::string last_name;
三个局部字符串被创建。
然后执行:
std::getline(input, type)
std::getline(input, first_name)
std::getline(input, last_name)
读取成功后:
type = "Dog"
first_name = "Fluffy"
last_name = "Wang"
如果任何一行缺失,程序抛出异常。
栈展开时:
last_name析构;first_name析构;type析构;input析构并关闭文件。
20.5 创建动态 Pet
return std::make_unique<Pet>(
type,
first_name,
last_name
);
make_unique:
- 为
Pet获取动态内存; - 调用
Pet构造函数; - 创建拥有该对象的
unique_ptr; - 将所有权返回给调用者。
如果 Pet 构造过程中抛出异常,make_unique 会负责释放刚获取的内存,不会留下裸指针泄漏。
20.6 loadPet() 返回
返回时,局部文件流 input 析构,文件关闭。
动态 Pet 不会跟着销毁,因为其所有权已经转移给 main() 中的:
pet
状态为:
main 中的 pet
│
▼
动态 Pet 对象
┌──────────────────┐
│ type_ = Dog │
│ first = Fluffy │
│ last = Wang │
└──────────────────┘
20.7 调用 displayName()
const std::string name =
displayName(*pet);
*pet 得到 Pet&。
参数类型为:
const Pet& pet
因此不会复制整个 Pet 对象。
函数判断:
pet.type() == "Dog"
结果为真。
由于 || 使用短路求值:
pet.firstName() == "Fluffy"
不需要继续用于条件判断。
函数输出:
Fluffy Wang is paw-some!
然后构造并返回:
Fluffy Wang
20.8 正常离开作用域
main() 的 try 块结束时:
pet
离开作用域。
unique_ptr 析构,并自动销毁它拥有的 Pet 对象。
不需要写:
delete pet;
20.9 如果 displayName() 抛出异常呢?
字符串拼接可能需要分配内存。
如果分配失败并抛出异常:
displayName() 异常退出
↓
main() 的 try 开始栈展开
↓
pet 析构
↓
动态 Pet 被删除
↓
异常到达 catch
这说明 RAII 不只保护“文件读取阶段”,还保护后续所有可能异常退出的路径。
二十一、综合练习的进一步思考
练习一:为什么 loadPet() 不返回裸指针?
可以写成:
Pet* loadPet(const std::string& filename);
并在里面:
return new Pet(...);
但这样调用者必须记住:
delete pet;
如果调用者忘记,或者在 delete 之前抛出异常,就会泄漏。
返回:
std::unique_ptr<Pet>
可以在类型层面表达:
调用者获得这个
Pet的唯一所有权。
练习二:如果两个系统需要共同拥有同一个 Pet 呢?
假设:
- 游戏场景系统需要使用宠物;
- 用户界面系统也需要独立持有宠物;
- 任意一方都可能比另一方活得更久。
这时可能需要:
std::shared_ptr<Pet>
工厂函数可以返回:
std::shared_ptr<Pet> loadPet(
const std::string& filename
);
并使用:
return std::make_shared<Pet>(
type,
first_name,
last_name
);
但只有在两个系统确实共同负责生命周期时才这样设计。
练习三:如果界面只观察宠物呢?
如果场景系统真正拥有宠物,而界面只是显示它的信息:
场景系统:shared_ptr<Pet>
界面系统:weak_ptr<Pet>
界面访问时:
if (auto pet = observed_pet.lock()) {
// 使用 pet
}
这样界面不会阻止宠物正常销毁。
二十二、本节课的完整知识串联
本节课从一个很短的函数开始。
肉眼看到的控制流只有:
if 真
if 假
return
但函数调用、对象构造和运算符都可能抛出异常,于是实际存在大量隐藏出口。
异常可以让函数中途退出
↓
末尾的手动清理可能被跳过
↓
new/delete、lock/unlock、open/close 都不可靠
为了解决这个问题,我们利用了 C++ 的对象生命周期:
局部对象离开作用域
↓
析构函数自动执行
于是产生 RAII:
构造对象
↓
获取资源
析构对象
↓
释放资源
标准库中的许多类型都采用了这个设计:
文件资源
↓
std::ifstream
互斥锁
↓
std::lock_guard
动态内存
↓
智能指针
动态内存中的所有权又分成三种主要关系:
独占所有权
↓
unique_ptr
共享所有权
↓
shared_ptr
非拥有观察
↓
weak_ptr
当代码逐渐拆成多个文件后:
.cpp 独立编译为 .o
多个 .o 链接为可执行文件
手动输入长编译命令不再现实,于是使用:
Makefile + make
进一步为了以更高层、跨平台的方式描述项目,可以使用:
CMakeLists.txt + CMake
整条逻辑链为:
异常带来隐藏退出路径
↓
手动清理不可靠
↓
RAII 绑定资源与对象生命周期
↓
智能指针管理动态对象所有权
↓
多文件项目需要自动化构建
↓
Make 和 CMake 管理工程结构
二十三、初学者最容易混淆的结论
RAII 不是垃圾回收
RAII 依赖确定的对象生命周期。
对象离开作用域时,析构函数立即执行。
垃圾回收通常由运行时在某个不确定时间寻找不再使用的对象。
智能指针不是普通指针的语法糖
它们编码了所有权规则。
unique_ptr:唯一所有权
shared_ptr:共享所有权
weak_ptr:不拥有
使用智能指针不等于所有代码都应当出现智能指针
函数临时使用对象时,常常应当接受:
const T&
或者可空借用:
const T*
不应该为了“现代 C++”而到处把参数改成 shared_ptr。
shared_ptr 的复制不是对象复制
auto second = first;
复制的是管理句柄。
目标对象仍然只有一个。
first ─┐
├──→ 同一个对象
second ─┘
RAII 不会自动撤销数据修改
它保证资源被释放,不保证业务逻辑回滚。
CMake 不等于编译器
CMake 生成构建系统。
真正编译代码的仍然是 GCC、Clang、MSVC 等编译器。
设置 C++20 不等于选择编译器
set(CMAKE_CXX_STANDARD 20)
表示要求 C++20,而不是指定必须使用 g++。
课件中的简单 Makefile不会自动实现最佳增量编译
要让 Make 精确追踪文件变化,需要描述目标文件和依赖关系。
二十四、本节知识如何连接到后续学习
这节课是课程中的最后一节必修讲座,课件接下来安排的是可选的 C++ Iceberg 内容。
“冰山”这个比喻很适合现在的阶段:
水面之上
├── 变量
├── 函数
├── 类
├── 容器
└── 基础模板
水面之下
├── 对象生命周期
├── 异常安全
├── 所有权
├── 并发同步
├── 编译与链接
├── 构建系统
├── ABI
└── 更复杂的语言规则
本节课已经让我们第一次看到这些内容彼此并不独立:
- 移动语义让
unique_ptr可以转移所有权; - 特殊成员函数决定资源管理类能否安全复制;
- 类型安全帮助表达“拥有、共享、观察”;
- RAII 让异常路径仍能正确清理;
- 构建系统把许多独立翻译单元组织成完整程序。
接下来学习更复杂的 C++ 时,可以始终用三个问题检查设计:
这个对象拥有谁?
谁负责释放资源?
异常发生时,对象仍然处于什么状态?
当这三个问题能够从类型和作用域中直接得到答案时,代码才真正开始具有现代 C++ 的结构。