C++ Smart Pointer
本文意在阐述 C++ 智能指针的实现原理,应用场景和潜在的坑。
shared_ptr
基于引用计数的智能指针。在构造和析构时修改引用计数。当引用计数为 0 时,析构资源。
单个引用计数器是线程安全的。但是,shared_ptr 并非线程安全,至少因为两个原因:
- 「改写资源指针」与「改写引用计数」,这两步并非原子化。
- 「改写共享引用计数」与「改写弱引用计数」,这两步也非原子化。
陈硕的书和博客给出了一个竞争条件的例子。如果要从多个线程读写同一个 shared_ptr 对象,是需要加锁的。
shared_ptr 的实现原理
- 一个
shared_ptr
持有两个裸指针,分别指向资源和「控制块」 (control block) - 控制块主要用于实现引用计数。具体的内容有:
- 指向所管理资源的指针,或者是资源本身
- deleter(已擦除类型)
- allocator(已擦除类型)
shared_ptr
共享引用计数器weak_ptr
弱引用计数器 (主流实现中shared_ptr
亦计入弱引用计数)
- 若使用
make_shared
或allocate_shared
来构造指针,则资源和控制块一同参与内存申请。这是潜在坑点。 - 若弱引用计数器不清零,则控制块占用的内存不会被回收。因此若控制块很大(直接存放了资源),则空间性能可能变差。
1 | // 别名构造函数(aliasing constructor) |
shared_ptr 的潜在坑点
- 环形引用导致内存泄漏:使用一个
weak_ptr
来打破环形引用。 - 不支持
shared_ptr<T[]>
:使用vector
或array
作容器。 - 容许基类析构函数不是虚函数(似乎是因为构造是泛型的):小心。
- 退化成链状的数据结构在析构时栈溢出:反思数据结构的退化,以及是否滥用了
shared_ptr
。 - 重复使用一个裸指针构造两个
shared_ptr
,导致双重释放:- 裸指针用完就扔。
- 不要使用
this
来构造智能指针,而是使用shared_from_this
。这是潜在坑点。
- 若没有任何
shared_ptr
持有对象,此时调用shared_from_this
就会出问题:- C++17 之前,这是 UB。
- C++17 以后,程序抛出
std::bad_weak_ptr
异常。 - 最佳实践是禁止访问构造函数,而提供工厂函数返回智能指针。
- 将
shared_ptr
复制给未知的函数,可能导致环形引用。- 好比不要在上锁后执行为未知用户代码
- GC 语言同样有回调地狱导致内存泄漏的问题
1 | // enable_shared_from_this 最佳实践 |
shared_ptr 的应用场景/条件
- 多线程/异步协作,难以确定资源生命周期。
- 主从关系明确,不存在环形引用:树、链表、DAG
- 可以在构造函数参数中提供自定义析构器:
[](Y* rc) { delect rc; }
。 - 原本使用
unique_ptr
的数据结构,需要对外提供强引用,则可以改用shared_ptr
。
unique_ptr
独占所有权的智能指针。当持有资源且退出作用域时,会析构资源。所有权只能移动或引用,不能拷贝。
unique_ptr 的潜在坑点
- 退化成链的数据结构在析构时栈溢出:反思数据结构;重写数据结构的析构函数。
unique_ptr 的应用场景
- 提供动态对象的 RAII,保证发生异常时对象仍被析构。
- 表示对堆对象的独占关系。适合用作类的数据成员,例如表示树的儿子。
- 父亲指针永远只用裸指针,因为儿子存活时父亲一定存活
- 单/双向链表是树的特例,也适用
unique_ptr
+ 裸指针。
- 在接口中明确表达「传递所有权」的语义。
- 作为具有移动语义的单个容器使用。
- 管理运行时确定长度的定长数组(
unique_ptr<T[]>
正确使用new []
delete []
) - 用于 Pimpl 编译隔离
1 | // Pimpl 抽象 |
weak_ptr
从 shared_ptr
构造而来,但不参与引用计数。
weak_ptr 的潜在坑点
良好的设计使得 weak_ptr
坑点较少,只需记得弱引用的存在会使得控制块无法释放即可。
weak_ptr 的应用场景
- 打破
shared_ptr
的循环引用。常用于环形链表、回调函数。 - 作为「可空资源的观察者」。根据资源是否为空表现不同的行为。
1 | // weak_ptr 做回调 |