智能指针与循环引用
面试回答
常见问法
unique_ptr、shared_ptr、weak_ptr分别适用于什么场景?- 为什么说
shared_ptr不是“万能内存管理方案”? - 循环引用为什么会导致内存泄漏?
weak_ptr为什么能打破循环引用?它本身为什么不拥有对象?shared_ptr的控制块里通常放什么?- 为什么
unique_ptr不能拷贝但可以移动? make_shared和直接new再交给shared_ptr有什么区别?- 为什么不能从同一个裸指针构造多个
shared_ptr?
回答
C++ 智能指针本质上是在表达所有权语义,不是单纯替代 new/delete。
unique_ptr表示独占所有权:同一时刻只能有一个拥有者,不能拷贝、可以移动,开销最小,语义最清晰,默认优先使用。shared_ptr表示共享所有权:多个对象共同拥有同一资源,通过引用计数控制生命周期,适合确实无法明确唯一拥有者的场景。weak_ptr表示非拥有的观察关系:它不参与对象生命周期管理,不增加强引用计数,只能通过lock()临时拿到shared_ptr使用。
循环引用泄漏的根因是:
shared_ptr 依赖强引用计数归零来析构对象。如果两个对象互相持有 shared_ptr,即使外部已经不再使用它们,它们彼此之间仍然让对方的强引用计数大于 0,导致析构永远不会发生,这就是循环引用。
所以工程上要先区分清楚关系:
- 拥有关系:用
unique_ptr或必要时用shared_ptr - 观察关系 / 回指关系 / 父指针 / 缓存弱引用:优先用
weak_ptr
一句话总结:
先用 unique_ptr 表达独占,确实需要共享才用 shared_ptr,凡是“不拥有、只观察”的关系就考虑 weak_ptr。
追问
-
为什么
unique_ptr不能拷贝? 因为它语义上代表唯一拥有者,拷贝会破坏独占性;但可以移动,把所有权转移给别人。 -
weak_ptr::lock()的作用是什么? 它会检查对象是否还活着;如果对象还在,返回一个新的shared_ptr;如果已经销毁,返回空对象。这样可以安全访问资源。 -
shared_ptr控制块里通常有什么? 一般包含强引用计数、弱引用计数、删除器、分配器等元数据,有些实现还会管理对象本体或指向对象的指针。 -
为什么
shared_ptr有额外开销? 因为要维护控制块、引用计数,而且引用计数通常涉及原子操作,多线程下成本更明显。 -
为什么不能从同一个裸指针构造多个
shared_ptr? 因为会生成多个独立控制块,最后每个控制块都认为自己该释放对象,导致双重释放。
原理展开
1. 智能指针的核心不是“自动释放”,而是“所有权建模”
很多人把智能指针理解成“帮我自动 delete 的工具”,这是不够的。
面试更好的说法是:
智能指针首先解决的是资源所有权表达问题,其次才是自动释放。
比如:
- 资源只能有一个主人:
unique_ptr - 资源需要多方共同持有:
shared_ptr - 只是看一眼,不负责释放:
weak_ptr
这也是为什么工程实践中常说:
默认 unique_ptr,谨慎 shared_ptr,非拥有关系用 weak_ptr。
2. unique_ptr:独占所有权,默认首选
unique_ptr 是最接近“RAII + 零额外共享成本”的方案。
特点
- 独占所有权
- 不能拷贝
- 可以移动
- 几乎没有
shared_ptr那种引用计数成本 - 非常适合明确生命周期边界的对象
典型场景
- 工厂函数返回对象
- 类成员独占资源
- 容器里存放多态对象
- 文件句柄、socket、锁对象等独占资源封装
示例
#include <memory>
class Widget {};
std::unique_ptr<Widget> create_widget() {
return std::make_unique<Widget>();
}
void demo() {
auto p1 = create_widget();
// auto p2 = p1; // 错误:不能拷贝
auto p2 = std::move(p1); // 正确:转移所有权
}
面试加一句
如果对象生命周期非常清晰,就不要为了“省事”上来就用 shared_ptr,因为那是在用更复杂的语义解决更简单的问题。
3. shared_ptr:共享所有权,但共享一定要有理由
shared_ptr 通过控制块 + 引用计数管理对象生命周期。
基本机制
- 每拷贝一个
shared_ptr,强引用计数加一 - 每销毁一个
shared_ptr,强引用计数减一 - 当强引用计数变成 0 时,对象析构
- 控制块何时彻底释放,还取决于弱引用计数是否也归零
示例
#include <memory>
struct Node {};
void demo() {
std::shared_ptr<Node> p1 = std::make_shared<Node>();
std::shared_ptr<Node> p2 = p1; // 共享所有权
}
控制块通常包含
- 强引用计数(shared count)
- 弱引用计数(weak count)
- 删除器(deleter)
- 分配器(allocator)
- 对象本体或对象地址
为什么 shared_ptr 不能滥用
因为它带来的不是“更高级”,而是“更重的生命周期语义”:
- 有控制块开销
- 有引用计数维护成本
- 多线程下常涉及原子操作
- 容易隐藏真实所有权,导致设计模糊
- 容易形成循环引用
什么时候该用 shared_ptr
只有在下面这类问题中才真有必要:
- 对象确实有多个平级拥有者
- 无法自然选出唯一生命周期管理者
- 生命周期跨多个模块共享
比如:
- 共享缓存对象
- 异步任务之间共享上下文
- 图结构中被多个节点共同引用的公共数据
4. weak_ptr:不拥有对象,只观察对象
weak_ptr 的价值不在“省一次拷贝”,而在于表达非拥有关系。
它解决什么问题
- 打破
shared_ptr环 - 表达“我知道你存在,但我不负责延长你生命周期”
- 避免“为了访问对象而误用共享所有权”
使用方式
weak_ptr 不能直接解引用,必须先 lock():
#include <memory>
#include <iostream>
struct Node {
int value = 42;
};
void demo() {
auto sp = std::make_shared<Node>();
std::weak_ptr<Node> wp = sp;
if (auto locked = wp.lock()) {
std::cout << locked->value << '\n';
}
}
典型场景
- 观察者模式
- 缓存中的弱引用
- 父子结构中的父指针 / 回指关系
- 事件订阅回调里避免对象被“意外续命”
一个常见追问
为什么 weak_ptr 不增加对象强引用计数?
因为它的设计目标就是不拥有。如果它也增加强引用计数,那就无法打破循环引用了。
5. 循环引用为什么会泄漏
这类题面试经常要求你从“现象”讲到“根因”。
错误示例
#include <memory>
#include <string>
#include <vector>
struct Person {
std::string name;
std::shared_ptr<Person> partner;
Person(const std::string& n) : name(n) {}
~Person() { /* 期望被调用 */ }
};
void demo() {
auto a = std::make_shared<Person>("A");
auto b = std::make_shared<Person>("B");
a->partner = b;
b->partner = a;
} // 退出后 a 和 b 都不会析构
根因分析
退出 demo() 后:
- 局部变量
a销毁,A 的强引用计数减一 - 局部变量
b销毁,B 的强引用计数减一 - 但 A 还被 B 的
partner持有 - B 还被 A 的
partner持有
所以:
- A 的强引用计数不是 0
- B 的强引用计数也不是 0
结果就是两个对象都无法析构,形成泄漏。
正确做法
把非拥有的回指关系改成 weak_ptr:
#include <memory>
#include <string>
#include <vector>
struct TreeNode {
std::string name;
std::weak_ptr<TreeNode> parent; // 不拥有父节点
std::vector<std::shared_ptr<TreeNode>> children; // 拥有子节点
TreeNode(const std::string& n) : name(n) {}
};
面试里的高分表达
不要只说“用 weak_ptr 打破循环”,还要说明:
本质上不是“哪里循环就硬改成
weak_ptr”,而是先识别真实所有权。 父子结构里通常是父拥有子,子只回看父,所以父到子用拥有指针,子到父用非拥有指针。
这体现的是设计能力,而不是 API 背诵。
6. make_shared、make_unique 为什么推荐优先使用
make_unique
- 语义清晰
- 避免显式
new - 异常安全更好
auto p = std::make_unique<Widget>();
make_shared
通常优先于 shared_ptr<T>(new T(...)),因为:
- 一般只需要一次内存分配(对象和控制块可能一起分配)
- 性能和局部性通常更好
- 异常安全更自然
auto p = std::make_shared<Widget>();
但也要知道边界
不是所有场景都适合 make_shared:
- 需要自定义删除器时,通常更常见的是直接构造
shared_ptr - 对象很大且弱引用存在时间可能很长时,控制块与对象同块分配的策略有时会影响内存回收粒度,需要结合实现和场景判断
- 需要和特殊资源管理方式配合时,要看接口约束
7. 工程实践里的选择原则
这部分最像真实工作经验,也是面试加分点。
原则一:默认从 unique_ptr 开始设计
因为它最清楚地表达“谁负责销毁”。
原则二:只有当共享是业务事实时,才升级为 shared_ptr
不是“可能以后会共享”,而是“现在确实有多个拥有者”。
原则三:回指、观察、订阅关系优先考虑 weak_ptr
特别是:
- 父子节点
- 发布订阅
- 回调持有对象
- 缓存观察
原则四:尽量减少共享所有权的扩散
一旦全链路都传 shared_ptr,对象生命周期会变得模糊,很难定位谁真正负责资源释放。
原则五:接口设计尽量区分“使用对象”和“拥有对象”
比如:
- 只读访问:传
const T&或const T* - 临时使用但不接管所有权:传裸指针 / 引用
- 要接管所有权:传
unique_ptr - 要共享拥有:传
shared_ptr
这说明你不只是会用智能指针,而是理解接口语义设计。
8. 常见陷阱与边界问题
8.1 从裸指针构造多个 shared_ptr
错误示例:
Widget* raw = new Widget();
std::shared_ptr<Widget> p1(raw);
std::shared_ptr<Widget> p2(raw); // 严重错误:两个独立控制块
这会导致最终双重释放。
8.2 在对象内部错误使用 shared_from_this
如果一个对象想安全地拿到“指向自己的 shared_ptr”,应该继承 std::enable_shared_from_this<T>,并确保对象本身已经被 shared_ptr 托管。
#include <memory>
struct Session : std::enable_shared_from_this<Session> {
std::shared_ptr<Session> get_self() {
return shared_from_this();
}
};
如果对象不是由 shared_ptr 管理,直接调用 shared_from_this() 会出问题。
8.3 误以为 shared_ptr 能解决所有悬空问题
它只能解决共享拥有对象的生命周期管理,解决不了:
- 非法并发访问
- 循环依赖设计混乱
- 引用逃逸
- 逻辑层面的资源管理错误
8.4 把 weak_ptr 当成“更轻量的 shared_ptr”
weak_ptr 不是“低配拥有指针”,它是非拥有语义。
它不能直接访问对象,必须 lock(),而且使用前必须接受对象可能已经释放的事实。
对比总结
| 概念 | 所有权语义 | 是否可拷贝 | 是否可移动 | 生命周期管理方式 | 典型场景 | 优点 | 缺点 |
|---|---|---|---|---|---|---|---|
unique_ptr | 独占所有权 | 否 | 是 | 单一拥有者离开作用域即释放 | 工厂返回值、类成员资源、容器中的独占对象 | 开销低、语义清晰、默认首选 | 不能共享 |
shared_ptr | 共享所有权 | 是 | 是 | 控制块 + 强引用计数 | 共享缓存、异步共享上下文、复杂图结构中的公共对象 | 使用灵活,适合多拥有者 | 有额外开销,容易滥用,可能循环引用 |
weak_ptr | 非拥有观察关系 | 是 | 是 | 依附于 shared_ptr 控制块,不增加强引用计数 | 父指针、观察者、缓存弱引用、打破环 | 能表达观察关系,能打破循环引用 | 不能直接访问对象,需要 lock() |
unique_ptr vs shared_ptr
| 对比项 | unique_ptr | shared_ptr |
|---|---|---|
| 语义 | 唯一拥有者 | 多个拥有者 |
| 成本 | 低 | 高 |
| 是否适合作为默认选择 | 是 | 否 |
| 适用判断 | 生命周期边界清晰 | 生命周期天然共享 |
shared_ptr vs weak_ptr
| 对比项 | shared_ptr | weak_ptr |
|---|---|---|
| 是否拥有对象 | 是 | 否 |
| 是否增加强引用计数 | 是 | 否 |
| 能否直接访问对象 | 是 | 否,需要 lock() |
| 用途 | 共享拥有 | 观察 / 回指 / 打破环 |
make_shared vs shared_ptr(new T)
| 对比项 | make_shared | shared_ptr(new T) |
|---|---|---|
| 分配次数 | 通常更少 | 通常更多 |
| 异常安全 | 更自然 | 相对容易写出不够优雅的代码 |
| 是否推荐优先使用 | 是 | 特殊场景才考虑 |
易错点
- 以为智能指针的目的只是“代替
delete”,忽略了真正重要的是所有权语义 - 上来就用
shared_ptr,把它当成通用解法 - 无法区分“拥有关系”和“观察关系”
- 只会背“循环引用用
weak_ptr”,但说不清为什么 - 从同一个裸指针构造多个
shared_ptr,导致双重释放 - 不知道
weak_ptr不能直接解引用,必须先lock() - 误以为有了
shared_ptr就不会有生命周期问题 - 在接口上传来传去都是
shared_ptr,导致所有权边界越来越模糊 - 不清楚
unique_ptr不能拷贝的根因是语义而不是语法限制 - 不知道
make_shared/make_unique一般优于显式new
记忆技巧
-
unique_ptr:独占 关键词:唯一主人、能移动不能拷贝、默认首选 -
shared_ptr:共管 关键词:多人负责、引用计数、确实共享才用 -
weak_ptr:旁观 关键词:只观察、不负责、打破循环
一句口诀
能独占就独占,必须共享才共享,只观察就别拥有。
场景联想法
- 工厂函数返回对象:
unique_ptr - 多模块共享缓存:
shared_ptr - 子节点回看父节点:
weak_ptr
面试速答版
智能指针核心是表达所有权。unique_ptr 表示独占所有权,不能拷贝只能移动,开销最小,工程里默认优先用;shared_ptr 表示共享所有权,通过控制块里的引用计数管理生命周期,适合确实有多个拥有者的场景;weak_ptr 不拥有对象,只是观察 shared_ptr 管理的对象,常用来打破循环引用。
循环引用泄漏的原因是两个对象互相持有 shared_ptr,即使外部引用都释放了,它们内部的强引用计数也不会归零,所以析构不会发生。解决办法不是机械地“把一个改成 weak_ptr”,而是先识别真实所有权:拥有关系用 shared_ptr 或 unique_ptr,观察或回指关系改成 weak_ptr。
面试加分版
我通常把智能指针分成三类语义来理解:独占、共享、观察。
unique_ptr 表达独占所有权,语义最清晰、成本最低,所以在工程里应该作为默认选择;shared_ptr 表达共享所有权,底层依赖控制块维护强弱引用计数,适合对象确实存在多个平级拥有者的场景;weak_ptr 则不拥有对象,只是观察者,它不会增加强引用计数,所以很适合父指针、订阅关系、缓存弱引用这类非拥有关系。
循环引用的问题本质上是 shared_ptr 依赖强引用计数归零来销毁对象,而一旦两个对象互相持有 shared_ptr,哪怕外部已经不再使用,它们也会让彼此的强引用计数始终大于 0,导致析构永远不发生。比如双向链表、父子节点、观察者关系里,如果两边都用 shared_ptr,就很容易形成闭环。
所以我在设计上会先问一个问题:谁真正拥有谁?
如果是拥有关系,就用 unique_ptr 或在必要时用 shared_ptr;如果只是回指、观察、回调引用,就应该用 weak_ptr。这比单纯记 API 更重要,因为它反映的是对象生命周期设计是否清晰。
工程实践里,我的选择原则一般是:
第一,默认 unique_ptr;第二,只有共享是业务事实时才用 shared_ptr;第三,凡是非拥有关系优先考虑 weak_ptr;第四,尽量避免接口到处传 shared_ptr,否则所有权边界会越来越模糊。
另外还要注意两个典型坑:一是不能从同一个裸指针构造多个 shared_ptr,否则会双重释放;二是一般优先用 make_unique 和 make_shared,因为写法更安全、也更符合现代 C++ 风格。