C++ Smart Pointer

本文意在阐述 C++ 智能指针的实现原理,应用场景和潜在的坑。

shared_ptr

基于引用计数的智能指针。在构造和析构时修改引用计数。当引用计数为 0 时,析构资源。

单个引用计数器是线程安全的。但是,shared_ptr 并非线程安全,至少因为两个原因:

  1. 「改写资源指针」与「改写引用计数」,这两步并非原子化。
  2. 「改写共享引用计数」与「改写弱引用计数」,这两步也非原子化。

陈硕的书和博客给出了一个竞争条件的例子。如果要从多个线程读写同一个 shared_ptr 对象,是需要加锁的。

shared_ptr 的实现原理

  1. 一个 shared_ptr 持有两个裸指针,分别指向资源和「控制块」 (control block)
  2. 控制块主要用于实现引用计数。具体的内容有:
    1. 指向所管理资源的指针,或者是资源本身
    2. deleter(已擦除类型)
    3. allocator(已擦除类型)
    4. shared_ptr 共享引用计数器
    5. weak_ptr 弱引用计数器 (主流实现中 shared_ptr 亦计入弱引用计数)
  3. 若使用 make_sharedallocate_shared 来构造指针,则资源和控制块一同参与内存申请。这是潜在坑点。
  4. 若弱引用计数器不清零,则控制块占用的内存不会被回收。因此若控制块很大(直接存放了资源),则空间性能可能变差。
1
2
3
4
5
6
7
8
// 别名构造函数(aliasing constructor)
template< class Y >
shared_ptr( const shared_ptr<Y>& r, element_type* ptr ) noexcept;
/*
新智能指针共享与 r 的控制块,但是 this->get() 总是返回 ptr,这可以与 r.get() 不相同!

场景:ptr 是 *r 的数据成员,若 r 存活,则 ptr 必定存活。
*/

shared_ptr 的潜在坑点

  1. 环形引用导致内存泄漏:使用一个 weak_ptr 来打破环形引用。
  2. 不支持 shared_ptr<T[]>:使用 vectorarray 作容器。
  3. 容许基类析构函数不是虚函数(似乎是因为构造是泛型的):小心。
  4. 退化成链状的数据结构在析构时栈溢出:反思数据结构的退化,以及是否滥用了 shared_ptr
  5. 重复使用一个裸指针构造两个 shared_ptr,导致双重释放:
    1. 裸指针用完就扔。
    2. 不要使用 this 来构造智能指针,而是使用 shared_from_this这是潜在坑点。
  6. 若没有任何 shared_ptr 持有对象,此时调用 shared_from_this 就会出问题:
    1. C++17 之前,这是 UB。
    2. C++17 以后,程序抛出 std::bad_weak_ptr 异常。
    3. 最佳实践是禁止访问构造函数,而提供工厂函数返回智能指针。
  7. shared_ptr 复制给未知的函数,可能导致环形引用。
    • 好比不要在上锁后执行为未知用户代码
    • GC 语言同样有回调地狱导致内存泄漏的问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// enable_shared_from_this 最佳实践
struct Best : std::enable_shared_from_this<Best> // 公有继承
{
std::shared_ptr<Best> getptr() {
return shared_from_this();
}
// 工厂函数返回智能指针
// nodiscard 防止暴毙
[[nodiscard]] static std::shared_ptr<Best> create() {
// 不能使用 make_shared 因为构造函数是私有的,不能转发
return std::shared_ptr<Best>(new Best());
}
private:
Best() = default;
};

shared_ptr 的应用场景/条件

  1. 多线程/异步协作,难以确定资源生命周期。
  2. 主从关系明确,不存在环形引用:树、链表、DAG
  3. 可以在构造函数参数中提供自定义析构器:[](Y* rc) { delect rc; }
  4. 原本使用 unique_ptr 的数据结构,需要对外提供强引用,则可以改用 shared_ptr

unique_ptr

独占所有权的智能指针。当持有资源且退出作用域时,会析构资源。所有权只能移动或引用,不能拷贝。

unique_ptr 的潜在坑点

  1. 退化成链的数据结构在析构时栈溢出:反思数据结构;重写数据结构的析构函数。

unique_ptr 的应用场景

  1. 提供动态对象的 RAII,保证发生异常时对象仍被析构。
  2. 表示对堆对象的独占关系。适合用作类的数据成员,例如表示树的儿子。
    • 父亲指针永远只用裸指针,因为儿子存活时父亲一定存活
    • 单/双向链表是树的特例,也适用 unique_ptr + 裸指针。
  3. 在接口中明确表达「传递所有权」的语义。
  4. 作为具有移动语义的单个容器使用。
  5. 管理运行时确定长度的定长数组(unique_ptr<T[]> 正确使用 new [] delete []
  6. 用于 Pimpl 编译隔离
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Pimpl 抽象
template<class T>
using Pimpl = const unique_ptr<T>;

class MyClass {
class Impl; // 定义于 *.cpp
Pimpl<Impl> pimpl;
/*...*/
};

// 运行时定长数组
class MyClass {
const unique_ptr<Data[]> array;
size_t arr_size;

MyClass(size_t size): arr_size(make_unique<Data[]>(size)) {}
/*...*/
};

weak_ptr

shared_ptr 构造而来,但不参与引用计数。

weak_ptr 的潜在坑点

良好的设计使得 weak_ptr 坑点较少,只需记得弱引用的存在会使得控制块无法释放即可。

weak_ptr 的应用场景

  1. 打破 shared_ptr 的循环引用。常用于环形链表、回调函数。
  2. 作为「可空资源的观察者」。根据资源是否为空表现不同的行为。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// weak_ptr 做回调
void bad(const shared_ptr<X>& x) {
obj.on_draw([=]{ x->extra_work(); }); // x 已经泄露
}

void good(const shared_ptr<X>& x) {
obj.on_draw([w=weak_ptr<X>(x)] {
if (auto x = w.lock()) x->extra_work(); // x 有机会析构
});
}


// weak_ptr 做 cache
shared_ptr<Payload> create(int id) {
static map<int, weak_ptr<Payload>> cache;
static mutex mut_cache;
lock_guard<mutex> hold(mut_cache);
auto sp = cache[id].lock();
if (!sp) cache[id] = sp = make_shared<Payload>();
return sp;
}

参考资料

  1. cppreference
  2. CppCon 2016: Herb Sutter “Leak-Freedom in C++... By Default.”
  3. Senlin《谈谈 shared_ptr 的那些坑》