RAII、智能指针与资源管理¶
难度:⭐~⭐⭐⭐ | 建议用时:2周 | 前置要求:现代类设计与特殊成员函数
前置自测¶
📋 答不出 ≥ 2 题 → 先回顾 现代类设计与特殊成员函数
- C++ 的六大特殊成员函数是哪些?声明析构函数会对移动操作产生什么影响?
- Rule of Zero 是什么意思?什么时候应该遵循它、什么时候不能?
= default和空函数体{}有什么区别?- 为什么
new出来的内存如果不delete,程序退出后操作系统虽然会回收内存,但这仍然是一个严重问题? - 什么是"异常安全"?如果一个函数在执行到一半时抛出异常,已分配的资源会怎样?
本章目标¶
学完本章,你将能够:
- 理解 RAII 的设计哲学——为什么 C++ 选择了"析构函数自动释放资源"而非 Java 的 GC 或 C 的手动管理
- 掌握
unique_ptr、shared_ptr、weak_ptr的所有权语义差异,能根据场景正确选择 - 在函数签名中表达清晰的所有权意图——调用者一看参数类型就知道"我是交出所有权还是共享所有权还是只是借用"
- 用 RAII 模式封装互斥锁、计时器、文件句柄等非内存资源
- 识别并避免智能指针的典型陷阱:循环引用、裸指针混用、
shared_ptr的原子操作开销
本章在课程中的位置:现代类设计与特殊成员函数 讲了特殊成员函数的自动生成规则——你已经知道声明析构函数会抑制移动操作。本章将解释**为什么你几乎永远不需要自定义析构函数**:因为智能指针替你管理了资源,编译器自动生成的析构函数(逐成员销毁)就是最优解。这正是 Rule of Zero 的核心前提——而 Rule of Zero 的前提,就是你会用智能指针。
知识树¶
RAII、智能指针与资源管理
├── 4.1 RAII 原则 ⭐⭐
│ ├── 确定性析构 vs GC
│ ├── 五种退出路径下的安全保证
│ └── 与 Java/Python/Rust 的对比
├── 4.2 std::unique_ptr ⭐⭐
│ ├── 零开销抽象
│ ├── make_unique 与异常安全
│ ├── 自定义删除器
│ └── PIMPL 中的不完整类型问题
├── 4.3 std::shared_ptr ⭐⭐
│ ├── 控制块与引用计数
│ ├── make_shared 的内存优化
│ └── 线程安全性(三层精确区分)
├── 4.4 std::weak_ptr ⭐⭐
│ ├── 打破循环引用
│ ├── 缓存和观察者模式
│ └── lock() 的原子安全性
├── 4.5 所有权转移模式与 API 设计 ⭐⭐
│ └── 五种参数传递模式
├── 4.6 RAII 封装模式 ⭐⭐
│ ├── lock_guard / unique_lock / scoped_lock
│ └── ScopedTimer
├── 4.7 enable_shared_from_this ⭐⭐⭐
├── 4.8 性能考量与选择指南 ⭐⭐⭐
├── 4.9 RAII 与线程、回调、订阅句柄 ⭐⭐⭐
├── 4.10 RAII 与事务性修改 ⭐⭐
└── 4.11 所有权图 ⭐⭐⭐
本章的核心线索是一个所有权决策链:需要动态分配吗? → 所有权是独占还是共享? → 独占用 unique_ptr,共享用 shared_ptr,观察用 weak_ptr,借用用裸引用。 掌握这个决策链后,你面对任何 SLAM 系统中的指针选择都能快速做出正确判断。
4.1 RAII原则:C++资源管理的哲学基石 ⭐⭐¶
动机:资源泄漏——长时间运行系统的隐形杀手¶
SLAM 系统、机械臂控制器、无人机飞控——它们有一个共同特点:必须 7×24 小时连续运行。一个每小时泄漏 1MB 内存的程序,一天泄漏 24MB,一周泄漏 168MB,一个月后进程被 OOM Killer 杀死。在自动驾驶场景中,这就是路上的定时炸弹。
但资源不仅仅是内存。一个 SLAM 系统同时管理着:
| 资源类型 | 获取操作 | 释放操作 | 泄漏后果 |
|---|---|---|---|
| 堆内存 | new / malloc |
delete / free |
内存耗尽,进程被杀 |
| 文件描述符 | open() / fopen() |
close() / fclose() |
超出系统限制(默认1024),无法打开新文件 |
| 互斥锁 | mutex.lock() |
mutex.unlock() |
死锁,系统永久卡住 |
| 网络套接字 | socket() + connect() |
close() |
端口耗尽,无法建立新连接 |
| GPU 显存 | cudaMalloc() |
cudaFree() |
GPU OOM,推理停止 |
| 数据库连接 | PQconnectdb() |
PQfinish() |
连接池耗尽,后续查询失败 |
在 C 语言中,程序员必须手动配对每一对获取/释放操作。问题在于:人会犯错,而且异常路径让手动管理变成噩梦。
如果不用 RAII 会怎样:C 风格的资源管理¶
考虑一个简单的场景:读取点云文件,处理数据,写入结果。
// C 风格资源管理——手动配对 acquire/release
void processPointCloud(const char* input, const char* output) {
FILE* fin = fopen(input, "rb");
if (!fin) return; // 错误路径1:直接返回(OK,没有资源泄漏)
float* buffer = (float*)malloc(1000000 * sizeof(float));
if (!buffer) {
fclose(fin); // 错误路径2:必须释放 fin
return;
}
FILE* fout = fopen(output, "wb");
if (!fout) {
free(buffer); // 错误路径3:必须释放 buffer 和 fin
fclose(fin);
return;
}
size_t n = fread(buffer, sizeof(float), 1000000, fin);
// 如果这里抛出异常?buffer、fin、fout 全部泄漏!
processData(buffer, n);
fwrite(buffer, sizeof(float), n, fout);
fclose(fout);
free(buffer);
fclose(fin);
}
这段代码有三个严重问题:
问题一:错误路径的组合爆炸。 每多获取一个资源,错误处理的分支就翻倍。上面只有 3 个资源就已经需要小心翼翼地在每个 return 前释放之前获取的所有资源。实际 SLAM 代码中,一个函数可能获取 5-10 个资源——手动管理几乎不可能不出错。
问题二:异常安全性为零。 如果 processData() 抛出异常(比如检测到数据损坏),控制流直接跳出函数,fclose 和 free 永远不会执行。C 语言没有异常所以可以忽略这个问题,但 C++ 中异常无处不在——new 可能抛 std::bad_alloc,标准容器操作可能抛异常,甚至你调用的第三方库也可能抛异常。
问题三:代码修改时的脆弱性。 三个月后,另一个开发者在函数中间加了一个提前返回 if (n == 0) return;——忘了释放资源。代码 review 时极易漏掉。
RAII 的核心思想¶
RAII 的全称是 Resource Acquisition Is Initialization(资源获取即初始化)。这个概念由 Bjarne Stroustrup 在 1984-1989 年间开发,最初是为了解决 C++ 异常处理引入后的资源安全问题。Andrew Koenig 为这一技术起了 "RAII" 这个名字。Stroustrup 在 The C++ Programming Language 第二版(1991)中已经描述了这一技术,并在 1994 年的 The Design and Evolution of C++ 中进一步阐述了其设计动机。
名字本身常被批评为误导性的——重点不是"获取时初始化",而是**"离开作用域时析构函数自动释放"**。有人建议用 SBRM(Scope-Bound Resource Management,作用域绑定资源管理)这个更准确的名字,但 RAII 已经约定俗成了。Stroustrup 本人也承认这个名字"不够好"——它强调了获取阶段,而真正关键的是释放阶段。
RAII 的核心契约只有两条:
- 构造函数获取资源(打开文件、分配内存、加锁)。如果获取失败,构造函数抛出异常——对象从未存在过,不需要清理。
- 析构函数释放资源(关闭文件、释放内存、解锁)。析构函数由编译器在对象离开作用域时**自动调用**——无论是正常退出、提前
return、还是异常传播。
"自动调用"是关键。 C++ 标准保证:当控制流离开一个作用域时(无论原因),该作用域内所有局部对象的析构函数按构造的逆序被调用。这个保证叫做**栈展开(stack unwinding)**,它把资源管理从程序员的"记忆力"转移到了编译器的"确定性"。
这里的"无论原因"值得展开理解,因为初学者往往只想到"函数正常返回"这一种退出方式。实际上,C++ 中离开作用域的方式至少有五种:
- 正常执行到作用域末尾——最平凡的情况,代码从
{跑到}。 - 提前
return——函数中间return直接退出。在 C 风格代码中,每个return前都要手动释放之前获取的资源;RAII 不需要,析构函数自动处理。 break/continue跳出循环——控制流跳出当前循环体的作用域,循环体内的 RAII 对象照常析构。goto跳过——虽然现代 C++ 极少使用goto,但即使使用了,从goto目标点到作用域末尾之间的 RAII 对象仍然正确析构。- 异常抛出——这是 RAII 最核心的价值所在。异常可能从任何一行代码抛出(
new可能抛std::bad_alloc、标准容器操作可能抛异常、第三方库调用可能抛异常),控制流直接跳到最近的catch块。在这个过程中,编译器保证沿途所有作用域内的 RAII 对象按逆序析构——这就是栈展开的全部意义。
如果没有 RAII,程序员必须在每一种退出路径上都手动写释放代码。一个有 3 个资源和 4 个可能的退出点的函数,理论上需要 12 处释放调用才能覆盖所有路径——遗漏任何一处就是资源泄漏。RAII 把这 12 处简化为 0 处:析构函数自动覆盖所有路径。
本质洞察:RAII 的核心价值不是"用类包装资源",而是把资源管理从**程序员的执行路径记忆**转移到**编译器的作用域规则**。C 程序员需要在每一条控制流路径上手动释放资源——正常返回、错误返回、嵌套错误返回……路径越多越容易遗漏。RAII 把这个 O(路径数) 的心智负担降为 O(1):你只需要在构造时获取资源,编译器保证在所有路径上自动释放。这就是为什么 C++ 虽然没有 GC,但内存泄漏率反而可以低于带 GC 的语言——前提是你正确使用 RAII。
用 RAII 重写上面的例子:
// RAII 风格——资源管理由对象的生命周期保证
void processPointCloud(const std::string& input, const std::string& output) {
std::ifstream fin(input, std::ios::binary); // 构造 = 打开文件
if (!fin) return;
std::vector<float> buffer(1000000); // 构造 = 分配内存
std::ofstream fout(output, std::ios::binary); // 构造 = 打开输出文件
if (!fout) return; // buffer 和 fin 自动析构
fin.read(reinterpret_cast<char*>(buffer.data()), buffer.size() * sizeof(float));
processData(buffer);
fout.write(reinterpret_cast<const char*>(buffer.data()), buffer.size() * sizeof(float));
// 函数结束:fout、buffer、fin 按逆序自动析构
// 即使 processData 抛异常,三者仍然自动析构——零泄漏
}
注意这段代码中**没有任何显式的释放操作**。文件在 ifstream/ofstream 析构时自动关闭,内存在 vector 析构时自动释放。无论控制流怎么走——正常返回、提前 return、异常抛出——资源都会被正确释放。
与其他语言资源管理策略的对比 ⭐⭐¶
理解 RAII 的独特性,需要和其他语言的策略做对比:
| 特性 | C++ RAII | Java try-with-resources | Python with/上下文管理器 |
Rust 所有权系统 |
|---|---|---|---|---|
| 确定性析构 | ✅ 离开作用域立即调用 | 仅在 try 块内 |
仅在 with 块内 |
✅ 离开作用域立即 Drop |
| 自动应用 | ✅ 所有对象,始终生效 | ❌ 必须显式写 try() |
❌ 必须显式写 with |
✅ 所有值,始终生效 |
| 可组合/嵌套 | ✅ 栈展开处理嵌套 | 需要嵌套 try 块 |
需要嵌套 with 或 ExitStack |
✅ drop 顺序为声明的逆序 |
| GC 交互 | 无 GC | GC 可能延迟非 try 中的释放 | GC 非确定性,__del__ 不可靠 |
无 GC |
| 资源泄漏风险 | 低——自动 | 中等——必须记住用 try | 中等——必须记住用 with | 极低——编译器强制 |
关键区别:C++ RAII 和 Rust 提供的是**自动的、普遍的**确定性析构——你不需要做任何特殊的事,只要对象离开作用域,资源就被释放。Java 和 Python 提供的是**可选的、需要显式使用的**作用域清理——忘了写 try-with-resources 或 with 就泄漏。
GC 的非确定性在机器人系统中的致命性。 Java 的 GC 暂停(G1 收集器平均 10-50ms,最坏可达 200ms+)在机器人控制中是不可接受的。一个 1kHz 的伺服控制回路每个周期只有 1ms——GC 暂停 200ms 意味着 200 个控制周期被跳过,机械臂可能撞上障碍物。这就是为什么实时机器人系统几乎全部使用 C++(确定性析构、零 GC 开销)而非 Java/Python。
Rust 的所有权模型与 RAII 的关系。 Rust 的 Drop trait 本质上就是 RAII——离开作用域时自动调用 drop() 释放资源。Rust 比 C++ 更进一步的是:编译器在编译期就检查所有权的合法性,而 C++ 把这个责任交给了程序员(虽然智能指针大大简化了这个工作)。如果你未来学 Rust,会发现 RAII 的概念可以无缝迁移。
RAII 在机器人系统中的关键应用场景 ⭐⭐¶
在机器人软件栈中,RAII 的价值远超"防止内存泄漏"。以下是四个最典型的应用场景:
场景一:传感器驱动的安全打开/关闭。LiDAR 传感器驱动通常需要 open() 初始化硬件和 close() 停止扫描。如果驱动程序因异常退出而没有调用 close(),传感器可能持续旋转并发送数据——在工业现场这是安全隐患。用 RAII 封装驱动,析构函数中自动调用 close(),无论退出路径如何。
场景二:控制权限的临时提升。某些操作需要临时提升实时优先级(如使用 sched_setscheduler)。操作完成后必须恢复原优先级——否则后续的非关键线程可能以实时优先级运行,饿死其他进程。RAII 封装保证优先级恢复。
场景三:ROS2 生命周期节点的状态管理。ROS2 的 lifecycle node 有明确的状态转换(configure → activate → deactivate → cleanup)。每个状态转换可能获取或释放资源。RAII 确保即使状态转换回调抛异常,已获取的资源也能被正确释放。
场景四:GPU 内存和 CUDA 上下文管理。在使用 GPU 进行点云处理(如 CUDA 加速的 ICP)时,CUDA 内存分配和上下文切换都需要配对释放。RAII 封装避免了 GPU 内存泄漏——在嵌入式 GPU(如 Jetson)上,显存只有 4-8GB,泄漏几个缓冲区就可能导致 OOM。
类比:RAII 在机器人系统中的角色,类似于安全工程中的"失效安全"(fail-safe)设计。核电站的安全系统设计为"断电时阀门自动关闭"——不需要额外的动力来执行安全操作。RAII 的析构函数就是这个"断电自动关闭"机制——不需要额外的代码路径来释放资源,编译器保证在所有退出路径上执行清理。
RAII 的历史脉络 ⭐⭐⭐¶
RAII 并非凭空出现。它的诞生与 C++ 异常处理机制的引入密切相关。
1979-1983 年,Stroustrup 在贝尔实验室开发 "C with Classes"(C++ 的前身)时,引入了构造函数和析构函数的概念。最初的动机很简单:确保对象在使用前被正确初始化。但 Stroustrup 很快意识到,如果构造函数获取资源、析构函数释放资源,那么 C++ 的作用域规则就自动保证了资源的正确管理。
1989-1990 年,C++ 引入异常处理(try/catch/throw)时,RAII 的价值才真正显现。异常让控制流变得不可预测——任何一行代码都可能跳转到 catch 块。手动的 acquire-use-release 模式在异常面前完全崩溃。但 RAII 对象不受影响:编译器保证栈展开时调用析构函数,无论异常从哪里抛出。
⚠️ 常见陷阱¶
⚠️ 概念误区:认为 RAII 只是"用类包装资源"
新手想法:"RAII 就是把
new/delete放进构造/析构函数呗。"实际上:RAII 是一种**所有权模型**。仅仅把资源放进类并不够——你还必须正确处理拷贝和移动语义(现代类设计与特殊成员函数 的 Rule of Five),否则浅拷贝会导致 double-free。现代 C++ 的最佳实践是:不要自己写 RAII 类管理原始资源——用标准库提供的 RAII 类(
unique_ptr、shared_ptr、lock_guard、fstream),让你的类遵循 Rule of Zero。正确理解:RAII = 资源的生命周期绑定到对象的生命周期,对象的生命周期由 C++ 的作用域规则确定性地管理。
💡 概念误区:认为"析构函数一定会被调用"
新手想法:"RAII 保证析构函数永远被调用,所以我的资源永远安全。"
有几个例外:(1) 调用
std::abort()或std::_Exit()会跳过所有析构函数;(2) 在析构函数中抛出异常(如果同时正在栈展开)会调用std::terminate(),后续对象的析构函数不会执行;(3) 通过longjmp跳出作用域不会调用析构函数(这就是为什么 C++ 中禁止混用setjmp/longjmp和 RAII 对象)。正确理解:RAII 在正常执行和异常传播的情况下保证析构函数调用。程序异常终止(abort、terminate)时不保证。这几乎覆盖了所有正常场景——但设计信号处理函数时要注意。
练习¶
-
分析题:下面的 C 代码有几条资源泄漏路径?用 RAII 重写后,还需要显式写几个释放操作?
-
思考题:
std::scope_exit源自 Library Fundamentals TS v3,直到 C++26 才正式纳入标准——为什么这么晚?提示:考虑std::unique_ptr+ 自定义删除器是否已经覆盖了大部分需求,以及委员会为什么对"在析构函数中执行任意代码"持谨慎态度。(截至 2026 年,主流编译器对<scope>的支持仍在推进中,工程中需按编译器版本确认可用性。) -
实践题:在你的系统上运行一个循环
new int[1000]不delete的程序,用htop或top观察内存增长速度。计算泄漏 1GB 需要多长时间。然后用std::vector<int>(1000)替换,确认内存稳定。
4.2 std::unique_ptr:独占所有权 ⭐⭐¶
动机:谁负责释放这个对象?¶
在没有智能指针的 C++ 代码中,一个裸指针 T* 传递的信息极其模糊:
- 这个指针指向的对象是谁分配的?调用者还是被调用者?
- 谁负责释放?函数执行完后调用者释放,还是函数内部释放?
- 这个指针可能是
nullptr吗? - 这个指针指向单个对象还是数组?
**所有权的不明确**是 C/C++ bug 的第一大来源。ORB-SLAM3(2021)的整个代码库使用裸指针管理 MapPoint 和 KeyFrame——Atlas.h 中存储的是 std::set<Map*>,MapPoint.h 中引用 KeyFrame 的类型是 KeyFrame*。这种设计意味着所有权完全依赖文档和开发者约定,编译器无法提供任何检查,社区也确实报告了多个内存相关的 issue。
std::unique_ptr 的存在意义不仅仅是"自动 delete"——它是一种**类型级别的所有权声明**。当你看到 std::unique_ptr<Sensor> 时,类型本身就告诉你:这个指针拥有(owns)它指向的对象,且同一时刻只有一个 unique_ptr 拥有它。所有权信息从"程序员脑子里的约定"变成了"编译器可以检查的类型约束"。
如果不用 unique_ptr 会怎样¶
考虑 g2o 优化器中添加顶点的场景。当前 g2o 的 API 使用裸指针:
// g2o 当前 API(裸指针)——所有权隐式转移
auto* v = new g2o::VertexSE3Expmap();
v->setEstimate(pose);
optimizer.addVertex(v);
// 问题:v 现在归谁管?文档说 optimizer "takes ownership"——
// 但类型系统无法表达这一点。如果你在后面 delete v,double-free。
// 如果 addVertex 失败返回 false 呢?v 泄漏了吗?你得读源码才知道。
g2o 内部通过一个编译时标志 G2O_DELETE_IMPLICITLY_OWNED_OBJECTS(默认开启)来决定析构时是否 delete 所有顶点和边。这种"convention-based ownership"是 C++11 之前的典型模式——可以工作,但脆弱。GitHub issue #184 记录了社区关于将 API 现代化为 unique_ptr 的讨论。
如果 g2o 使用 unique_ptr,API 就变成自文档化的:
// 假设的现代 API(unique_ptr)——所有权显式转移
auto v = std::make_unique<g2o::VertexSE3Expmap>();
v->setEstimate(pose);
optimizer.addVertex(std::move(v));
// 一切清晰:
// 1. make_unique 创建了对象,v 拥有所有权
// 2. std::move(v) 把所有权转移给 optimizer
// 3. 转移后 v == nullptr,我无法(也不应该)再使用它
// 4. 如果 addVertex 前抛异常,v 的析构函数自动 delete 对象
std::move 的存在要求程序员**显式地交出所有权**——这不是麻烦,而是安全网。你不可能"不小心"把所有权转走。
Kimera-VIO(MIT SPARK 实验室)展示了现代 SLAM 代码应该怎么写——它通过 KIMERA_POINTER_TYPEDEFS 宏为每个类自动生成六种智能指针类型别名(Ptr、ConstPtr、UniquePtr、ConstUniquePtr、WeakPtr、WeakConstPtr),在 Pipeline.h 中用 unique_ptr 管理所有独占的模块实例(VIO 前端、后端、网格化器、回环检测),形成了清晰的所有权层次。
unique_ptr 的内部实现:零开销抽象 ⭐⭐¶
unique_ptr 是 C++ "零开销抽象"(zero-overhead abstraction)哲学的典范。它的内部实现极其简单——本质上就是一个包装了裸指针的类,再加上析构函数中的 delete 调用。
在使用默认删除器 std::default_delete<T> 的情况下,unique_ptr<T> 的大小**等于一个裸指针**(通常 8 字节,64 位系统)。这是因为 std::default_delete 是一个空类(没有数据成员),通过空基类优化(EBO, Empty Base Optimization),它不占用任何空间。编译器会将 unique_ptr 的所有操作(解引用、比较、移动)内联优化到与裸指针完全相同的机器码。使用 unique_ptr 相比裸指针,运行时没有任何额外开销——不多一次函数调用、不多一字节内存、不多一个 CPU 周期。
这个零开销的保证之所以成立,关键在于 unique_ptr 禁止了拷贝。不需要引用计数,不需要控制块,不需要原子操作——只有一个指针和一个析构函数。
有一个微妙的例外值得了解:当 unique_ptr 跨越函数调用边界(ABI 边界)传递时,由于它是非平凡析构的类型,调用约定可能要求通过内存(栈)传递而非寄存器。这比传递裸指针(直接走寄存器)多几条指令。但这只在微基准测试中可测量,实际工程中几乎不可感知。
// unique_ptr 的简化实现(展示核心机制)
template <typename T>
class SimpleUniquePtr {
T* ptr_;
public:
explicit SimpleUniquePtr(T* p = nullptr) : ptr_(p) {}
~SimpleUniquePtr() { delete ptr_; } // 析构 = 释放
SimpleUniquePtr(const SimpleUniquePtr&) = delete; // 禁止拷贝
SimpleUniquePtr& operator=(const SimpleUniquePtr&) = delete; // 禁止拷贝赋值
SimpleUniquePtr(SimpleUniquePtr&& other) noexcept // 移动构造 = 转移所有权
: ptr_(other.ptr_) { other.ptr_ = nullptr; }
SimpleUniquePtr& operator=(SimpleUniquePtr&& other) noexcept { // 移动赋值
if (this != &other) {
delete ptr_;
ptr_ = other.ptr_;
other.ptr_ = nullptr;
}
return *this;
}
T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
T* get() const { return ptr_; }
explicit operator bool() const { return ptr_ != nullptr; }
};
注意移动构造函数中的关键步骤:other.ptr_ = nullptr。这不是可选的——如果不把源对象的指针置空,当源对象析构时会 delete 同一块内存,导致 double-free。这和 现代类设计与特殊成员函数 中讲的移动语义"偷走资源、置空源对象"的模式完全一致。
make_unique:为什么不用 new ⭐⭐¶
C++14 引入了 std::make_unique(C++11 中没有,需要手动写 unique_ptr<T>(new T(args))),它应该成为你创建 unique_ptr 的默认方式。原因有二:
原因一:异常安全。 考虑这个函数调用:
C++17 之前,标准允许编译器以任意顺序执行函数参数的子表达式。一种可能的执行顺序是:(1) new A,(2) new B,(3) 构造 unique_ptr<A>,(4) 构造 unique_ptr<B>。如果步骤 (2) 抛出异常,A 的内存已经分配但 unique_ptr<A> 尚未构造来接管它——内存泄漏。
make_unique 消除了这个问题:
每个 make_unique 是一个不可分割的操作——分配内存和构造 unique_ptr 在同一个函数调用中完成。
注:C++17 修改了函数参数的求值规则,使得上述交错求值不再合法。但 make_unique 仍然是更好的实践——代码可能需要在 C++14 模式下编译,且 make_unique 语义更清晰(不出现 new 关键字),符合 C++ Core Guidelines R.11 "避免显式 new 和 delete"。
原因二:DRY(Don't Repeat Yourself)。 std::unique_ptr<VeryLongTypeName>(new VeryLongTypeName(args)) 重复了类型名。std::make_unique<VeryLongTypeName>(args) 只写一次。
自定义删除器:管理非 new 分配的资源 ⭐⭐⭐¶
默认情况下,unique_ptr 用 delete(或 delete[])释放资源。但很多资源不是用 new 分配的——CUDA 显存用 cudaMalloc 分配、cudaFree 释放;C 库的 FILE 指针用 fopen 打开、fclose 关闭。自定义删除器让 unique_ptr 能管理任意类型的资源。
与 shared_ptr 的关键区别:unique_ptr 的删除器是**类型的一部分**——unique_ptr<T, DeleterA> 和 unique_ptr<T, DeleterB> 是不同的类型。而 shared_ptr 的删除器是**类型擦除的**——所有 shared_ptr<T> 都是相同类型,无论删除器是什么。这个区别意味着不同删除器的 unique_ptr 不能互相赋值,而 shared_ptr 可以。
删除器的类型直接影响 unique_ptr 的大小:
| 删除器类型 | sizeof(unique_ptr) |
说明 |
|---|---|---|
std::default_delete<T> |
8 字节 = 裸指针 | 无状态,EBO 生效 |
| 无状态仿函数(空 struct) | 8 字节 = 裸指针 | EBO 生效 |
| 无捕获 lambda | 8 字节 = 裸指针 | 无状态,EBO 生效 |
| 有捕获的 lambda(1 个捕获) | 16 字节 | 存储 lambda 状态 |
函数指针 void(*)(T*) |
16 字节 | 必须存储指针本身 |
std::function |
32-64 字节(因实现而异) | 巨大开销,避免使用 |
RAPIDS RMM 库(NVIDIA 的 GPU 内存管理库)的 device_buffer 类是 GPU RAII 的工业级典范——它使用移动语义转移 GPU 内存所有权,在析构函数中甚至通过嵌套的 cuda_set_device_raii 确保在正确的 GPU 设备上释放内存。对于简单场景,无状态仿函数 + unique_ptr 是最轻量的选择:
// CUDA 显存的 RAII 管理——零额外开销
struct CudaDeleter {
void operator()(void* ptr) const {
if (ptr) cudaFree(ptr);
}
};
using CudaPtr = std::unique_ptr<void, CudaDeleter>; // sizeof == sizeof(void*) == 8字节
CudaPtr allocGPU(size_t bytes) {
void* ptr = nullptr;
cudaMalloc(&ptr, bytes);
return CudaPtr(ptr); // 离开作用域自动 cudaFree
}
unique_ptr 在多态场景中的应用 ⭐⭐¶
unique_ptr 的一个强大用途是管理多态对象——通过基类 unique_ptr 持有派生类对象,利用虚函数实现多态行为。这在 SLAM 系统中的传感器驱动管理、优化器选择、特征检测器选择等场景中非常常见。
// 传感器基类
class Sensor {
public:
virtual ~Sensor() = default; // 必须虚析构!
virtual SensorData read() = 0;
};
// 具体传感器
class LidarSensor : public Sensor { /* ... */ };
class IMUSensor : public Sensor { /* ... */ };
// 工厂函数返回 unique_ptr<Sensor>——调用者可选择独占或升级为 shared_ptr
std::unique_ptr<Sensor> createSensor(SensorType type) {
switch (type) {
case SensorType::LIDAR: return std::make_unique<LidarSensor>();
case SensorType::IMU: return std::make_unique<IMUSensor>();
}
throw std::invalid_argument("unknown sensor type");
}
// 管理器持有多个独占的传感器
class SensorManager {
std::vector<std::unique_ptr<Sensor>> sensors_;
public:
void addSensor(std::unique_ptr<Sensor> sensor) {
sensors_.push_back(std::move(sensor)); // 所有权转移给管理器
}
void readAll() {
for (auto& s : sensors_) {
auto data = s->read(); // 多态调用
}
}
};
本质洞察:工厂函数返回
unique_ptr而非shared_ptr遵循了"最小权限原则"——给调用者最大的灵活性。unique_ptr可以隐式转为shared_ptr(通过shared_ptr<T>(std::move(uptr))),但反过来不行。所以返回unique_ptr让调用者可以选择独占还是共享;返回shared_ptr则强制所有调用者都共享——即使它们不需要。
unique_ptr 与不完整类型(PIMPL 续篇)⭐⭐⭐¶
编译模型基础 介绍了 PIMPL(Pointer to Implementation)模式——用指针隐藏实现细节以减少编译依赖。unique_ptr 是实现 PIMPL 的首选工具,但有一个微妙的陷阱(Scott Meyers Effective Modern C++ Item 22 详述)。
unique_ptr 的默认删除器需要调用 delete ptr_,而 delete 需要知道 T 的完整定义(因为要调用析构函数并计算释放的大小)。如果你在头文件中声明了 std::unique_ptr<Impl> pImpl_,并且让编译器在头文件中自动生成析构函数(类的隐式析构函数),那么在编译析构函数时 Impl 还是不完整类型——编译错误:"can't delete an incomplete type"。
// ❌ 错误:Impl 是不完整类型时 unique_ptr 的析构函数无法编译
// sensor.h
class Sensor {
struct Impl; // 前向声明(不完整类型)
std::unique_ptr<Impl> pImpl_; // OK:声明没问题
public:
Sensor();
// 隐式析构函数在这里生成 → 需要 Impl 的完整定义 → 编译失败!
};
// ✅ 正确:在 .cpp 中定义析构函数(此时 Impl 已完整定义)
// sensor.h
class Sensor {
struct Impl;
std::unique_ptr<Impl> pImpl_;
public:
Sensor();
~Sensor(); // 声明但不定义——告诉编译器"别自动生成"
Sensor(Sensor&&) noexcept; // 移动构造也必须声明
Sensor& operator=(Sensor&&) noexcept; // 移动赋值也必须声明
};
// sensor.cpp
#include "sensor.h"
struct Sensor::Impl { /* 完整定义 */ };
Sensor::Sensor() : pImpl_(std::make_unique<Impl>()) {}
Sensor::~Sensor() = default; // 在这里定义——Impl 是完整类型
Sensor::Sensor(Sensor&&) noexcept = default; // 同理
Sensor& Sensor::operator=(Sensor&&) noexcept = default;
注意:不仅析构函数,移动构造和移动赋值也必须在 .cpp 中定义——因为移动赋值可能需要销毁旧的 pImpl_(调用 delete),同样需要完整类型。这就打破了 Rule of Zero——PIMPL 是 Rule of Zero 的少数合法例外之一。
⚠️ 常见陷阱¶
⚠️ 编程陷阱:对 unique_ptr 调用
.get()后保存裸指针错误做法:
T* raw = uptr.get(); /* ... 某处 uptr 被销毁 ... */ raw->method(); // 悬空指针!现象:看似工作正常,但实际上
raw已经是悬空指针(dangling pointer),访问它是未定义行为——可能崩溃、可能返回垃圾数据、可能"看起来正常"但在另一台机器上崩溃。根本原因:
.get()返回的裸指针不参与所有权管理。unique_ptr被销毁或reset()后,裸指针失效。正确做法:用
.get()返回的指针只做"即用即弃"的操作——传给接受T*的旧 API,立即使用,不保存。如果需要长期持有引用,考虑重新设计所有权关系。🧠 思维陷阱:把 unique_ptr 当 shared_ptr 用
新手想法:"我需要在多个地方访问这个对象,所以到处传
unique_ptr——哦,它不能拷贝?那我用.get()拿裸指针传递。"实际上:多个地方访问同一个对象不等于共享所有权。很多访问只是借用,应该传
T&、T*或std::span;只有多个模块都需要延长对象生命周期时,才应该用shared_ptr。用unique_ptr+ 裸指针绕过所有权约束,是在对抗类型系统——你会得到unique_ptr的不便,又回到了裸指针的不安全。正确思维:先确定所有权模型(独占?共享?借用?),再选择指针类型。不要从指针类型出发反过来适应所有权。
练习¶
-
代码分析:下面的代码有什么问题?如何修复?
-
设计题:你正在设计一个传感器驱动管理器(
SensorManager),参考 Kimera-VIO 的 Pipeline 模式。管理器拥有多个不同类型的传感器驱动(LiDAR、IMU、Camera)。每个驱动同一时刻只被一个管理器拥有。使用unique_ptr设计SensorManager的接口——构造函数、addSensor()、removeSensor()、getSensor()应该分别接受/返回什么类型? -
实践题:编写一个 RAII 包装,使用
unique_ptr+ 自定义删除器管理 C 标准库的FILE*。接口:FilePtr openFile(const char* path, const char* mode)返回 RAII 包装的文件指针,离开作用域自动fclose。
4.3 std::shared_ptr:共享所有权与引用计数 ⭐⭐¶
动机:当所有权无法独占时¶
有些场景中,一个对象的生命周期不属于任何单一的"所有者"——它被多个模块共同使用,应该在最后一个使用者不再需要它时才释放。
SLAM 系统中的地图点(MapPoint)是典型案例:一个 MapPoint 被多个关键帧(KeyFrame)观测到——KeyFrame A 看到了这个点,KeyFrame B 也看到了。如果 KeyFrame A 被边缘化删除了,这个 MapPoint 不应该被释放(因为 B 还在用)。只有当所有观测到这个 MapPoint 的 KeyFrame 都被删除后,这个 MapPoint 才应该释放。这就是**共享所有权**——没有任何单一对象"拥有" MapPoint,而是所有观测者共同拥有它。
在 ORB-SLAM3 中,这种共享关系通过裸指针 + 集中式管理(Map 对象统一管理所有 MapPoint 的生命周期)来实现。这种方案可以工作,但代码中的所有权关系完全隐式,依赖开发者对代码库的全局理解。现代 SLAM 代码(如 Kimera-VIO)倾向于用 shared_ptr 显式表达这种共享关系。
unique_ptr 无法表达共享所有权——它要求独占。你需要 shared_ptr。
但在进入 shared_ptr 的技术细节之前,需要理解一个更深层的问题:unique_ptr 和 shared_ptr 不仅仅是"一个所有者 vs 多个所有者"的区别——它们代表两种完全不同的资源管理哲学。
unique_ptr 体现的哲学是**确定性管理**:在任何时刻,一个对象有且只有一个明确的"负责人"。负责人被销毁时对象被销毁,负责人通过 std::move 把对象交给下一个负责人。这条所有权链是完全可追踪的——你可以在代码中沿着 move 调用一路追溯,清楚地知道对象在哪里被创建、经过哪些中间站、最终在哪里被销毁。这种确定性使得调试和推理变得简单——如果对象提前析构了,你只需要找到唯一的那个 unique_ptr 的生命周期。
shared_ptr 体现的哲学是**协商式管理**:对象的生命周期不由任何单一实体决定,而由"最后一个不再需要它的人"隐式决定。这更灵活,但也更难推理——对象何时被销毁取决于所有 shared_ptr 的析构顺序,而析构顺序可能受线程调度、容器清理顺序和异常路径的影响。在调试时,你无法轻易回答"这个对象什么时候会死"——答案是"当最后一个持有者放手时",但"最后一个持有者是谁"可能只有在运行时才能确定。
这就是为什么 C++ Core Guidelines 把"默认用 unique_ptr"作为首要建议(R.20)——不是因为 shared_ptr 不好用,而是因为 unique_ptr 的确定性让代码更容易理解和维护。shared_ptr 是为那些**真正需要协商式管理**的场景保留的——比如多个观测者共同持有一个地图点,没有天然的"唯一所有者"。
shared_ptr 的内部结构:控制块 ⭐⭐¶
shared_ptr 的核心机制是**引用计数(reference counting)。每个被 shared_ptr 管理的对象关联一个**控制块(control block),控制块包含:
| 字段 | 类型 | 作用 |
|---|---|---|
| 强引用计数 (strong count) | std::atomic<long> |
指向此对象的 shared_ptr 数量 |
| 弱引用计数 (weak count) | std::atomic<long> |
指向此对象的 weak_ptr 数量(+ 1,如果 strong > 0) |
| 删除器 | 类型擦除的可调用对象 | 释放被管理对象时调用的函数 |
| 分配器 | 类型擦除的分配器 | 释放控制块本身时使用 |
shared_ptr 对象本身的大小是**两个指针**(通常 16 字节,64 位系统):一个指向被管理的对象,一个指向控制块。这比 unique_ptr 的 8 字节大一倍。
当你拷贝一个 shared_ptr 时,只是原子递增控制块中的强引用计数。当一个 shared_ptr 被销毁时,强引用计数原子递减。当强引用计数降到 0 时,对象被删除(调用删除器);当弱引用计数也降到 0 时,控制块内存被释放。
shared_ptr A ──→ ┌─────────────┐ ┌─────────────────────┐
[ptr|ctrl] │ 被管理对象 T │ │ 控制块 │
└─────────────┘ │ strong_count: 2 │
shared_ptr B ──→ ↑ ──→│ weak_count: 1 │
[ptr|ctrl] │ deleter: default │
└─────────────────────┘
理解控制块的存在,就能理解 shared_ptr 和 unique_ptr 在开销上的根本差异。unique_ptr 不需要控制块——因为所有权是唯一的,不需要计数。shared_ptr 必须有控制块——因为多个指针共同持有一个对象,必须有一个"公告板"来记录"现在还有几个人在用"。这个公告板(控制块)就是 shared_ptr 比 unique_ptr 多消耗 8 字节的原因——一个额外指针指向控制块。
更重要的是,控制块中的引用计数使用原子操作——即使你的程序是单线程的。标准库无法在编译期知道你的 shared_ptr 是否会被多线程共享,所以必须保守地使用原子操作来保证线程安全。这是 shared_ptr 性能开销的主要来源——不是指针解引用(和裸指针一样快),而是每次拷贝和销毁时的原子递增/递减。
一个容易忽略但重要的细节:控制块中的被管理对象指针(managed pointer)可以和 shared_ptr 存储的指针(stored pointer)不同。 这是别名构造函数(aliasing constructor)的基础——你可以创建一个 shared_ptr<Yolk> 指向 Egg 对象的 yolk 子成员,但共享 Egg 的所有权。注意:shared_ptr<Base> 能在没有虚析构函数时正确删除派生对象,前提是控制块一开始就是从 Derived* 或 make_shared<Derived> 建立的,删除器记住了真实类型;如果先把对象擦成 Base* 再构造 shared_ptr<Base>,仍然是危险设计。
make_shared 的内存优化 ⭐⭐¶
创建 shared_ptr 有两种方式,在内存分配行为上有重要区别:
方式一:shared_ptr<T>(new T(args)) — 两次内存分配。第一次分配 T 对象(new T),第二次分配控制块(shared_ptr 构造函数内部)。两块内存在堆上的位置可能相距很远,缓存不友好。
方式二:std::make_shared<T>(args) — 一次内存分配。make_shared 在一块连续内存中同时分配控制块和 T 对象(对象被就地构造在控制块内的对齐缓冲区中)。这就是 Stephan T. Lavavej(MSVC STL 维护者)所称的 "We Know Where You Live" 优化——控制块通过地址算术推导对象位置,而非存储额外的指针。
shared_ptr(new T): make_shared<T>():
┌──────────┐ ┌──────────┐ ┌──────────┬──────────┐
│ 控制块 │ │ 对象 T │ │ 控制块 │ 对象 T │
└──────────┘ └──────────┘ └──────────┴──────────┘
分配 #1 分配 #2 单次分配(连续内存)
更少的分配次数、更好的缓存局部性——典型测量表明 make_shared 比两次分配更容易获得稳定性能。在 SLAM 系统中,如果每帧创建数百个 shared_ptr<MapPoint>,这个差异会累积成可观的性能差距。这里不要把 malloc 简化成“每次都是系统调用”:现代分配器通常有线程缓存和小块缓存,真正的成本来自可能的分配器锁竞争、缓存未命中、元数据维护和偶发向操作系统申请新页。因此 make_shared 是创建 shared_ptr 的默认首选,但最终仍要用目标平台 profile 确认。
但 make_shared 有一个不太明显的副作用:由于控制块和对象共享同一块内存,即使对象已经被销毁(强引用计数 = 0),如果还有 weak_ptr 指向它(弱引用计数 > 0),控制块不能释放——因此整块内存(包括对象曾经占据的部分)都不能释放。如果 T 是一个很大的对象(如 1MB 的图像缓冲区),这意味着即使所有 shared_ptr 都已销毁,只要有 weak_ptr 存活,那 1MB 内存就一直被占着。对于大对象 + 长生命周期 weak_ptr 的场景,shared_ptr<T>(new T) 反而更好——它允许对象内存和控制块内存独立释放。
线程安全性:精确理解 ⭐⭐¶
shared_ptr 的线程安全性是面试和工程实践中最容易搞错的地方。必须精确区分三个层次:
| 操作 | 线程安全? | 原因 |
|---|---|---|
多线程拷贝/销毁**不同的** shared_ptr 实例(指向同一对象) |
✅ 安全 | 每个 shared_ptr 有自己的指针对;控制块的引用计数用原子操作 |
多线程读写**同一个** shared_ptr 变量 |
❌ 不安全 | shared_ptr 的赋值涉及更新指针 + 更新引用计数,不是原子的 |
多线程通过各自的 shared_ptr 访问被管理的对象 |
❌ 不安全 | 和裸指针一样——对象的并发读写需要额外同步 |
第二条是最容易犯的错误。 初学者常常这样推理:"引用计数是原子的,所以 shared_ptr 是线程安全的。" 这个推理的前半句是对的,但结论是错的。原子引用计数保证的是:多个线程**各自持有**的 shared_ptr 副本可以独立拷贝和销毁而不冲突。但一个 shared_ptr **变量本身**的赋值操作不是原子的——它需要更新内部的两个指针(对象指针和控制块指针),这两步之间没有原子保护。
两个线程同时对全局变量 std::shared_ptr<Config> g_config 做读和写(一个线程 g_config = newConfig,另一个线程 auto local = g_config)是数据竞争,是未定义行为。C++20 引入了 std::atomic<std::shared_ptr<T>> 来解决这个问题,它使 shared_ptr 本身的读写成为原子操作。
ORB-SLAM3 中,多线程访问同一个 MapPoint 的数据时使用细粒度的互斥锁保护——mMutexPos 保护位置坐标、mMutexFeatures 保护特征描述子、mMutexMap 保护地图关联。这说明即使用了 shared_ptr(或裸指针),被指向对象的并发访问仍然需要额外的锁。
// 线程安全的 MapPoint 访问模式
class MapPoint {
mutable std::mutex mMutexPos;
Eigen::Vector3f mWorldPos;
public:
void SetWorldPos(const Eigen::Vector3f& pos) {
std::unique_lock<std::mutex> lock(mMutexPos); // RAII 加锁
mWorldPos = pos;
} // 自动解锁
Eigen::Vector3f GetWorldPos() const {
std::unique_lock<std::mutex> lock(mMutexPos);
return mWorldPos;
}
};
C++20 std::atomic<shared_ptr<T>> ⭐⭐⭐¶
C++20 引入了 std::atomic<std::shared_ptr<T>>,解决了多线程同时读写同一个 shared_ptr 变量的问题。在此之前,需要用 std::atomic_load/std::atomic_store 这些自由函数来原子地操作 shared_ptr——但这些函数的语义和用法都不够直观。
// C++17: 需要使用自由函数
std::shared_ptr<Config> g_config;
// 线程 A: std::atomic_store(&g_config, newConfig);
// 线程 B: auto local = std::atomic_load(&g_config);
// C++20: 自然的语法
std::atomic<std::shared_ptr<Config>> g_config;
// 线程 A: g_config.store(newConfig);
// 线程 B: auto local = g_config.load();
在 SLAM 系统中,全局配置对象(如实时调参接口)经常需要在多线程间共享和更新。atomic<shared_ptr> 提供了一种无锁的方式来实现这种"发布/订阅"模式——写者原子地发布新配置,读者原子地获取最新配置。
shared_ptr 的开销:原子操作不是免费的 ⭐⭐⭐¶
shared_ptr 不是免费的。每次拷贝(包括按值传递给函数),都会执行一次**原子递增**操作;每次销毁,都会执行一次**原子递减**操作。在 x86-64 上,一次无竞争的 std::atomic<long>::fetch_add 约需 5-20 个时钟周期(相比普通整数递增的 1 个周期,慢约 5-20 倍);在多核竞争下(缓存行弹跳),可达 50-200+ 个周期。
重要的优化知识:移动构造一个 shared_ptr 通常不递增引用计数——它只是转移两个内部指针。所以函数返回 shared_ptr 是廉价的(RVO 或隐式移动生效)。但移动赋值到一个已经持有对象的 shared_ptr 时,需要释放旧控制块,仍可能触发一次原子递减。
在 SLAM 的紧密循环中(如对每个地图点迭代),按值传递 shared_ptr 可能导致显著的性能下降:
// ❌ 低效:每次调用拷贝 shared_ptr → 原子 +1/-1
void processPoint(std::shared_ptr<MapPoint> point) { /* ... */ }
for (auto& mp : mapPoints) {
processPoint(mp); // 每次循环:atomic_increment + atomic_decrement
}
// ✅ 高效:const 引用,不改变引用计数
void processPoint(const std::shared_ptr<MapPoint>& point) { /* ... */ }
// ✅ 更好:如果函数不需要参与所有权管理,直接传裸指针或引用
void processPoint(const MapPoint& point) { /* ... */ }
Herb Sutter 在 GotW #91 中总结的规则:只在函数需要"保留一份共享所有权"时才按值传递 shared_ptr——比如函数要把这个 shared_ptr 存起来延长对象生命周期。如果函数只是使用对象而不需要延长其生命周期,传 const T& 或 T*。"Don't pass a smart pointer as a function parameter unless you want to use or manipulate the smart pointer itself."
⚠️ 常见陷阱¶
⚠️ 编程陷阱:从同一个裸指针创建多个 shared_ptr
错误做法:
现象:
a和b各自创建了独立的控制块,各自认为自己是唯一的所有者。a析构时delete raw,b析构时再次delete raw→ double-free。正确做法:永远用
make_shared创建shared_ptr,或者从已有的shared_ptr拷贝。C++ Core Guidelines R.11:"避免显式new"就是为了防止这类错误。⚠️ 编程陷阱:将 shared_ptr 指向栈上对象
错误做法:
void foo() { int x = 42; std::shared_ptr<int> sp(&x); // 指向栈对象! } // sp 析构 → delete &x → 对栈内存调用 delete → 未定义行为正确做法:
shared_ptr只管理堆上(new或make_shared分配的)对象。栈对象的生命周期由编译器管理,不需要也不应该被智能指针管理。💡 概念误区:认为 shared_ptr 可以替代所有的 unique_ptr
新手想法:"shared_ptr 更灵活(能拷贝),为什么不全部用 shared_ptr?"
三个原因不行:(1) 性能:shared_ptr 比 unique_ptr 多一个指针大小(16 vs 8 字节)、每次拷贝有原子操作开销;(2) 语义模糊:全用 shared_ptr 等于放弃了类型系统对所有权的表达——代码读者无法从类型判断"这个对象到底归谁管";(3) 更容易引入循环引用导致内存泄漏。
正确原则:默认用
unique_ptr,只在真正需要共享所有权时才用shared_ptr。 这也是 C++ Core Guidelines R.20 的建议。
练习¶
-
性能测量:编写程序,在循环中分别按值传递和按
const&传递shared_ptr各 1000 万次,用chrono测量差异。你预期差异是多少倍? -
设计题:在 SLAM 系统中,一个
Map对象管理所有的MapPoint。Tracking线程和LocalMapping线程都需要访问MapPoint。Map应该用unique_ptr<MapPoint>还是shared_ptr<MapPoint>存储?为什么? -
代码修复:下面的代码为什么会内存泄漏(提示:不会崩溃,但内存永远不会被释放)?
4.4 std::weak_ptr:打破循环的观察者 ⭐⭐¶
动机:shared_ptr 的阿喀琉斯之踵¶
上一节练习 3 展示了 shared_ptr 的致命弱点:循环引用(circular reference)导致内存泄漏。
这不是理论问题——在 SLAM 系统中,循环引用几乎不可避免:
- KeyFrame ↔ MapPoint:KeyFrame 持有"它观测到的 MapPoint 列表",MapPoint 持有"观测到它的 KeyFrame 列表"。如果双方都用
shared_ptr,两者互相引用,引用计数永远不会降到 0。 - 父子 KeyFrame:关键帧之间形成生成树(spanning tree),父帧指向子帧,子帧也指向父帧。ORB-SLAM3 的
KeyFrame.h中明确有KeyFrame* mpParent和std::set<KeyFrame*> mspChildrens。 - 观察者模式:一个事件发布者持有订阅者列表,订阅者也持有发布者的引用(用于取消订阅)。
循环引用的本质问题是:引用计数假设引用图是无环的(DAG)。一旦存在环,环上的每个节点的引用计数都至少为 1(被环中的前一个节点引用),因此永远不会降到 0,即使整个环已经无法从外部访问。
weak_ptr 就是为了打破这个环而设计的。
weak_ptr 的核心语义¶
weak_ptr 是一种**非拥有型(non-owning)引用**:它指向一个被 shared_ptr 管理的对象,但不增加强引用计数。换句话说,weak_ptr 的存在与否不影响对象的生命周期——对象该死时照死,weak_ptr 不会阻止。
weak_ptr 不能直接解引用——你必须先调用 lock() 获取一个临时的 shared_ptr。lock() 的实现是**原子的**:它检查强引用计数是否大于 0,如果是,原子地递增并返回有效的 shared_ptr;如果强引用计数已经是 0(对象已死),返回空的 shared_ptr。这个原子性保证了不会出现"检查通过但对象在检查和使用之间被删除"的竞态条件。
std::shared_ptr<MapPoint> mp = std::make_shared<MapPoint>(x, y, z);
std::weak_ptr<MapPoint> wp = mp; // 弱引用,不增加强引用计数
// 使用时必须先 lock()
if (auto locked = wp.lock()) {
// locked 是 shared_ptr<MapPoint>,对象存活
locked->update();
} // locked 销毁,引用计数 -1
// 如果 mp 被销毁...
mp.reset();
// weak_ptr 自动知道对象已死
if (auto locked = wp.lock()) {
// 不会进入这里——lock() 返回空 shared_ptr
} else {
// 对象已被销毁
}
用 weak_ptr 设计 SLAM 中的双向引用 ⭐⭐¶
如果我们要用智能指针重新设计 ORB-SLAM3 的 KeyFrame-MapPoint 关系(ORB-SLAM3 本身用裸指针),最佳的所有权模型是:
- KeyFrame 用
shared_ptr<MapPoint>持有观测到的地图点——KeyFrame 是地图点的"共同所有者" - MapPoint 用
weak_ptr<KeyFrame>记录观测到它的关键帧——MapPoint 不拥有 KeyFrame,只是"观察"
这样做的好处:当所有观测到某个 MapPoint 的 KeyFrame 都被删除时,该 MapPoint 的强引用计数降到 0,自动释放。MapPoint 中的 weak_ptr<KeyFrame> 不阻止 KeyFrame 的释放——lock() 返回空指针时清理掉失效的观测即可。
// 现代化的 SLAM 所有权设计(需要前向声明解决循环依赖)
class MapPoint; // 前向声明
class KeyFrame {
// KeyFrame 共同拥有它观测到的 MapPoint
std::vector<std::shared_ptr<MapPoint>> mvpMapPoints;
};
class MapPoint {
// MapPoint 不拥有观测它的 KeyFrame——用 weak_ptr 打破循环
std::map<std::weak_ptr<KeyFrame>, size_t,
std::owner_less<std::weak_ptr<KeyFrame>>> mObservations;
void eraseDeadObservations() {
for (auto it = mObservations.begin(); it != mObservations.end(); ) {
if (it->first.expired()) {
it = mObservations.erase(it); // KeyFrame 已被删除,清理
} else {
++it;
}
}
}
};
注意 std::owner_less 的使用——weak_ptr 没有 operator<(因为它不拥有对象,直接比较指针值没有意义),但 owner_less 基于控制块地址提供弱序比较,使 weak_ptr 可以作为 std::map 的键。
weak_ptr 与 SLAM 地图管理的实际考量 ⭐⭐¶
在实际的 SLAM 系统中,MapPoint 的删除(culling)不仅涉及所有权管理,还涉及**一致性维护**。当一个 MapPoint 被判定为坏点(观测次数过少、投影误差过大)需要删除时:
- Map 中移除
shared_ptr<MapPoint>——强引用计数减少 - 所有观测到该点的 KeyFrame 中的
weak_ptr<MapPoint>自动失效——lock()返回空 - 但 KeyFrame 的观测列表中仍然保留着失效的
weak_ptr——需要定期清理
这个"延迟清理"模式在 SLAM 中很常见——你不能在删除 MapPoint 时立刻遍历所有 KeyFrame 清理失效引用(太慢,影响实时性)。取而代之的是在下次访问观测列表时,通过 lock() 检测并清理失效的 weak_ptr。
// SLAM 中常见的延迟清理模式
std::vector<std::shared_ptr<MapPoint>> KeyFrame::getValidObservations() {
std::vector<std::shared_ptr<MapPoint>> valid;
auto it = observations_.begin();
while (it != observations_.end()) {
if (auto mp = it->lock()) {
valid.push_back(mp); // 点还活着
++it;
} else {
it = observations_.erase(it); // 点已死,清理
}
}
return valid;
}
反事实推理:如果 SLAM 系统不使用
weak_ptr而是用裸指针来记录 MapPoint-KeyFrame 的反向引用(ORB-SLAM3 就是这样做的),那么 MapPoint 被删除时必须立即遍历所有 KeyFrame 清理指向它的裸指针——否则悬空指针会在后续访问时崩溃。这种"立即清理"的方式在小规模地图中可以工作,但在大规模地图(数万个 MapPoint 和 KeyFrame)中,一次清理可能阻塞 Tracking 线程数毫秒——影响实时性。weak_ptr的延迟清理模式把清理成本分摊到后续的正常访问中,对实时性更友好。
weak_ptr 的其他用途 ⭐⭐⭐¶
weak_ptr 不仅仅用于打破循环引用。它在以下模式中同样重要:
缓存(Cache):缓存持有对象的 weak_ptr。当对象仍在使用时(强引用存在),缓存可以快速返回它;当所有使用者都释放了对象(强引用降到 0),缓存中的 weak_ptr 自动失效,不阻止内存释放。
// 纹理缓存——不阻止未使用纹理的释放
class TextureCache {
std::unordered_map<std::string, std::weak_ptr<Texture>> cache_;
public:
std::shared_ptr<Texture> getTexture(const std::string& path) {
auto it = cache_.find(path);
if (it != cache_.end()) {
if (auto tex = it->second.lock()) {
return tex; // 缓存命中,纹理仍存活
}
cache_.erase(it); // 纹理已被释放,清理失效条目
}
auto tex = std::make_shared<Texture>(path);
cache_[path] = tex;
return tex;
}
};
观察者模式:发布者持有观察者的 weak_ptr 列表。当某个观察者被销毁时,发布者在下次通知时通过 lock() 发现它已失效,自动清理——无需显式的"取消订阅"操作。
⚠️ 常见陷阱¶
⚠️ 编程陷阱:expired() 和 lock() 之间的竞态条件
错误做法:
根本原因:
expired()返回false后、lock()执行前,另一个线程可能销毁了最后一个shared_ptr,导致对象被删除。lock()返回空指针,sp->doSomething()解引用空指针。cppreference 文档明确指出:expired()"is inherently racy if the managed object is shared among threads"。正确做法:直接用
lock(),不要先调expired()。lock()内部的检查和递增是原子的——要么成功获取shared_ptr(对象保证存活),要么返回空。💡 概念误区:认为 weak_ptr 的 use_count() 可以用于控制流
新手想法:"
wp.use_count() > 0就说明对象还活着,可以安全访问。"实际上:
use_count()也是固有竞态的——它返回的只是调用那一刻的快照值,可能在你读到返回值之前就已经过期了。use_count()只适合调试/日志,不能用于控制流判断。
练习¶
-
代码修复:用
weak_ptr修复 4.3 节练习 3 的Node循环引用问题。prev和next哪个应该改为weak_ptr?有没有多种合理的选择? -
设计题:ROS2 中的话题订阅回调函数
void callback(const sensor_msgs::msg::PointCloud2::SharedPtr msg)接收shared_ptr消息。如果你的节点维护一个"最近 N 帧"的滑动窗口,窗口中应该存shared_ptr<PointCloud2>还是weak_ptr<PointCloud2>?为什么?
4.5 所有权转移模式与 API 设计 ⭐⭐¶
动机:函数签名即文档¶
在大型 SLAM 代码库中,一个函数的参数类型应该向调用者传达清晰的所有权意图——不需要阅读文档或实现就能知道"我传进去的对象会怎样"。
C++ Core Guidelines(由 Bjarne Stroustrup 和 Herb Sutter 主导制定)为此制定了一套系统的规则。Kimera-VIO 的 PipelineModule.h 是这些规则在 SLAM 中的优秀实践:输入数据包用 UniquePtr&& 传递(所有权转移——数据被"消费"),输出数据用 SharedPtr 传递(多个下游模块可能需要同一份结果)。代码注释明确写道:"From this point on, you cannot use input, since spinOnce owns it."
五种参数传递模式¶
| 参数类型 | 含义 | 调用者行为 | 函数行为 |
|---|---|---|---|
unique_ptr<T> |
"我要接管所有权" | 必须 std::move |
函数成为对象的唯一拥有者 |
shared_ptr<T> 按值 |
"我要共享所有权" | 拷贝传递 | 函数持有一份共享引用 |
const shared_ptr<T>& |
"我只是使用,不参与所有权" | 引用传递 | 函数不改变引用计数 |
T& 或 const T& |
"我只是使用对象本身" | 直接传递 | 与所有权完全无关 |
T* |
"可选地使用对象(可能为 null)" | 传地址或 nullptr | 与所有权无关,可能为空 |
模式一:void sink(std::unique_ptr<T> p) — 所有权转移(Sink)
调用者创建对象,通过 std::move 把所有权"沉入"(sink)接收方。转移后,调用者的 unique_ptr 变为 nullptr,不应再使用。编译器强制执行——unique_ptr 不可拷贝,不写 std::move 就编译失败。
模式二:void share(std::shared_ptr<T> p) — 共享所有权
函数按值接收 shared_ptr,意味着在函数调用期间会延长对象生命周期。如果函数把它存入成员、回调或异步任务,才表示函数退出后仍然长期持有对象。通常用于注册回调、建立长期引用。
模式三-五:只是借用,不涉及所有权
Herb Sutter 的关键建议:如果函数不需要参与所有权管理,不要传 shared_ptr——传 T& 或 const T&。 这样函数与所有权模型解耦,可以被任何来源的对象调用。
// ❌ 过度耦合:函数不需要知道所有权模型
void computeNormal(const std::shared_ptr<PointCloud>& cloud);
// ✅ 解耦:函数只关心数据本身
void computeNormal(const PointCloud& cloud);
返回值中的所有权表达 ⭐⭐¶
返回值同样应该表达所有权。工厂函数应该返回 unique_ptr——给调用者最大的灵活性(unique_ptr 可以隐式转为 shared_ptr,反过来不行):
// 工厂函数返回 unique_ptr——最大灵活性
std::unique_ptr<Sensor> createSensor(SensorType type) {
switch (type) {
case SensorType::LIDAR: return std::make_unique<LidarSensor>();
case SensorType::IMU: return std::make_unique<ImuSensor>();
case SensorType::CAMERA: return std::make_unique<CameraSensor>();
}
throw std::invalid_argument("unknown SensorType");
}
// 调用者可以选择:
auto lidar = createSensor(SensorType::LIDAR); // 独占
std::shared_ptr<Sensor> shared = createSensor(SensorType::IMU); // 隐式转为共享
ROS2 的 shared_ptr 架构 ⭐⭐⭐¶
ROS2 的节点系统以 shared_ptr 为核心。rclcpp::Node 继承自 std::enable_shared_from_this<Node>,标准创建方式是 auto node = std::make_shared<MyNode>(options)。这样设计的原因是:执行器(Executor)通过 shared_ptr 持有节点引用,节点的生命周期需要被安全管理——节点可能在回调中通过 shared_from_this() 传递自身。注意 ROS2 不允许同一个节点同时关联到多个执行器(add_node 会检查并抛异常)。
// ROS2 标准节点创建和使用
int main(int argc, char* argv[]) {
rclcpp::init(argc, argv);
rclcpp::spin(std::make_shared<MinimalSubscriber>());
rclcpp::shutdown();
}
ROS2 内部使用 weak_ptr 避免循环引用——执行器通过 weak_ptr 收集回调组中的实体(订阅、定时器、服务等),而非直接持有强引用,确保节点销毁时所有回调实体可以被正确清理。
⚠️ 常见陷阱¶
🧠 思维陷阱:所有权设计先于编码
新手做法:先写代码,遇到编译错误(
unique_ptr不能拷贝)再改成shared_ptr。正确做法:在写代码前先画出所有权关系图——谁创建对象?谁持有对象?谁最终销毁?有没有共享?有没有循环?确定所有权模型后,指针类型自然就确定了。
实操建议:画一个简单的框图,箭头标注
owns(unique_ptr/shared_ptr)或observes(weak_ptr/raw pointer)。如果图中有环,环上至少一条边必须是observes。
练习¶
-
设计题:设计一个 SLAM 系统的数据流管道,参考 Kimera-VIO 的架构。LiDAR 驱动产生
PointCloud,传给预处理模块(降采样),再传给配准模块(ICP),最后传给地图管理器。画出所有权关系图,标注每个模块之间的指针类型。 -
代码重构:把 g2o 风格的裸指针 API 改为智能指针 API,使所有权语义清晰:
4.6 RAII 封装模式:超越内存管理 ⭐⭐¶
动机:RAII 不只是智能指针¶
RAII 的适用范围远超内存管理。C++ Core Guidelines R.1 指出:"使用 RAII 自动管理资源"——这适用于任何具有获取/释放语义的资源。SLAM 系统中最常用的非内存 RAII 模式是互斥锁管理和性能计时。
标准库已经提供了多种 RAII 包装:
| 资源 | RAII 包装 | 获取 | 释放 |
|---|---|---|---|
| 堆内存 | unique_ptr, shared_ptr |
new / make_* |
delete |
| 互斥锁 | lock_guard, unique_lock, shared_lock |
.lock() |
.unlock() |
| 文件句柄 | std::fstream, std::ofstream |
.open() / 构造 |
.close() / 析构 |
| 线程 | std::jthread (C++20) |
构造 | 析构时先请求停止,再 join |
lock_guard 和 unique_lock:互斥锁的 RAII ⭐⭐¶
互斥锁管理是 RAII 在非内存资源上最经典的应用,也是理解"RAII 不只是智能指针"的最佳切入点。互斥锁的获取/释放模式和内存的分配/释放模式完全同构——都是"获取一个资源,使用它,然后释放"。但互斥锁遗漏释放的后果比内存泄漏更严重:内存泄漏只是浪费空间,死锁会让整个系统永久卡住。
为什么手动管理锁特别容易出错?因为"忘记解锁"不是唯一的风险。更隐蔽的风险是异常路径:一段看似完美配对了 lock()/unlock() 的代码,如果中间的任何一行抛出异常,unlock() 就永远不会执行——死锁。这和前面讨论的 C 风格资源管理的问题完全一样:手动配对在异常面前是脆弱的。RAII 的 lock_guard 把 unlock() 绑定到析构函数,利用栈展开机制保证无论控制流怎么走,锁都会被释放。
ORB-SLAM3 是一个多线程系统:Tracking 线程、LocalMapping 线程、LoopClosing 线程并发运行,通过共享数据(Map、MapPoint、KeyFrame)交互。每次访问共享数据都需要加锁保护——如果忘记解锁(或者异常导致跳过解锁),就是死锁。
std::lock_guard 是最简单的锁 RAII 包装:构造时加锁,析构时解锁。
// ❌ 手动加锁/解锁——异常不安全、容易遗漏
void Map::addMapPoint(MapPoint* mp) {
mMutexMap.lock();
mspMapPoints.insert(mp);
mMutexMap.unlock(); // 如果 insert 抛异常?永远不会解锁 → 死锁!
}
// ✅ RAII 加锁——异常安全、不可能遗漏
void Map::addMapPoint(MapPoint* mp) {
std::lock_guard<std::mutex> lock(mMutexMap); // 构造 = 加锁
mspMapPoints.insert(mp);
} // 析构 = 解锁,无论正常退出还是异常
std::unique_lock 比 lock_guard 更灵活:它允许延迟加锁(std::defer_lock)、提前解锁(.unlock())、尝试加锁(std::try_to_lock)、以及与条件变量配合使用。代价是多一个 bool 成员记录锁的状态。
// unique_lock 的灵活用法——缩短临界区
void processFrame(Frame& frame) {
std::unique_lock<std::mutex> lock(mMutexMap, std::defer_lock); // 先不加锁
frame.extractFeatures(); // 不需要锁的预处理
lock.lock(); // 现在加锁——只保护必须保护的操作
auto points = mspMapPoints; // 拷贝一份数据
lock.unlock(); // 提前解锁——让其他线程尽快获得锁
// 在锁外处理拷贝的数据——不阻塞其他线程
for (auto& mp : points) {
matchFeature(frame, *mp);
}
}
C++17 std::shared_lock:读写锁(shared_mutex)的 RAII 包装。在 SLAM 中,地图数据的典型访问模式是"多读少写"——Tracking 线程频繁读取地图点位置,LocalMapping 线程偶尔修改地图。shared_lock 允许多个读线程并行访问,unique_lock 保证写线程独占访问。这比普通 mutex 在读密集场景下吞吐量高很多。
class Map {
mutable std::shared_mutex mtx_;
std::vector<MapPoint> points_;
public:
// 读操作:shared_lock 允许多线程并行读
MapPoint getPoint(size_t i) const {
std::shared_lock lock(mtx_);
return points_[i];
}
// 写操作:unique_lock 保证独占
void addPoint(MapPoint mp) {
std::unique_lock lock(mtx_);
points_.push_back(std::move(mp));
}
};
C++17 引入了 std::scoped_lock,它可以同时锁定多个互斥锁并避免死锁(内部使用 std::lock 算法保证不会出现 ABBA 死锁):
// 同时锁定两个互斥锁——避免 ABBA 死锁
void swapMapPoints(Map& map1, Map& map2) {
std::scoped_lock lock(map1.mMutex, map2.mMutex); // 无死锁风险
std::swap(map1.mspMapPoints, map2.mspMapPoints);
}
ScopedTimer:SLAM 开发者的性能调试利器 ⭐⭐¶
SLAM 算法的实时性至关重要——10Hz LiDAR 意味着每帧只有 100ms 的预算。开发者需要频繁测量各个模块的耗时。
VINS-Fusion 使用了一个手动的 TicToc 计时器——TicToc t; /* code */ printf("time: %f ms\n", t.toc());。简单但容易出错:如果你忘了调 toc(),就拿不到测量结果。而且如果被测代码抛异常,toc() 永远不会执行。
RAII 提供了更优雅、更安全的方案:
class ScopedTimer {
std::string name_;
std::chrono::steady_clock::time_point start_; // steady_clock 保证单调递增
public:
explicit ScopedTimer(std::string name)
: name_(std::move(name))
, start_(std::chrono::steady_clock::now()) {}
~ScopedTimer() noexcept {
try {
auto end = std::chrono::steady_clock::now();
auto us = std::chrono::duration_cast<std::chrono::microseconds>(end - start_).count();
std::cout << "[Timer] " << name_ << ": " << us << " us\n";
} catch (...) {} // 析构函数不能抛异常
}
ScopedTimer(const ScopedTimer&) = delete;
ScopedTimer& operator=(const ScopedTimer&) = delete;
};
// 使用——计时自动且异常安全:
void Tracking::processFrame(const Frame& frame) {
ScopedTimer timer("Tracking::processFrame"); // 开始计时
// ... 特征提取、匹配、PnP 等 ...
} // 自动打印:[Timer] Tracking::processFrame: 45230 us
// 即使中间抛异常也会打印——析构函数保证执行
与 VINS-Fusion 的手动 TicToc 相比,RAII 计时器的优势在于**不可遗忘**——析构函数保证在作用域结束时触发,无论执行路径如何。
C++26 <scope> 标准作用域守卫 ⭐⭐⭐¶
<scope> 头文件中的 scope_exit、scope_success、scope_fail 源自 Library Fundamentals TS v3,已被投票纳入 C++26 标准,提供了三种标准化的作用域守卫:
| 类型 | 执行条件 | 典型用途 |
|---|---|---|
std::scope_exit |
总是在离开作用域时执行 | 清理资源、恢复状态 |
std::scope_success |
仅在正常退出时执行(无异常) | 提交事务 |
std::scope_fail |
仅在异常退出时执行 | 回滚事务 |
#include <scope> // C++26(实际工具链支持可能滞后于标准发布)
void transferFunds(Account& from, Account& to, double amount) {
from.debit(amount);
// 如果后续操作抛异常,自动回滚 debit
std::scope_fail rollback([&] {
from.credit(amount); // 回滚
});
to.credit(amount); // 如果这里抛异常,rollback 自动执行
// 正常退出:rollback 不执行(scope_fail 只在异常时触发)
}
在此之前,社区通过 Boost.ScopeExit 或自定义 ScopeGuard(如本章 4.10 节的实现)来解决这个问题。标准化的好处是统一了接口和行为保证。
实际工具链状况:截至 2026 年,<scope> 的编译器支持仍在逐步推进。工程中建议按编译器版本确认支持情况,不支持时使用本章自定义的 ScopeExit 或 Boost.ScopeExit。
反事实推理:如果 C++ 从 C++11 就标准化了
scope_exit,很多手动 try-catch 的资源清理代码可以被简洁的 RAII 守卫替代。std::unique_ptr+ 自定义删除器在很多场景下是scope_exit的变通方案,但语义不够直接——用一个"智能指针"来管理"状态恢复"在概念上是牵强的。
自定义 RAII 类的设计原则 ⭐⭐¶
当标准库没有提供现成的 RAII 包装时,遵循五个原则:
- 构造函数获取资源——如果获取失败,抛异常
- 析构函数释放资源——析构函数不能抛异常(
noexcept) - 禁止拷贝(除非拷贝有明确的语义,如"深拷贝资源")
- 支持移动(移动 = 转移资源所有权,源对象置为无效状态)
- 析构函数中检查无效状态(移动后的对象析构时不应释放资源)
Open3D 的 CUDAScopedDevice 是一个工业级 RAII 案例——构造时切换到指定 GPU 设备,析构时恢复之前的设备。这在多 GPU 机器人系统中非常实用:
// Open3D 风格的 CUDA 设备 RAII
class CUDAScopedDevice {
int prev_device_id_ = -1;
bool valid_ = false;
public:
explicit CUDAScopedDevice(int device_id) {
if (cudaGetDevice(&prev_device_id_) == cudaSuccess) {
valid_ = true;
cudaSetDevice(device_id);
}
}
~CUDAScopedDevice() {
if (valid_) cudaSetDevice(prev_device_id_);
}
CUDAScopedDevice(const CUDAScopedDevice&) = delete;
CUDAScopedDevice& operator=(const CUDAScopedDevice&) = delete;
};
// 使用:
void processOnGPU1() {
CUDAScopedDevice dev(1); // 切换到 GPU 1
// ... 在 GPU 1 上的操作 ...
} // 自动恢复之前的 GPU
⚠️ 常见陷阱¶
⚠️ 编程陷阱:忘记给 RAII 对象命名
错误做法:
注意:std::lock_guard<std::mutex>(mMutex);用圆括号时,会被解析为声明一个名为mMutex的lock_guard变量(C++ 的 "most vexing parse" 的变体),实际效果取决于上下文,可能导致编译错误。花括号版本则明确是临时对象。现象:临时对象在语句结束时析构——锁在加上的同一行就被释放了。后续的代码完全没有锁保护。这个 bug 极难发现,因为程序大部分时候"看起来正常",只在特定的线程交错下才暴露。
正确做法:一定要给 RAII 对象一个名字——它的生命周期延续到名字所在的作用域结束。
💡 概念误区:认为析构函数可以抛异常
新手想法:"析构函数释放资源失败了(比如
fclose返回错误),应该抛异常通知调用者。"实际上:析构函数抛异常是 C++ 中最危险的操作之一。如果析构函数在栈展开过程中被调用(已经有一个异常在传播),再抛出第二个异常会直接调用
std::terminate()终止程序——连catch的机会都没有。C++11 起,析构函数默认是noexcept(true)的。正确做法:析构函数中的资源释放失败应该被记录日志后静默处理。如果调用者需要处理释放错误,提供一个显式的
close()/release()方法让用户在析构前调用。
练习¶
-
实践题:实现一个
ScopedTimer变体:在析构时将耗时(微秒)追加到一个std::vector<std::pair<std::string, int64_t>>(通过构造函数传入引用),而非打印到cout。测试:在一个函数中创建三个ScopedTimer(嵌套作用域),验证析构顺序和计时结果。 -
设计题:CUDA 编程中经常需要在 GPU 上执行异步操作。设计一个 RAII 类
CudaStreamGuard,构造时创建一个 CUDA stream,析构时同步(cudaStreamSynchronize)并销毁(cudaStreamDestroy)。支持移动但禁止拷贝。
4.7 enable_shared_from_this:安全地共享自身 ⭐⭐⭐¶
动机:当对象需要把"自己"传出去¶
有时候,一个被 shared_ptr 管理的对象需要在自己的方法内部获得一个指向自身的 shared_ptr。最直觉的做法是 shared_ptr<T>(this)——但这是致命的错误。
class MapPoint {
public:
void registerSelf(Observer& observer) {
// ❌ 致命错误!从 this 创建新的 shared_ptr
observer.watch(std::shared_ptr<MapPoint>(this));
}
};
auto mp = std::make_shared<MapPoint>();
mp->registerSelf(observer);
// 现在有两个独立的 shared_ptr 管理同一个对象:
// 1. mp(原始的,有自己的控制块)
// 2. observer 内部的那个(新创建的,有另一个控制块)
// 当两者都析构时 → double-delete!
问题的根源:从裸指针构造 shared_ptr **总是**创建一个全新的控制块。新控制块不知道原来的 mp 已经在管理这个对象——两个控制块各自认为自己是唯一的所有者,各自在强引用计数归零时 delete this。
enable_shared_from_this 的原理 ⭐⭐⭐¶
std::enable_shared_from_this<T> 是一个 CRTP(Curiously Recurring Template Pattern,变参模板折叠表达式与CRTP 将详述)基类。它的内部机制:
- 基类包含一个
mutable weak_ptr<T>成员(叫做weak_this) - 当第一个
shared_ptr<T>被创建时(通过make_shared或shared_ptr构造函数),构造函数通过 SFINAE/std::is_convertible检测T是否公开继承自enable_shared_from_this<T>——如果是,就用新创建的控制块初始化weak_this - 调用
shared_from_this()时,它从weak_this构造一个shared_ptr——复用已有的控制块
class MapPoint : public std::enable_shared_from_this<MapPoint> {
public:
void registerSelf(Observer& observer) {
// ✅ 安全:复用已有的控制块
observer.watch(shared_from_this());
}
};
auto mp = std::make_shared<MapPoint>();
mp->registerSelf(observer);
// mp 和 observer 内部的 shared_ptr 共享同一个控制块
// 引用计数正确,析构时只 delete 一次
ROS2 中的 rclcpp::Node 就继承了 enable_shared_from_this<Node>——节点在回调中需要把自身传递给服务客户端或定时器时,使用 shared_from_this() 获取安全的 shared_ptr。
三个致命的使用错误¶
错误一:在构造函数中调用 shared_from_this()
构造函数执行时,make_shared<T>() 还没有返回——shared_ptr 尚未构建完成,内部的 weak_this 尚未初始化。C++17 中会抛出 std::bad_weak_ptr,C++17 之前是未定义行为。
解决方案:使用工厂函数模式。
class Node : public std::enable_shared_from_this<Node> {
Node() = default; // 私有构造
public:
static std::shared_ptr<Node> create() {
auto node = std::shared_ptr<Node>(new Node());
node->init(); // init() 中可以安全调用 shared_from_this()
return node;
}
};
错误二:非公开继承
enable_shared_from_this 要求**公开继承**。如果用 private 或 protected 继承,shared_ptr 的构造函数中的检测机制(std::is_convertible)无法通过,weak_this 不会被初始化——但编译器不会给出任何警告。
错误三:对非 shared_ptr 管理的对象调用
C++17 还引入了 weak_from_this() 成员函数,它不会抛异常——如果对象尚未被 shared_ptr 管理,返回空的 weak_ptr。
练习¶
-
代码分析:为什么
enable_shared_from_this使用 CRTP(class T : public enable_shared_from_this<T>)而不是简单的class T : public enable_shared_from_this?提示:shared_from_this()的返回类型是什么? -
设计题:一个 ROS2 节点类同时继承
rclcpp::Node(已继承enable_shared_from_this<Node>)和一个自定义基类SensorNode(也想继承enable_shared_from_this<SensorNode>)。这会导致什么问题?如何解决?(提示:菱形enable_shared_from_this。)
4.8 智能指针的性能考量与选择指南 ⭐⭐⭐¶
零开销 vs 有开销:数字说话¶
不同的指针类型有截然不同的性能特性。理解这些差异对于 SLAM 这样的实时系统至关重要:
| 指针类型 | 大小(64位) | 解引用开销 | 拷贝开销 | 创建/销毁开销 |
|---|---|---|---|---|
裸指针 T* |
8 字节 | 0 | 0(值拷贝) | 0 |
unique_ptr<T> |
8 字节 | 0 | N/A(禁止拷贝) | 等同 delete |
unique_ptr<T, D> |
8 + sizeof(D) | 0 | N/A | 等同调用 D |
shared_ptr<T> |
16 字节 | 0 | 原子 +1/-1(~5-20 周期无竞争) | 原子 -1,可能 delete |
weak_ptr<T> |
16 字节 | N/A(需 lock) | 原子 +1/-1 | 原子 -1 |
关键洞察:
-
unique_ptr真正是零开销。 在优化编译(-O2)下,unique_ptr的所有操作都被内联为等价的裸指针操作。你可以在 godbolt.org 上验证:unique_ptr->method()和raw_ptr->method()生成完全相同的汇编代码。 -
shared_ptr的主要开销是原子操作。 在高竞争场景下(多个线程频繁拷贝/销毁指向同一对象的shared_ptr),控制块所在的缓存行在 CPU 核间不断弹跳(cache line bouncing),进一步放大开销。 -
移动构造
shared_ptr通常不递增引用计数——它只是转移两个指针。所以返回shared_ptr是廉价的;但移动赋值到一个已经持有对象的shared_ptr时,会先释放旧控制块,仍可能触发原子递减。
选择决策树 ⭐⭐¶
需要动态分配吗?
├── 不需要 → 用栈对象(值语义),不用任何指针
└── 需要
├── 只有一个所有者?
│ ├── 是 → unique_ptr
│ └── 否
│ ├── 多个所有者需要共同管理生命周期?
│ │ ├── 是 → shared_ptr
│ │ └── 否 → unique_ptr + 裸指针/引用(借用)
│ └── 需要打破循环引用或做非拥有观察?
│ └── weak_ptr
└── 需要与 C API 交互?
└── unique_ptr + 自定义删除器
首选栈对象:很多时候你根本不需要指针。Eigen::Matrix3d R;(72 字节栈分配)比 unique_ptr<Eigen::Matrix3d>(new ...) 更快、更简单。只有当对象需要多态(通过基类指针访问派生类)、生命周期超出当前作用域、或大小过大不适合放在栈上时,才需要动态分配。
栈分配 vs 堆分配的性能差异:栈分配只需要移动栈指针(通常是一条 sub rsp, N 指令——几乎零开销),堆分配需要调用内存分配器(即使现代分配器如 jemalloc/tcmalloc 有线程缓存,仍然涉及元数据维护和偶发的系统调用)。在 1kHz 的控制循环中,每帧创建一个小型对象时,栈分配和堆分配的差异可能是 1 纳秒 vs 50 纳秒——看起来不多,但累积 1000 次就是 0.001ms vs 0.05ms。在控制循环的 1ms 时间预算中,50 微秒的分配开销占了 5%。
SLAM 系统中的经验法则:
- Eigen::Matrix3d(72 字节)、Eigen::Vector3d(24 字节)、Pose2D(24 字节)→ 栈分配
- 100 万个点的 std::vector<Eigen::Vector3d>(24MB)→ vector 内部自动堆分配,但 vector 对象本身在栈上
- 多态对象(如不同传感器类型)→ unique_ptr<Sensor> 堆分配
- 跨线程共享的大型数据结构(如 Map)→ shared_ptr<Map> 堆分配
默认 unique_ptr:当你确实需要动态分配时,从 unique_ptr 开始。只有当你发现需要共享所有权时才"升级"到 shared_ptr。从 unique_ptr 到 shared_ptr 的转换是隐式且高效的——只需创建一个控制块。反过来不行。
何时不用智能指针 ⭐⭐⭐¶
智能指针不是银弹。以下场景应该使用裸指针或引用:
场景一:非拥有的函数参数。 函数只是使用对象,不参与生命周期管理。用 T&(不能为 null)或 T*(可能为 null)。
场景二:容器中的"视图"。 你需要一个指针数组指向别处管理的对象——比如"当前可见的 MapPoint 列表"只是主地图中 MapPoint 的子集引用。用 std::vector<MapPoint*> 即可。ORB-SLAM3 的 KeyFrame::mvpMapPoints 就是 std::vector<MapPoint*>——所有权在 Map 对象,KeyFrame 只是借用。
场景三:性能关键路径中的短期使用。 在一个 10μs 的循环体内,shared_ptr 的原子操作可能占据可观的比例。用 get() 取出裸指针在循环内使用,但必须确保 shared_ptr 的生命周期覆盖整个循环。
场景四:全局/静态生命周期的对象。 活到程序结束的对象用智能指针管理增加了不必要的复杂性。
⚠️ 常见陷阱¶
🧠 思维陷阱:认为"有智能指针就不会有内存问题"
实际上:智能指针防止了忘记
delete导致的泄漏,但引入了新的问题模式:(1)shared_ptr循环引用 = 泄漏;(2) 大量短生命周期shared_ptr拷贝 = 原子操作开销;(3)make_shared+ 长期存活的weak_ptr= 对象内存延迟释放;(4) 对shared_ptr变量本身的并发读写 = 数据竞争。智能指针是工具,不是魔法——理解其机制才能正确使用。
练习¶
-
性能实验:编写基准测试,比较以下操作各执行 1000 万次的耗时:(a) 创建并销毁
unique_ptr<int>;(b) 创建并销毁shared_ptr<int>(用make_shared);(c) 拷贝一个shared_ptr<int>并销毁副本。在你的机器上,(c) 比 (a) 慢多少倍? -
架构分析:阅读 Kimera-VIO 的
Pipeline.h和PipelineModule.h,列出所有智能指针的使用。分析:哪些用了unique_ptr?哪些用了shared_ptr?输入和输出为什么选择不同的指针类型?
4.9 RAII 与线程、回调、订阅句柄 ⭐⭐⭐¶
工程问题:机器人系统的资源不只是内存¶
RAII 最容易被理解成“智能指针自动释放内存”。 但机器人软件里的资源远不止堆内存:
| 资源 | 获取动作 | 释放动作 |
|---|---|---|
| 线程 | std::thread / std::jthread 启动 |
请求停止并 join |
| ROS2 订阅 | 创建 subscription | 注销或销毁句柄 |
| 回调注册 | 向事件源注册 lambda | 退订 |
| 文件 | fopen / open |
fclose / close |
| GPU buffer | 分配 device memory | 释放 device memory |
| mutex 所有权 | lock | unlock |
| 临时模式切换 | 设置 debug/安全模式 | 恢复原模式 |
如果资源有“进入”和“离开”两个动作,就应该考虑 RAII。 RAII 的价值是把离开动作绑定到析构函数。 这样即使中途 return、抛异常或走错误分支,资源也能按作用域释放。
反面失败:回调注册后忘记退订¶
看一个简化事件系统:
class EventBus {
public:
int subscribe(std::function<void(const Frame&)> callback);
void unsubscribe(int token) noexcept;
};
错误写法:
class Viewer {
public:
explicit Viewer(EventBus& bus)
: bus_(bus) {
token_ = bus_.subscribe([this](const Frame& frame) {
draw(frame);
});
}
~Viewer() = default;
private:
void draw(const Frame& frame);
EventBus& bus_;
int token_ = -1;
};
Viewer 析构后,事件总线里可能还保存着捕获 this 的 lambda。
下一次事件触发,lambda 访问已析构对象。
这类问题比普通内存泄漏更危险,因为它通常表现为偶发崩溃。
抽象不变量:注册得到句柄,句柄析构完成退订¶
更稳妥的设计是让 subscribe 返回 RAII 句柄:
class Subscription {
public:
Subscription() = default;
Subscription(EventBus* bus, int token)
: bus_(bus), token_(token) {}
~Subscription() noexcept {
reset();
}
Subscription(const Subscription&) = delete;
Subscription& operator=(const Subscription&) = delete;
Subscription(Subscription&& other) noexcept
: bus_(std::exchange(other.bus_, nullptr)),
token_(std::exchange(other.token_, -1)) {}
Subscription& operator=(Subscription&& other) noexcept {
if (this != &other) {
reset();
bus_ = std::exchange(other.bus_, nullptr);
token_ = std::exchange(other.token_, -1);
}
return *this;
}
void reset() noexcept {
if (bus_) {
bus_->unsubscribe(token_);
bus_ = nullptr;
token_ = -1;
}
}
private:
EventBus* bus_ = nullptr;
int token_ = -1;
};
使用者不再手动记退订:
这里假设 EventBus::subscribe() 的公开接口已经改成返回 Subscription,内部仍然可以用整数 token 管理订阅表。
class Viewer {
public:
explicit Viewer(EventBus& bus)
: subscription_(bus.subscribe([this](const Frame& frame) {
draw(frame);
})) {}
private:
void draw(const Frame& frame);
Subscription subscription_;
};
只要 Viewer 析构,subscription_ 就会析构并退订。
资源释放顺序由成员声明顺序决定。
这又回到了 现代类设计与特殊成员函数 的成员初始化和析构顺序。
工程边界:析构函数中不能抛异常¶
RAII 对象的析构函数承担清理职责。 析构函数不应抛异常。 如果退订、关闭设备、释放资源可能失败,常见策略是:
- 析构函数尽最大努力清理,不抛出。
- 提供显式
close()/stop()返回错误。 - 析构函数在未显式关闭时执行兜底清理。
- 关键错误通过日志或诊断通道记录,但不从析构函数抛出。
这条规则在异常传播期间尤其重要。
如果栈展开时另一个异常从析构函数逃出,程序会调用 std::terminate。
代码验证:线程 RAII 守卫¶
std::jthread 已经提供协作停止和析构 join。
如果项目只能使用 std::thread,可以封装一个最小守卫:
#include <thread>
class JoiningThread {
public:
JoiningThread() = default;
explicit JoiningThread(std::thread thread)
: thread_(std::move(thread)) {}
~JoiningThread() {
if (thread_.joinable()) {
thread_.join();
}
}
JoiningThread(const JoiningThread&) = delete;
JoiningThread& operator=(const JoiningThread&) = delete;
JoiningThread(JoiningThread&&) noexcept = default;
JoiningThread& operator=(JoiningThread&&) = delete;
private:
std::thread thread_;
};
这个封装只保证析构时 join。
它故意删除移动赋值。
原因是 std::thread 的移动赋值在目标对象仍持有可 join 线程时会终止程序;如果需要可赋值版本,必须先明确处理当前线程的停止或 join,再接管新线程。
它不提供停止请求。
如果线程函数可能永远不退出,析构会阻塞。
因此线程 RAII 必须和停止协议一起设计。
⚠️ 常见陷阱¶
A. 编程陷阱:回调捕获 this 后对象被销毁
⚠️ 编程陷阱:lambda 捕获 this 但对象生命周期比回调短
错误做法:在构造函数中注册回调 [this](Frame& f) { process(f); },
然后对象被销毁但回调仍在事件系统中——下次事件触发时访问悬空指针
现象:偶发崩溃,通常在对象销毁后不久触发
根本原因:回调中的 this 指针不参与所有权管理——它不会延长对象生命周期
正确做法:让 subscribe() 返回 RAII 句柄,句柄析构时自动退订;
或使用 weak_ptr + shared_from_this() 让回调在对象销毁后自动失效
练习¶
-
RAII 订阅实现(⭐⭐):设计一个
EventBus类,其subscribe()方法返回一个 RAII 的Subscription对象。Subscription析构时自动退订。支持移动但禁止拷贝。 -
线程 RAII(⭐⭐⭐):编写一个
BackgroundWorker类,构造时启动一个线程执行指定任务,析构时请求停止并 join。使用std::stop_token(C++20)或std::atomic<bool>实现停止协议。
4.10 RAII 与事务性修改:作用域结束时恢复状态 ⭐⭐¶
工程问题:临时修改系统状态后必须恢复¶
机器人程序经常临时改变某个状态:
- 暂停地图更新。
- 临时关闭可视化发布。
- 提高日志级别。
- 切换控制模式。
- 禁用某个 safety check 做离线回放。
如果函数有多个 return 分支,手动恢复容易遗漏。
反面失败:异常路径没有恢复模式¶
void runRelocalization(System& system) {
system.setMappingPaused(true);
if (!system.hasEnoughFeatures()) {
return;
}
solveRelocalization();
system.setMappingPaused(false);
}
如果特征不足直接返回,Mapping 会一直暂停。 这不是内存泄漏,却是状态泄漏。
抽象不变量:进入作用域时保存旧状态,离开时恢复¶
可以写一个通用作用域恢复器:
#include <exception>
#include <utility>
template <class Restore>
class ScopeExit {
public:
explicit ScopeExit(Restore restore)
: restore_(std::move(restore)) {}
~ScopeExit() noexcept {
if (active_) {
try {
restore_();
} catch (...) {
std::terminate();
}
}
}
ScopeExit(const ScopeExit&) = delete;
ScopeExit& operator=(const ScopeExit&) = delete;
ScopeExit(ScopeExit&& other) noexcept
: restore_(std::move(other.restore_)),
active_(std::exchange(other.active_, false)) {}
void release() {
active_ = false;
}
private:
Restore restore_;
bool active_ = true;
};
使用:
void runRelocalization(System& system) {
const bool old = system.mappingPaused();
system.setMappingPaused(true);
ScopeExit restore([&] {
system.setMappingPaused(old);
});
if (!system.hasEnoughFeatures()) {
return;
}
solveRelocalization();
}
无论函数从哪里离开,状态都会恢复。
这里的恢复函数必须满足一个重要约束:析构阶段不能把异常传播出去。
ScopeExit 的析构函数标记为 noexcept,如果 restore_() 抛异常,示例选择直接终止程序。
这不是因为终止程序“优雅”,而是因为析构函数在栈展开期间再次抛异常会让程序进入更危险的状态。
因此作用域恢复器适合执行不抛的、局部的、确定性的恢复动作:
| 适合 | 不适合 |
|---|---|
| 恢复布尔标志 | 写网络请求 |
| 解锁或恢复线程局部状态 | 执行可能失败的复杂 IO |
| 关闭临时诊断开关 | 提交数据库事务 |
| 释放已获得的轻量资源 | 运行长时间清理逻辑 |
如果恢复动作本身可能失败,应把失败处理放到显式代码路径中,而不是藏在析构函数里。
工程边界:作用域恢复器不要捕获即将失效的对象¶
ScopeExit 常用 lambda 捕获引用。
这要求被捕获对象的生命周期覆盖 guard。
不要把 guard 移到比引用对象更长的生命周期里。
也不要从函数返回一个捕获局部引用的 guard。
RAII 解决释放时机。 它不自动解决引用生命周期。
练习¶
-
ScopeExit 实现(⭐⭐):实现上文的
ScopeExit模板类,并用它重写runRelocalization函数。验证:即使函数提前返回,状态也能正确恢复。 -
状态恢复器(⭐⭐⭐):为以下场景设计 RAII 状态恢复器:一个机器人控制系统在执行紧急停止时需要临时切换到安全模式,紧急停止流程结束后恢复到之前的控制模式。恢复动作不能抛异常。
4.11 所有权图:从单个指针扩展到系统架构 ⭐⭐⭐¶
工程问题:大型系统的所有权是图,不是一条链¶
单个 unique_ptr 很容易理解。
难的是系统级所有权:
System
owns SensorManager
owns Tracking
owns Mapping
owns Viewer
Map
owns KeyFrame
owns MapPoint
KeyFrame
observes MapPoint
Viewer
observes Map
如果每条边都用 shared_ptr,图会变成难以释放的网。
如果每条边都用裸指针,又容易悬空。
抽象不变量:先画所有权,再选指针类型¶
选择指针前,先给每条边分类:
| 关系 | 推荐表达 |
|---|---|
| A 独占拥有 B | std::unique_ptr<B> |
| A 和 B 共享长期所有权 | std::shared_ptr<B> |
| A 临时使用 B 且不保存 | B& 或 B* |
| A 保存非拥有观察 | 原始指针、weak_ptr 或 observer 类型 |
| B 生命周期必须短于 A | 成员对象 |
| 生命周期由外部框架管理 | 明确注释和句柄 |
shared_ptr 应该表达共享所有权。
不要用它表达“我懒得想谁拥有”。
代码验证:地图所有权与观测关系¶
#include <memory>
#include <vector>
class MapPoint;
class KeyFrame {
public:
void addObservation(const std::shared_ptr<MapPoint>& point) {
points_.push_back(point);
}
private:
std::vector<std::weak_ptr<MapPoint>> points_;
};
class Map {
public:
std::shared_ptr<MapPoint> createMapPoint() {
auto point = std::make_shared<MapPoint>();
points_.push_back(point);
return point;
}
std::shared_ptr<KeyFrame> createKeyFrame() {
auto keyframe = std::make_shared<KeyFrame>();
keyframes_.push_back(keyframe);
return keyframe;
}
private:
std::vector<std::shared_ptr<MapPoint>> points_;
std::vector<std::shared_ptr<KeyFrame>> keyframes_;
};
这里 Map 保存强引用。
KeyFrame 保存弱引用,避免 KeyFrame 和 MapPoint 互相延长生命周期。
真实系统还要处理删除、坏点剔除和并发访问。
但所有权方向已经清楚。
练习¶
-
所有权图绘制(⭐⭐):为以下简化的 SLAM 系统画出所有权关系图(用
owns、shares、observes标注每条边),并确定每条边应该使用什么指针类型: -
循环检测(⭐⭐⭐):在你画的图中,找出所有可能的循环引用,用
weak_ptr打破。解释你选择哪条边改为weak_ptr的理由。
教学结论¶
RAII 的终点不是”把裸指针换成智能指针”。 而是让资源生命周期成为类型系统和作用域规则的一部分。 当所有权图清楚后,智能指针只是实现手段。 当所有权图不清楚时,智能指针会把问题隐藏得更深。
本质洞察:
unique_ptr、shared_ptr、weak_ptr三者的选择本质上是在回答一个所有权问题——“谁负责这个对象的生死?”unique_ptr说”只有我负责”,shared_ptr说”我们共同负责、最后一个离开的关灯”,weak_ptr说”我只是看看、不参与决定生死”。选择错误的智能指针不是性能问题,而是语义错误——用shared_ptr表达独占所有权会掩盖设计意图,用unique_ptr表达共享所有权会导致 use-after-free。先画清楚所有权关系图,再选指针类型。
跨章综合练习¶
- [综合 现代类设计与特殊成员函数+RAII与智能指针+移动语义与完美转发] RAII、特殊成员函数与移动语义的交叉设计:
- (a) 设计一个
SensorDriver类,它通过 C APIsensor_open()/sensor_close()管理硬件资源。要求使用 Rule of Five 实现 move-only 语义(不可拷贝但可移动)。移动构造函数中如何确保源对象析构时不会重复关闭硬件? - (b) 现在用
std::unique_ptr配合自定义删除器重构SensorDriver,使其回到 Rule of Zero。写出unique_ptr的自定义删除器,并解释为什么这种方式更安全。 - (c) 在一个 SLAM 系统中,
Map拥有所有MapPoint(shared_ptr),KeyFrame观测MapPoint(weak_ptr)。解释:为什么KeyFrame不能用shared_ptr持有MapPoint?如果两者都用shared_ptr互相引用,会发生什么?(结合 现代类设计与特殊成员函数 析构函数和 RAII与智能指针 引用计数知识回答。) - (d) 编写一段代码,在
Map::removeMapPoint()中删除一个MapPoint,然后在KeyFrame中通过weak_ptr::lock()检测该点是否已被删除。用 类型系统与值类别推导 的auto推导分析lock()返回值的类型。
🔧 故障排查手册¶
| 症状 | 可能原因 | 排查步骤 | 相关章节 |
|---|---|---|---|
程序内存持续增长,但没有明显的 new 泄漏 |
shared_ptr 循环引用导致引用计数永远不为零,对象无法释放 |
1. 画出对象间的所有权图,检查是否存在环 2. 用 Valgrind 的 --leak-check=full 确认泄漏位置 3. 将环中一个方向改为 weak_ptr |
RAII与智能指针 4.5 |
unique_ptr<Impl> 在头文件中编译失败:sizeof incomplete type |
PIMPL 模式中析构函数在头文件隐式生成,此时 Impl 是不完整类型 |
1. 在头文件中声明析构函数 ~MyClass(); 2. 在 .cpp 中定义 MyClass::~MyClass() = default;(此时 Impl 完整可见) |
RAII与智能指针 4.3, 编译模型基础 2.6 |
shared_ptr 密集使用场景下性能明显低于预期 |
shared_ptr 拷贝涉及原子引用计数操作(atomic_increment/decrement),在多线程高频拷贝时成为瓶颈 |
1. 用 perf 检查 __atomic_add_fetch 是否是热点 2. 函数参数改为 const shared_ptr<T>&(避免不必要的拷贝/引用计数增减) 3. 评估是否可改为 unique_ptr(无原子操作开销) |
RAII与智能指针 4.8 |
通过 shared_ptr 管理的对象析构顺序不确定,导致依赖关系崩溃 |
多个 shared_ptr 指向同一对象,最后一个释放者取决于运行时执行顺序 |
1. 明确所有权层级——谁是"主所有者" 2. 考虑改为 unique_ptr(所有权唯一,析构顺序确定) 3. 如果必须共享,用显式的 reset() 控制释放顺序 |
RAII与智能指针 4.11 |
本章小结¶
| 知识点 | 核心要义 | 难度 |
|---|---|---|
| RAII 原则 | 资源生命周期绑定对象生命周期,析构函数确定性释放 | ⭐⭐ |
unique_ptr |
独占所有权,零开销,不可拷贝只可移动 | ⭐⭐ |
shared_ptr |
共享所有权,引用计数,16 字节 + 原子操作开销 | ⭐⭐ |
weak_ptr |
非拥有观察,打破循环引用,lock() 原子安全访问 |
⭐⭐ |
| 所有权转移模式 | 函数签名表达所有权意图,5 种参数传递模式 | ⭐⭐ |
| RAII 封装 | lock_guard/unique_lock/ScopedTimer,超越内存 |
⭐⭐ |
enable_shared_from_this |
对象安全获取指向自身的 shared_ptr,CRTP 机制 |
⭐⭐⭐ |
| 性能考量 | unique_ptr 零开销,shared_ptr 原子操作开销,移动不涉及原子 |
⭐⭐⭐ |
一句话总结:unique_ptr 是默认选择(零开销 + 清晰语义),shared_ptr 用于真正的共享所有权,weak_ptr 打破循环——这三者加上 RAII 原则,构成了现代 C++ 资源管理的完整体系。理解了本章内容,你就明白了为什么 现代类设计与特殊成员函数 说"优先遵循 Rule of Zero"——因为智能指针已经替你做了资源管理。
现实审视:ORB-SLAM3(2021 年发表,机器人领域最有影响力的 SLAM 系统之一)全面使用裸指针——这说明即使不用智能指针也能写出可工作的大型系统。但 Kimera-VIO(MIT SPARK)展示了现代 C++ 风格如何让所有权关系更清晰、代码更安全。学习智能指针不只是"跟潮流",而是为了让类型系统帮你捕获所有权 bug。
类比:裸指针就像没有合同的口头协议——"你来负责释放这块内存"。在小团队、短周期项目中,口头协议能工作。但在大型系统中(ORB-SLAM3 有 30+ 源文件、数百个类),口头协议的维护成本随代码量指数增长。智能指针就像书面合同——所有权关系写在类型签名中,编译器自动执行。哪种更可靠,取决于你项目的规模和生命周期。
从 ORB-SLAM3 到 ORB-SLAM4 的设计演进预测:基于 C++ 社区的发展趋势和 Kimera-VIO 等现代 SLAM 代码库的示范效应,未来 SLAM 系统的设计可能会:(1) 用 unique_ptr 替代裸指针管理 MapPoint/KeyFrame 的所有权;(2) 用 shared_ptr 管理跨线程共享的数据结构;(3) 用 weak_ptr 打破 KeyFrame-MapPoint 的循环引用;(4) 用 RAII 的 lock_guard/scoped_lock 替代手动 lock/unlock。这些改进不会改变算法的数学本质,但会让代码更安全、更易维护。
累积项目:本章新增模块¶
Mini-SLAM 项目进度:
| 章节 | 模块 | 状态 |
|---|---|---|
| 类型系统与值类别推导 | 类型安全的传感器数据类型定义 | ✅ |
| 编译模型基础 | 编译单元拆分与头文件组织 | ✅ |
| 现代类设计与特殊成员函数 | Frame/MapPoint 类设计(特殊成员函数) | ✅ |
| RAII与智能指针 | 智能指针重构 + ScopedTimer 性能计时 | 🔧 本章 |
本章任务:将 现代类设计与特殊成员函数 中使用裸指针的 Frame/MapPoint 类改为智能指针管理。具体:
SensorDriver用unique_ptr管理(独占所有权——同一时刻只有一个管理器使用传感器)MapPoint用shared_ptr管理(共享所有权——多个 KeyFrame 观测同一个 MapPoint)KeyFrame在MapPoint::observations_中用weak_ptr(打破循环引用)- 实现
ScopedTimer用于各模块性能计时 - 工厂函数返回
unique_ptr——遵循 Kimera-VIO 的模式
延伸阅读¶
| 资源 | 内容 | 难度 |
|---|---|---|
| Effective Modern C++ Items 18-22 (Scott Meyers) | 智能指针最佳实践的权威指南 | ⭐⭐ |
| C++ Core Guidelines R 节 (R.1-R.37) | 工业级 RAII 与资源管理准则 | ⭐⭐ |
| GotW #89, #91 (Herb Sutter) | 智能指针选择与参数传递规则 | ⭐⭐⭐ |
| Raymond Chen "Inside STL" 系列 | shared_ptr 控制块的内部实现细节 |
⭐⭐⭐ |
| CnTransGroup/EffectiveModernCppChinese | 上述 Meyers 条款的中文翻译 | ⭐⭐ |
Light-City/CPlusPlusThings basic_content/ |
智能指针中文实战教程 | ⭐ |
Kimera-VIO Pipeline.h / Macros.h |
现代 C++ SLAM 代码的所有权设计典范 | ⭐⭐⭐ |
ORB-SLAM3 Atlas.h / MapPoint.h / KeyFrame.h |
裸指针风格——理解为什么需要智能指针 | ⭐⭐ |
RAPIDS RMM device_buffer.hpp |
GPU 内存 RAII 的工业级实现 | ⭐⭐⭐ |
Open3D core/CUDAUtils.h |
CUDA 设备 RAII 和内存管理 | ⭐⭐⭐ |
延伸方向:
- 移动语义与完美转发 将从使用者角度深入 std::move 和完美转发——本章从资源管理角度奠定了基础
- 继承与多态深入 将讨论 unique_ptr<Base> 管理多态对象时虚析构函数的必要性
- 并发编程 将深入讨论 shared_ptr 的线程安全边界和 atomic<shared_ptr>
智能指针与异常安全的深层关系¶
RAII 和智能指针经常被提及的优势是"防止内存泄漏",但更深层的价值在于**异常安全性**(exception safety)。在没有智能指针的代码中,异常会绕过 delete 语句导致泄漏;而 RAII 保证无论函数如何退出(正常返回、异常抛出、提前 return),析构函数都会被调用。
考虑一个 SLAM 系统中的典型场景:读取传感器数据、处理、写入地图。如果处理阶段抛出异常,传感器句柄和临时缓冲区都必须正确释放。
// ❌ 裸指针风格——异常不安全
void processScan(Sensor* sensor) {
auto* buffer = new float[1000000];
sensor->read(buffer); // 如果这里抛异常?
processPoints(buffer); // 如果这里抛异常?
delete[] buffer; // 永远执行不到
}
// ✅ RAII 风格——异常安全
void processScan(std::unique_ptr<Sensor>& sensor) {
auto buffer = std::make_unique<float[]>(1000000);
sensor->read(buffer.get()); // 即使抛异常,buffer 析构时自动释放
processPoints(buffer.get()); // 同上
} // buffer 离开作用域,自动释放
C++ 标准定义了三个异常安全级别:基本保证(不泄漏资源)、强保证(操作失败时状态回滚)、不抛出保证(操作永不抛异常)。unique_ptr 天然提供基本保证。配合 copy-and-swap 惯用法(在 现代类设计与特殊成员函数 中讨论过),可以实现强保证。
反事实推理:如果 C++ 没有 RAII,异常安全将极其困难。Java 和 Python 依赖垃圾回收处理内存,但文件句柄、网络连接等非内存资源仍需要
try-finally或with语句手动管理。C++ 的 RAII 统一了所有资源类型的安全释放机制——这是其他语言的 GC 无法替代的优势。
make_unique 和 make_shared 的异常安全动机¶
std::make_unique(C++14)和 std::make_shared(C++11)的引入动机之一是消除 new 表达式在函数参数求值中的异常安全隐患。在 C++17 之前,函数参数的求值顺序是未指定的:
// C++14 之前的潜在泄漏(C++17 修复了求值顺序问题)
f(std::shared_ptr<A>(new A), std::shared_ptr<B>(new B));
// 如果求值顺序是:new A → new B → shared_ptr<A> → shared_ptr<B>
// 且 new B 抛异常,则 new A 的内存泄漏
make_shared 和 make_unique 将 new 和智能指针构造合并为一个不可分割的操作,从根本上消除了这类问题。在工程实践中,应当始终优先使用 make_unique 和 make_shared,而非直接使用 new。
shared_ptr 别名构造器的高级用法¶
shared_ptr 有一个较少为人知的别名构造器(aliasing constructor),允许一个 shared_ptr 指向另一个 shared_ptr 所管理对象的子对象,同时共享引用计数。这在机器人系统中有实际用途:
struct RobotState {
Eigen::Vector3d position;
Eigen::Quaterniond orientation;
Eigen::VectorXd joint_positions;
};
auto state = std::make_shared<RobotState>();
// 创建指向 state->position 的 shared_ptr,但共享 state 的引用计数
std::shared_ptr<Eigen::Vector3d> pos_ptr(state, &state->position);
// pos_ptr 存活期间,整个 RobotState 对象都不会被释放
这个模式在需要把子对象传递给只接受 shared_ptr 的 API 时非常有用,同时保证了父对象的生命周期不会提前结束。
⚠️ 概念误区:别名构造器不是弱引用
别名构造器创建的 shared_ptr 增加了引用计数,会阻止原始对象的释放。这和 weak_ptr 完全不同——weak_ptr 不增加引用计数,不阻止释放。混淆两者会导致对象生命周期超出预期。
跨章综合练习(补充)¶
- [综合 类型系统与值类别推导+现代类设计与特殊成员函数+RAII与智能指针]
auto推导与智能指针工厂的类型推理: - (a) 给定工厂函数
auto createSensor(SensorType type) -> std::unique_ptr<SensorBase>,用decltype推导auto sensor = createSensor(SensorType::Lidar)的类型。解释为什么auto在这里推导出unique_ptr<SensorBase>而非unique_ptr<LidarSensor>。 - (b) 如果把工厂返回类型改为
auto(C++14 返回类型推导),且函数体中根据type返回不同的派生类unique_ptr,编译器会报什么错?为什么?(提示:多个 return 语句的类型必须一致。) - (c) 设计一个类型安全的传感器注册表,使用
std::unordered_map<std::string, std::function<std::unique_ptr<SensorBase>()>>存储工厂函数。解释为什么 map 的值类型不能是std::unique_ptr<SensorBase>而必须是工厂函数。 - (d) 在 (c) 的注册表基础上,实现一个
SensorManager类,它持有所有活跃传感器的unique_ptr。当传感器出错需要重新初始化时,SensorManager通过注册表重新创建传感器实例,旧的unique_ptr被新值覆盖后自动析构旧对象。解释为什么这个模式天然是异常安全的。
智能指针在多线程 SLAM 系统中的实践模式 ⭐⭐⭐¶
在多线程 SLAM 系统中,智能指针的使用模式比单线程场景复杂得多。ORB-SLAM3 使用三个主要线程(Tracking、LocalMapping、LoopClosing),它们共享对 MapPoint 和 KeyFrame 的访问。
所有权分离模式¶
最清晰的多线程所有权设计是:一个线程拥有(unique_ptr),其他线程观察(weak_ptr 或裸指针 + 生命周期保证)。
class Map {
// Map 拥有所有 MapPoint——单一所有者
std::vector<std::unique_ptr<MapPoint>> map_points_;
std::mutex map_mutex_;
public:
// 添加新点——所有权从调用者转移到 Map
void addMapPoint(std::unique_ptr<MapPoint> point) {
std::lock_guard lock(map_mutex_);
map_points_.push_back(std::move(point));
}
// 其他线程获取观察指针——不拥有,不延长生命周期
MapPoint* getMapPoint(size_t id) {
std::lock_guard lock(map_mutex_);
return map_points_[id].get();
}
};
这种模式的前提是 Map 的生命周期覆盖所有使用 MapPoint 的线程。如果不能保证这一点(如线程持有指针时 Map 可能被重置),则需要 shared_ptr + weak_ptr。
shared_ptr 的引用计数与 atomic 开销¶
shared_ptr 的拷贝涉及原子引用计数操作。在 x86 上,lock inc/lock dec 指令本身约 10-20 ns,但它们会导致包含引用计数的 cache line 在多核之间"乒乓"传递(MESI 协议的 Invalidate/Modified 状态切换)。在高频拷贝场景(如每帧遍历所有 MapPoint 并拷贝 shared_ptr),这个开销可能显著。
解决方案是函数参数使用 const shared_ptr<T>& 而非 shared_ptr<T> 值传递——引用传递不增加引用计数,避免了原子操作:
// ❌ 值传递——每次调用增减引用计数
void processPoint(std::shared_ptr<MapPoint> point) { ... }
// ✅ 引用传递——无原子操作开销
void processPoint(const std::shared_ptr<MapPoint>& point) { ... }
atomic<shared_ptr>(C++20)¶
C++20 引入了 std::atomic<std::shared_ptr<T>>,提供了对 shared_ptr 的线程安全读写。这比手动用互斥锁保护 shared_ptr 更高效,也比 std::atomic_load/std::atomic_store 的自由函数接口更自然:
// C++20:原子 shared_ptr
std::atomic<std::shared_ptr<RobotState>> latest_state;
// 写线程(状态估计)
void estimatorThread() {
auto new_state = std::make_shared<RobotState>(computeState());
latest_state.store(new_state); // 原子写入
}
// 读线程(控制器)
void controlThread() {
auto state = latest_state.load(); // 原子读取
computeControl(*state);
}
这个模式适合"最新值"语义——控制器总是读取最新的状态估计结果,不需要排队也不需要锁。但要注意:atomic<shared_ptr> 的实现可能使用内部自旋锁(在 libstdc++ 中是这样),在高争用场景下性能不如手工优化的无锁方案。
类比:
unique_ptr像房产证——一个房子只有一个所有者,转让需要过户。shared_ptr像合租协议——多人共同持有使用权,最后一个人搬走时负责退租。weak_ptr像邻居——知道这个房子在哪,但不持有使用权,进门前需要确认还有人住(lock())。atomic<shared_ptr>像公告栏上的最新房态——任何人都能安全地查看当前状态。