目录
-
-
- [15.1 RAII](#15.1 RAII)
-
- [15.1.1 exceptions](#15.1.1 exceptions)
- [15.1.2 资源获取即初始化](#15.1.2 资源获取即初始化)
- [15.2 智能指针](#15.2 智能指针)
-
- [15.2.1 三种智能指针](#15.2.1 三种智能指针)
- [15.3 构建C++项目](#15.3 构建C++项目)
-
15.1 RAII
15.1.1 exceptions
exceptions(异常)是处理代码中出现的错误的一种方式。
exceptions会被 "抛出"。
不过,我们可以编写代码来处理异常,这样我们就能继续执行代码,而不一定会出错。


这段内容是对一段C++代码中可能抛出异常的代码路径(code paths)的分析,总共至少有23条可能抛出异常的路径,具体解析如下:
- **Pet的拷贝构造函数(1处)**:
函数参数Pet p采用值传递方式,会调用Pet的拷贝构造函数,此处可能抛出异常。
- **临时字符串构造(5处)**:
函数返回类型为std::string,过程中涉及临时字符串的构造,这些操作可能抛出异常(如内存分配失败)。
若p.type()、p.firstName()、p.lastName()的返回值是std::string,这些函数返回时会构造临时字符串(可能 3 处)。代码中"Dog"、"Fluffy"等字符串字面值,在与std::string进行比较(==)或拼接(+)时,会隐式转换为临时std::string对象(可能 2 处)。
- **成员函数调用(1+3+2处)**:
- 调用
p.type()(1处)可能抛出异常 - 调用
p.firstName()(3处)可能抛出异常 - 调用
p.lastName()(2处)可能抛出异常
- 调用
- **运算符重载(10处)**:
代码中p.firstName() + " " + p.lastName()使用了字符串拼接运算符,用户重载的这些运算符可能抛出异常。因为有10个符号,所以10处。
- **返回字符串的拷贝构造(1处)**:
返回std::string对象时,其拷贝构造函数可能抛出异常。
这些数字总和(1+5+6+10+1)提示了代码中存在多处可能引发异常的点,需要注意异常安全处理。

代码用 new Pet(petId) 动态分配了 Pet 对象(指针 p 指向它),但 delete p 的执行存在 "漏洞",导致内存无法回收:
- 若
Pet构造函数抛出异常(比如petId无效、内部初始化失败):此时new Pet(petId)会直接触发异常,后续的delete p根本不会执行,分配的内存永久 "丢失"。 - 若后续操作(如
p.type()、p.firstName())抛出异常:比如这些成员函数内部访问无效数据、动态内存分配失败(如返回std::string时内存不足),程序会直接跳转到异常处理流程,同样跳过delete p,导致内存泄漏。
只有当所有操作都正常执行到 delete p 时,内存才会回收 ------ 但只要中间任何一步抛异常,就会泄漏,风险极高。
不仅是指针很多资源都会在获取后释放。

我们如何保证当我们有异常时,资源会正确的释放呢?
15.1.2 资源获取即初始化
RAII(Resource Acquisition is Initialization) 是由 Bjarne Stroustrup(C++ 语言的创始人)提出的编程范式,其核心是将资源管理 与对象的生命周期强绑定,是 C++ 中管理资源(如内存、文件句柄、网络连接、锁等)的基石思想。
包括:
- 一个类所使用的所有资源都应该在构造函数中获取!
- 一个类所使用的所有资源都应该在析构函数中释放!
1、本质
RAII 的本质是 "资源获取即初始化,资源释放即销毁",具体通过两个关键步骤实现:
- 资源获取(Acquisition = Initialization): 类在构造函数 中完成资源的申请 / 获取(比如用
new分配内存、open()打开文件、lock()加锁)。 这确保了 "创建对象" 和 "获取资源" 是原子操作 ------ 只要对象创建成功,资源就一定已就绪,不会出现 "对象存在但资源未获取" 的中间状态。 - 资源释放(Release = Destruction): 类在析构函数 中完成资源的释放 / 清理(比如用
delete释放内存、close()关闭文件、unlock()解锁)。 由于 C++ 会自动调用对象的析构函数 (栈对象离开作用域时、堆对象被delete时),这意味着:资源会随着对象生命周期的结束自动释放 ,完全无需手动调用 "释放函数"(如free()、close())。
2、解决的核心问题:避免资源泄漏
手动管理资源时,很容易因代码逻辑漏洞导致资源泄漏(比如忘记释放内存、提前 return 跳过释放步骤、异常抛出打断释放流程)。而 RAII 通过 "自动析构" 从根本上规避了这些问题。
**示例对比(以动态内存为例)**:
- 手动管理(风险高):
cpp
void badExample() {
int* ptr = new int[10]; // 手动获取资源(内存)
if (someCondition) {
return; // 提前返回,跳过 delete,内存泄漏!
}
delete[] ptr; // 手动释放(若上面返回,此处不会执行)
}
- RAII 管理(安全): 用
std::vector(C++ 标准库中典型的 RAII 容器)封装内存:
cpp
void goodExample() {
std::vector<int> vec(10); // 构造函数中自动分配内存(获取资源)
if (someCondition) {
return; // 离开作用域,vec 析构函数自动释放内存,无泄漏
}// 无需手动释放,vec 生命周期结束时析构函数自动执行
}
3、典型应用场景
RAII 几乎覆盖所有 "需手动申请 / 释放" 的资源类型,常见场景包括:
- 动态内存(
std::unique_ptr/std::shared_ptr,替代裸指针new/delete); - 文件操作(自定义类在构造函数
open(),析构函数close()); - 线程锁(
std::lock_guard/std::unique_lock,自动加锁 / 解锁,避免死锁); - 网络连接、数据库连接等稀缺资源。
4、一些例子
文件打开与关闭,不符合RAII

这段代码在 RAII合规性方面存在问题:
资源管理方式不符合 RAII:
- 代码手动调用了
input.close()关闭文件流,这违背了 RAII 的核心思想 - RAII 要求资源(如文件句柄)应在对象构造时获取,在析构时自动释放
潜在的资源泄漏风险:
- 如果
while(getline(...))循环内部抛出异常,input.close()可能不会被执行 - 文件流资源可能无法正常释放
正确的 RAII 做法:
ifstream本身是符合 RAII 的类,其析构函数会自动关闭文件- 不需要手动调用
close(),应移除显式的input.close()语句 - 当
input对象超出作用域时,会自动调用析构函数释放文件资源
因此,这段代码不符合 RAII 规范,手动管理资源的方式增加了出错风险。
锁的打开与关闭,不符合RAII

如何修复?

核心优势在于lock_guard的 RAII 特性:无论函数正常返回还是因异常退出,只要lock_guard对象超出作用域,就会自动释放锁,避免死锁风险。
15.2 智能指针

避免显式调用 new 和 delete:
原因
new 返回的指针应当属于一个资源句柄(该句柄能够调用 delete)。如果 new 返回的指针被赋值给一个普通 / 裸指针,那么该对象可能会发生泄漏。
注意
在大型程序中,裸 delete(即应用程序代码中的 delete,而非专门用于资源管理的代码部分中的 delete)很可能是一个错误:如果你有 N 个 delete,你如何能确定不需要 N+1 个或 N-1 个呢?这个错误可能是潜在的:它可能只在维护期间才会显现。如果你有一个裸 new,你很可能在某个地方需要一个裸 delete,所以你很可能存在一个错误。
实施对任何显式使用 new 和 delete 的情况发出警告。建议改用 make_unique。
15.2.1 三种智能指针
有三种符合 RAII 规范的指针:
std::unique_ptr
唯一拥有其资源,不能被复制
std::shared_ptr
可以进行复制,当底层内存超出作用域时会被销毁
std::weak_ptr
一类旨在缓解循环依赖的指针
std::unique_ptr
cpp
// 普通指针不好
void rawptrFn(){
Node* n = new Node;
//do smth with n
delete n;
}
//////////////////////////////////////
// 用智能指针
void rawptrEn(){
std:unique_ptr<Node> n(new Node);
//do something with n
//n automatically freed
std:unique_ptr<Node> copy = n; // unique智能指针不能被复制!
}
为什么unique_ptr不能被复制?
这行代码尝试复制 一个unique_ptr,但unique_ptr故意禁用了拷贝构造函数(删除了拷贝操作),所以这会直接导致编译错误。
如果假设这段代码能编译通过(实际上不可能),会引发严重问题:
- 当
n的生命周期结束时,其析构函数会自动释放所指向的Node对象内存 - 此时
copy仍然指向已被释放的内存,成为悬空指针 - 当
copy的析构函数被调用时,会尝试再次释放已释放的内存,导致双重释放(double free)错误,这是 C++ 中常见的内存安全问题
std::shared_ptr
std::shared_ptr(共享指针)的核心工作原理:
- 解决
unique_ptr的拷贝限制 :std::unique_ptr是独占所有权的智能指针,无法被拷贝(只能移动),而shared_ptr允许拷贝,因为它采用了共享所有权模式。 - 内存释放机制 :
shared_ptr不会像unique_ptr那样在自身超出作用域时就释放底层内存,而是要等到所有指向该内存的 shared_ptr 都超出作用域时才会释放,避免了过早释放或重复释放的问题。

- 内部结构包含:
- 指向 T 的指针:实际指向管理的对象
- **控制块(Control Block)**:存储关键元数据
- **引用计数(Reference Count)**:记录当前有多少个
shared_ptr指向该对象 - **弱引用计数(Weak Count)**:记录关联的
weak_ptr数量 - 其他信息:如自定义删除器(Custom Deleter)、分配器(Allocator)等
- **引用计数(Reference Count)**:记录当前有多少个
工作流程简单来说:每次拷贝shared_ptr时,控制块中的引用计数加 1;当某个shared_ptr销毁时,引用计数减 1;当引用计数变为 0 时,才真正释放所管理的对象内存。
cpp
// 显式调用new,不对
std::unique_ptr<T> uniquePtr{new T};
std::shared_ptr<T> sharedPtr{new T};
std::weak_ptr<T> wp = sharedPtr;
////////////////////////////////////////
// 用make_unique
std::unique_ptr<T> uniquePtr = std::make_unique<T>();
std::shared_ptr<T> sharedPtr = std::make_shared<T>();
std::weak_ptr<T> wp = sharedPtr;
始终使用 std::make_unique<T>和 std::make_shared<T>
原因是:如果不这样做,我们将会进行两次内存分配,一次是为指针本身分配,另一次是为 new T 分配。
我们还应该保持一致性 ------ 如果你使用 make_unique,那么也请使用 make_shared!
std::weak_ptr
弱指针是一种避免代码中出现循环依赖的方法,这样我们就不会发生内存泄漏。
cpp
#include <iostream>
#include <memory>
class B;
class A {
public:
std::shared_ptr<B> ptr_to_b;
~A (){
std::cout << "A 的所有资源已释放" << std::endl;
}
};
class B {
public:
std::shared_ptr<A> ptr_to_a;
~B (){
std::cout << "B 的所有资源已释放" << std::endl;
}
};
int main () {
std::shared_ptr<A> shared_ptr_to_a = std::make_shared<A>();
std::shared_ptr<A>_shared_ptr_to_b = std::make_shared<B>();
a->ptr_to_b = shared_ptr_to_b;
b->ptr_to_a = shared_ptr_to_a;
return 0;
}
类 A 的实例 a 和类 B 的实例 b 都持有指向对方的共享指针。因此,它们永远不会被正确释放。
cpp
#include <iostream>
#include <memory>
class B;
class A {
public:
std::shared_ptr<B> ptr_to_b;
~A (){
std::cout << "A 的所有资源已释放" << std::endl;
}
};
class B {
public:
std::weak_ptr<A> ptr_to_a;
~B (){
std::cout << "B 的所有资源已释放" << std::endl;
}
};
int main () {
std::shared_ptr<A> shared_ptr_to_a = std::make_shared<A>();
std::shared_ptr<A>_shared_ptr_to_b = std::make_shared<B>();
a->ptr_to_b = shared_ptr_to_b;
b->ptr_to_a = shared_ptr_to_a;
return 0;
}
在这里,在 B 类中,我们不再将 a 存储为 shared_ptr,因此它不会增加 a 的引用计数。
因此,a 可以正常被释放,b 也因此可以被释放。
分步骤的详细解释:
一、前提:共享指针(std::shared_ptr)的核心逻辑
std::shared_ptr 是 C++ 用于共享对象所有权的智能指针,其核心是通过「引用计数」管理内存:
- 每个被
shared_ptr管理的对象,都关联一个「引用计数器」(记录当前有多少个shared_ptr指向它)。 - 当新增一个
shared_ptr指向对象时,引用计数 +1;当shared_ptr销毁,如出作用域时,引用计数-1。 - 只有当引用计数 减至 0 时,对象才会被自动销毁(调用析构函数),避免内存泄漏。
这种机制在「单向引用」场景下完美工作,但在「双向循环引用」时会彻底失效。
二、错误例子:循环依赖导致内存泄漏的根源
你的错误代码中,A 和 B 类的实例互相持有对方的 shared_ptr,形成了「循环引用」,具体过程如下:
- 执行流程
cpp
#include <iostream>
#include <memory>
// 前向声明:告诉编译器 B 是一个类(后续会定义)
class B;
class A {
public:
// A 持有指向 B 的 shared_ptr
std::shared_ptr<B> ptr_to_b;
~A() {
std::cout << "A 的所有资源已释放" << std::endl;
}
};
class B {
public:
// B 持有指向 A 的 shared_ptr
std::shared_ptr<A> ptr_to_a;
~B() {
std::cout << "B 的所有资源已释放" << std::endl;
}
};
int main() {
// 创建 A 和 B 的实例,用 shared_ptr 管理
std::shared_ptr<A> shared_ptr_to_a = std::make_shared<A>();
std::shared_ptr<B> shared_ptr_to_b = std::make_shared<B>();
// 互相赋值:形成循环引用
shared_ptr_to_a->ptr_to_b = shared_ptr_to_b; // A 的实例持有 B 的 shared_ptr
shared_ptr_to_b->ptr_to_a = shared_ptr_to_a; // B 的实例持有 A 的 shared_ptr
return 0; // main 函数结束,局部的 shared_ptr 开始销毁
}
- 循环引用导致的"引用计数僵局"
执行到 main 函数结束时,会发生以下关键步骤,最终导致内存泄漏:
步骤1:局部 shared_ptr销毁
main 中的 shared_ptr_to_a 和 shared_ptr_to_b 是局部变量,函数结束时会被销毁:
shared_ptr_to_a销毁:A对象的引用计数从 2(shared_ptr_to_a+B的ptr_to_a)减为 1。shared_ptr_to_b销毁:B对象的引用计数从 2(shared_ptr_to_b+A的ptr_to_b)减为 1。
步骤2:引用计数无法减至 0,对象永远不销毁
此时,A 对象的引用计数还剩 1(由 B 的 ptr_to_a 持有),B 对象的引用计数也剩 1(由 A 的 ptr_to_b 持有)。由于 A 和 B 互相"牵制",它们的 shared_ptr 永远不会被完全销毁,引用计数永远无法到 0------最终 A 和 B 的析构函数都不会执行(控制台不会输出释放信息),内存泄漏。
三、正确例子:用弱指针(std::weak_ptr)打破循环
弱指针(std::weak_ptr)是专门为解决 shared_ptr 循环依赖设计的智能指针,它有两个核心特性:
不参与引用计数 :weak_ptr 指向对象时,不会增加对象的引用计数(仅作为"观察者",不持有所有权)。
需要先转为 shared_ptr 才能访问对象:weak_ptr 本身不能直接解引用(避免访问已销毁的对象),必须通过 lock() 方法转为 shared_ptr(若对象已销毁,lock() 返回空的 shared_ptr)。
- 正确代码实现
将其中一方的 shared_ptr 改为 weak_ptr(如 B 类的 ptr_to_a),即可打破循环:
cpp
#include <iostream>
#include <memory>
class B; // 前向声明
class A {
public:
std::shared_ptr<B> ptr_to_b; // A 仍用 shared_ptr 持有 B
~A() {
std::cout << "A 的所有资源已释放" << std::endl;
}
};
class B {
public:
// 关键:B 用 weak_ptr 持有 A(不增加 A 的引用计数)
std::weak_ptr<A> ptr_to_a;
~B() {
std::cout << "B 的所有资源已释放" << std::endl;
}
};
int main() {
std::shared_ptr<A> shared_ptr_to_a = std::make_shared<A>();
std::shared_ptr<B> shared_ptr_to_b = std::make_shared<B>();
// 互相赋值:此时仅 A 持有 B 的 shared_ptr,B 持有 A 的 weak_ptr
shared_ptr_to_a->ptr_to_b = shared_ptr_to_b; // A 的引用计数:1(仅 shared_ptr_to_a)
shared_ptr_to_b->ptr_to_a = shared_ptr_to_a; // B 的引用计数:2(shared_ptr_to_b + A 的 ptr_to_b)
return 0; // main 结束,局部 shared_ptr 销毁
}
- 弱指针如何打破循环?执行流程解析
步骤1:局部 shared_ptr 销毁
shared_ptr_to_a销毁:A对象的引用计数从 1 减为 0 →A的析构函数执行(输出"A 的所有资源已释放")。A析构时,其成员ptr_to_b(指向B的shared_ptr)也会销毁 →B对象的引用计数从 2 减为 1。
步骤2: B 对象的引用计数最终减为 0 main 中的 shared_ptr_to_b 继续销毁 → B 对象的引用计数从 1 减为 0 → B 的析构函数执行(输出"B 的所有资源已释放")。
最终,A 和 B 都能正常销毁,无内存泄漏。
四、弱指针的其他注意事项
不能直接解引用 :weak_ptr 没有 operator* 和 operator->,必须通过 lock() 转为 shared_ptr 后才能访问对象,例如:
cpp
// 在 B 类中若要访问 A 的成员,需先 lock()
void B::accessA() {
// lock():若 A 未销毁,返回指向 A 的 shared_ptr;否则返回空
if (auto a_ptr = ptr_to_a.lock()) {
// 此时 a_ptr 是 shared_ptr<A>,可安全访问 A 的成员
// a_ptr->xxx;
} else {
std::cout << "A 对象已销毁,无法访问" << std::endl;
}
}
用途限制:弱指针仅用于"打破循环依赖"或"观察对象是否存活",不适合作为常规的对象持有方式(因为无法保证对象一定存活)。
总结
- 循环依赖的本质 :
shared_ptr互相持有导致引用计数无法减至 0,对象永远不销毁。 - 弱指针的作用:以"不持有所有权"的方式观察对象,不增加引用计数,从而打破循环。
- 核心原则 :在双向引用场景中,让一方用
shared_ptr持有(负责对象存活),另一方用weak_ptr观察(不影响存活),即可避免内存泄漏。
15.3 构建C++项目
先看:2.5 编译C++程序
make 是一个 "构建系统" 程序,能帮助你进行编译!
你可以指定想要使用的编译器
要使用 make,你需要有一个 Makefile

CMake 是一个构建系统生成器。
因此,你可以使用 CMake 来生成 Makefile 文件。
它就像是 Makefile 文件的一种更高级别的抽象。
cpp
cmake_minimum_required(VERSION 3.10)
project(cs106l_classes)
set(CMAKE_CXX_STANDARD 20) // 告诉cmake把编译器设置为C++20
file(GLOB SRC_FILES "*.cpp") // 这个 GLOB 命令是告诉 CMAKE 程序对所有符合 *.cpp 模式的文件进行通配符搜索。
add_executable(main ${SRC_FILES}) // 此命令会将我们程序的所有源文件添加到可执行文件中
1.你的项目根目录中需要有一个 CMakeLists.txt 文件
2.在你的项目中创建一个 build 文件夹(mkdir build)
3.进入 build 文件夹(cd build)
4.运行 cmake...
a. 该命令使用项目根目录中的 CMakeLists.txt 运行 cmake!
b. 这会生成一个 Makefile
5.运行 make
6.像往常一样使用./main 执行你的程序
RAII(资源获取即初始化)指出,动态分配的资源应该在构造函数中获取,在析构函数中释放。智能指针就是这样的例子。对于编译我们的项目,我们可以而且应该使用 Makefile。而为了生成 Makefile,我们可以而且应该使用 CMAKE。