C++ Smart Pointers
裸指针(new/delete)的两大顽疾:忘记释放导致内存泄漏,重复释放或异常路径跳过释放导致未定义行为。智能指针用 RAII 把“获取即初始化”贯彻到指针上——构造时持有资源,析构时自动释放,从而让资源管理与控制流解耦。
C++11 后标准库提供三类智能指针(均在 <memory>):std::unique_ptr、std::shared_ptr、std::weak_ptr,加上已废弃的 std::auto_ptr。理解它们的关键不是 API,而是所有权模型。
所有权模型三问
选哪个智能指针,只需回答三个问题:
| 问题 | 答案 | 选择 |
|---|---|---|
| 资源是否独占? | 是 | unique_ptr |
| 是否需要共享? | 是 | shared_ptr |
| 是否需要观察但不拥有? | 是 | weak_ptr |
默认选 unique_ptr,需要共享才升级到 shared_ptr。绝大多数场景独占就够。
unique_ptr:独占所有权
unique_ptr 表达严格独占:同一时刻只有一个 unique_ptr 拥有该对象。它不可拷贝,只能移动。
1 | |
为什么默认用 unique_ptr
- 零开销抽象:大小与裸指针相同(默认情况下),无引用计数开销。
- 可自定义删除器:能管理任意资源(文件、socket、C API 句柄)。
- 可高效转换为 shared_ptr:
std::shared_ptr<T> sp = std::move(up);直接移交,反之不行。 - 表达意图清晰:API 接收
unique_ptr参数即声明“我要接管所有权”。
自定义删除器
unique_ptr 的删除器是类型的一部分,这让它能管理非内存资源且无运行时开销:
1 | |
注意:删除器类型不同,
unique_ptr<FILE, DeleterA>与unique_ptr<FILE, DeleterB>是不同类型,不能互相赋值。这也是shared_ptr的优势之一——删除器不影响类型。
数组特化
unique_ptr<T[]> 提供了数组特化,重载了 operator[]:
1 | |
现代 C++ 更推荐
std::vector或std::array替代裸数组,仅在对接 C API 或性能极端敏感时才用unique_ptr<T[]>。
shared_ptr:共享所有权
shared_ptr 允许多个指针共享同一对象,通过引用计数(reference counting)管理生命周期:每多一个 shared_ptr 指向对象,计数 +1;销毁一个,计数 -1;归零时释放对象。
控制块(Control Block)
每个被共享对象关联一个控制块,包含:
- 强引用计数(use_count):指向对象的
shared_ptr数量。 - 弱引用计数(weak_count):指向控制块的
weak_ptr数量 + 1(实现细节)。 - 删除器、分配器。
控制块是 shared_ptr 开销的来源:原子计数 + 一次堆分配(控制块本身)。make_shared 能把对象和控制块合并为一次分配。
make_shared 的优势
1 | |
make_shared 把对象和控制块分配在同一块内存,优势:
- 单次分配:性能更好,减少内存碎片。
- 局部性好:对象和控制块同址,缓存友好。
- 异常安全:避免
f(std::shared_ptr<T>(new T), g())中g()抛异常导致泄漏。
但有一个权衡:make_shared 导致对象内存延迟释放。只要还有 weak_ptr 存活,控制块就不销毁,对象内存(即使已析构)也无法归还,直到所有 weak_ptr 也消失。对大对象或需尽快归还内存的场景,改用 shared_ptr(new T)。
shared_ptr 的线程安全性
一个常被混淆的点:shared_ptr 的线程安全是分层的:
- 控制块计数:原子操作,线程安全。多线程拷贝/销毁不同
shared_ptr(指向同一对象)是安全的。 shared_ptr对象本身:不是线程安全。多线程读写同一个shared_ptr变量需要加锁(或用std::atomic<std::shared_ptr>,C++20)。- 指向的对象:与
shared_ptr无关,由用户保证。
1 | |
智能指针与标准容器
标准容器存放的是值。当这个值是智能指针时,容器管理的是“指针对象”的生命周期,而智能指针再管理“被指向对象”的生命周期:
std::vector<std::unique_ptr<T>>:容器元素不可拷贝,只能移动;删除元素会释放其拥有的对象。std::vector<std::shared_ptr<T>>:容器元素可拷贝;拷贝会增加引用计数,删除元素只减少计数,不一定释放对象。std::vector<T>:若不需要多态、可空、共享或稳定地址,优先直接存值,最简单也最 cache-friendly。
顺序容器:vector / deque / list
vector<unique_ptr<T>> 是最常见的“拥有一组多态对象”的写法:
1 | |
因为 unique_ptr 不可拷贝,插入已有指针时必须显式移动:
1 | |
vector 扩容时会移动其中的 unique_ptr。这只移动指针本身,不会移动被管理的对象,所以 widgets[i].get() 指向的对象地址不变;但指向容器元素本身的迭代器、引用、指针仍可能失效。
1 | |
deque<unique_ptr<T>> 适合两端频繁插入;list<unique_ptr<T>> 适合需要稳定迭代器/节点、频繁拼接或中间删除的场景。但 list 牺牲局部性,默认仍优先考虑 vector。
shared_ptr 放进顺序容器时要注意“拷贝就是共享”:
1 | |
如果只是遍历调用,不要为了“方便”把裸对象批量包成 shared_ptr。shared_ptr 应表达真实共享所有权,而不是替代引用或裸指针观察。
关联容器:map / unordered_map
智能指针更常作为 map / unordered_map 的 value,而不是 key:
1 | |
少用 operator[] 插入 unique_ptr value,因为它会先默认构造一个空指针:
1 | |
shared_ptr 作为 value 时,erase 只表示“容器不再共享”:
1 | |
把智能指针作为 key 要非常谨慎:默认比较的是指针地址,不是对象内容。
1 | |
如果想按对象内容排序,提供比较器,并保证对象中参与排序的字段在容器内不被修改:
1 | |
如果想按 shared_ptr 的所有权控制块比较(而不是裸地址),用 std::owner_less,常见于需要把 aliasing shared_ptr 或 weak_ptr 放进有序容器的场景:
1 | |
容器适配器:queue / stack / priority_queue
queue 和 stack 可以存 unique_ptr,入队/入栈时移动所有权:
1 | |
priority_queue<unique_ptr<T>> 能存,但取出最大元素很别扭:top() 返回 const_reference,不能直接把 unique_ptr move 出来。若确实需要“按优先级拥有任务并取出”,通常用 std::vector<std::unique_ptr<T>> 配合 std::ranges::push_heap / std::ranges::pop_heap,或改用能自然共享的 shared_ptr。
容器中的选择规则
| 场景 | 推荐写法 |
|---|---|
| 同类型对象集合,无多态、不共享 | std::vector<T> |
| 多态对象集合,容器独占对象 | std::vector<std::unique_ptr<Base>> |
| 对象需要被多个模块共同持有 | std::vector<std::shared_ptr<T>> |
| 容器只是索引/缓存,不应延长生命周期 | std::vector<std::weak_ptr<T>> 或裸指针/引用(需明确生命周期) |
| 按 id 查找并独占对象 | std::unordered_map<Id, std::unique_ptr<T>> |
| 按 id 查找并共享对象 | std::unordered_map<Id, std::shared_ptr<T>> |
下面把“存”和“取”两个方向单独展开——它们的所有权语义最容易踩坑,且 unique_ptr(只能 move)与 shared_ptr(拷贝即共享)的写法截然不同。
存入容器:插入与所有权转移
1. 就地构造,最干净
把 make_unique/make_shared 直接写在插入点,没有中间变量,所有权一路移交,不存在“忘记 move”的问题:
1 | |
2. 移动已有指针进容器
unique_ptr 不可拷贝,插入已存在的指针必须 std::move,源指针随即变 nullptr(与 [[cxx-move-semantics]] 一致):
1 | |
3. emplace_back 不会帮你省掉 move
常见误解:以为 emplace_back 能“原地构造”从而避免移动。但容器元素类型本身就是 unique_ptr<T>,emplace_back 收到的实参仍是 unique_ptr<T>,照样要移动一次指针。真正能省移动的是“原地构造被管理对象”——但那要求容器存值 T 而非 unique_ptr<T>。所以对智能指针容器,push_back(std::move(p)) 和 emplace_back(std::make_unique<T>()) 在移动次数上等价,选哪个看可读性。
4. vector 预先 reserve
unique_ptr/shared_ptr 本身可高效移动,但扩容会搬动整个元素数组、使指向容器元素的迭代器/引用失效(指向被管理对象的指针不受影响)。规模已知时先 reserve:
1 | |
5. map 插入:try_emplace 优先,按需用 insert_or_assign
operator[] 插入 unique_ptr value 会先默认构造一个 nullptr 槽、再 move 赋值,多一次无谓操作,且要求 value 可默认构造。try_emplace 只在 key 不存在时才构造,语义干净;insert_or_assign 则用于“存在就替换”:
1 | |
shared_ptrvalue 的插入同理,但多一个维度:emplace/try_emplace的实参若直接传make_shared的结果(右值),是移动、计数不变;若传一个具名shared_ptr变量(左值),则拷贝、计数 +1。是否共享就在这一步决定,写代码时要看清传的是右值还是左值。
从容器取出:访问与所有权移交
“取”分两类:只观察、不改所有权,和把所有权拿走。两者写法不同,混用会出 bug。
1. 只观察:用引用,不要拷贝指针
遍历或按位访问时,用 const auto&(顺序容器)或 it->second(map)拿到智能指针的引用,再 -> 调用,全程不动计数:
1 | |
读 map 时切忌
sessions["alice"]->touch():operator[]在 key 不存在时会插入一个空nullptr槽,既污染容器又解引用空指针。查找一律用find/contains。
需要把对象交给不接受智能指针的接口时,用 .get() 取裸指针,仅作观察用,不要 delete、不要存起来超过容器寿命:
1 | |
2. 从 vector 拿走所有权
std::move 把某个槽位的 unique_ptr 搬空,槽位留下 nullptr,对象所有权转出;之后通常要 erase 掉这个空槽:
1 | |
但中间 erase 是 O(n)(后面元素逐个前移)。要 O(1) 删除且不在乎顺序时,用 swap-and-pop:把末尾元素交换到待删位置再 pop_back。对智能指针,交换的只是指针本身,被管理对象不动:
1 | |
3. 从 map 拿走所有权:extract 节点句柄(C++17)
extract 把节点从容器中摘下并返回节点句柄,被管理的对象既不拷贝也不析构,可以直接把 unique_ptr move 出来:
1 | |
extract 比“find + std::move(it->second) + erase”更直接:不留下空槽、不触发 rehash,还能把节点 insert 进另一个同类型 map 实现零拷贝迁移。
4. shared_ptr 的“取”要看清计数
从 vector<shared_ptr<T>> 取出,是观察还是共享,取决于写法:
1 | |
若调用方只需本次使用对象,传 const& 或裸指针(.get())即可,避免无谓的原子计数增减;只有确实要延长生命周期时才拷贝出 shared_ptr。
weak_ptr:不占有的观察者
weak_ptr 是 shared_ptr 的“弱引用”:指向控制块但不增加强引用计数,因此不会延长对象生命周期。主要解决两个问题:
- 打破
shared_ptr循环引用。 - 观察对象但不拥有(如缓存、观察者模式,需先检查对象是否存活)。
循环引用问题
1 | |
解法:把其中一环改为 weak_ptr:
1 | |
lock():安全访问
weak_ptr 不能直接访问对象(对象可能已销毁)。用 lock() 提升为 shared_ptr:
1 | |
lock() 等价于 shared_ptr<T>(wp) 的原子化版本,避免“检查存活”与“使用”之间的竞态。也可用 expired() 检查,但 lock() 更安全(原子)。
enable_shared_from_this
当对象需要在成员函数中返回指向自身的 shared_ptr 时,直接 shared_ptr<X>(this) 会创建新的控制块,导致双重释放:
1 | |
正确做法是继承 std::enable_shared_from_this:
1 | |
shared_from_this()要求对象本身已由shared_ptr管理,否则 UB。构造函数中(对象尚未被管理)不能调用。
enable_shared_from_this 的原理
enable_shared_from_this 内部持有一个 weak_ptr<T> weak_this。当 shared_ptr 第一次构造该对象时,会检查它是否继承自 enable_shared_from_this,若是则把 weak_this 指向自己(已有的控制块)。之后 shared_from_this() 即 lock() 该 weak_ptr,复用同一控制块。
aliasing constructor:共享所有权但指向别处
shared_ptr 的别名构造函数允许两个 shared_ptr 共享一个控制块(生命周期绑定),却指向不同对象:
1 | |
用于把大对象的一部分以 shared_ptr 暴露出去,同时保证父对象存活。
常见陷阱
1. 用裸指针构造多个 shared_ptr
1 | |
2. 在对象内部返回 this
见上文 enable_shared_from_this。
3. 误以为 shared_ptr 线程安全
对象本身和 shared_ptr 变量仍需同步保护(见“线程安全性”节)。
4. 循环引用
shared_ptr 互相持有导致泄漏,用 weak_ptr 打破(见上文)。
5. make_shared 与弱引用延长内存
weak_ptr 长期存活时,make_shared 的对象内存延迟释放。需尽快释放大对象内存时避免 make_shared。
与移动语义的关系
智能指针的移动是“所有权转移”而非“资源拷贝”,与 [[cxx-move-semantics]] 一致:
1 | |
shared_ptr 移动构造比拷贝构造更高效:拷贝要原子地增加计数,移动则直接搬指针、计数不变。容器中存放 shared_ptr 时,优先 emplace_back(std::move(sp)) 而非 push_back(sp)。
选型决策树
1 | |
小结
- 默认
unique_ptr:零开销、表达独占、可转shared_ptr。 shared_ptr用于真正的共享:注意控制块、make_shared的内存权衡、线程安全分层。weak_ptr打破循环与观察:用lock()安全访问。enable_shared_from_this:对象内返回自身shared_ptr的唯一正确方式。- 优先
make_unique/make_shared:异常安全、性能更好。
智能指针是现代 C++ 资源管理的基石,配合 [[cxx-rvo]]、[[cxx-special-member-functions]] 和 [[cxx-new-and-delete]],基本可以告别裸 delete。









