一、为什么有了unique_ptr还需要shared_ptr
上一篇手写的unique_ptr强调:
一个资源 ↓ 只能有一个所有者例如:
MyUniquePtr<int> p1(new int(10));此时:
p1 ↓ 10不能再直接:
MyUniquePtr<int> p2 = p1;因为unique_ptr禁止拷贝。
如果真的要把资源交给p2:
MyUniquePtr<int> p2(std::move(p1));执行的是:
所有权转移最终:
p1 → nullptr p2 → 10但是实际开发中,有时候一块资源确实需要被多个对象共同使用。
例如:
对象A ↓ Resource ↑ 对象B对象 A 和对象 B 都需要访问同一个资源。
如果仍然使用独占所有权,就会比较麻烦。
于是就有了:
shared_ptr它允许:
多个智能指针 ↓ 共同拥有同一块资源例如:
std::shared_ptr<int> p1(new int(10)); std::shared_ptr<int> p2 = p1;此时:
p1 ──┐ ├──→ 10 p2 ──┘但这又带来了一个新的问题:
到底应该由谁delete?如果:
p1析构的时候直接:
delete ptr_;那么:
p2就会变成悬空指针。
所以shared_ptr必须知道:
当前还有多少个shared_ptr 正在使用这块资源这就是:
引用计数 Reference Count二、引用计数到底是怎么工作的
假设刚创建:
MySharedPtr<int> p1(new int(10));此时只有:
p1管理资源。
所以:
引用计数 = 1结构可以理解成:
p1 ↓ ptr_ ─────────→ 10 count_ ───────→ 1现在执行:
MySharedPtr<int> p2 = p1;因为多了一个共享所有者:
引用计数 1 → 2此时:
p1 ──┐ ├──→ 10 p2 ──┘ 引用计数 = 2如果再执行:
MySharedPtr<int> p3 = p2;那么:
引用计数 2 → 3现在:
p1 ──┐ p2 ──┼──→ 10 p3 ──┘ 引用计数 = 3接下来假设:
p3离开作用域那么:
引用计数 3 → 2但是:
不能释放资源因为还有:
p1 p2正在使用。
接着:
p2离开作用域引用计数:
2 → 1还是不能释放。
最后:
p1离开作用域引用计数:
1 → 0此时已经没有任何所有者,所以:
delete ptr_;真正释放资源。
因此shared_ptr的核心逻辑可以总结成:
创建shared_ptr ↓ count = 1 ↓ 发生拷贝 ↓ count++ ↓ 对象析构 ↓ count-- ↓ count == 0 ? ↙ ↘ 是 否 ↓ ↓ 释放资源 不释放所以手写shared_ptr最关键的就是:
所有共享对象 必须共同看到同一个引用计数不能每个shared_ptr自己保存:
size_t count_;因为这样:
p1.count_ = 1 p2.count_ = 1二者互相不知道对方的存在。
因此简化实现中可以保存:
size_t *count_;让多个MySharedPtr:
共同指向同一个引用计数结构变成:
p1.ptr_ ────────┐ ├──→ 10 p2.ptr_ ────────┘ p1.count_ ──────┐ ├──→ 2 p2.count_ ──────┘这就是引用计数能够共享的原因。
三、拷贝构造、赋值和析构怎么写
先定义一个基本结构:
template <typename T> class MySharedPtr { private: T *ptr_; // 真正管理的对象 size_t *count_; // 共享引用计数 public: explicit MySharedPtr(T *ptr = nullptr) : ptr_(ptr), count_(ptr ? new size_t(1) : nullptr) { } };例如:
MySharedPtr<int> p1(new int(10));此时:
ptr_ ↓ 10 count_ ↓ 11. 拷贝构造
shared_ptr和unique_ptr最大的区别之一就是:
shared_ptr允许拷贝所以需要实现:
MySharedPtr(const MySharedPtr &other) : ptr_(other.ptr_), count_(other.count_) { if (count_) ++(*count_); }例如:
MySharedPtr<int> p1(new int(10)); MySharedPtr<int> p2(p1);首先:
ptr_ = other.ptr_;让:
p1 p2指向同一个对象。
接着:
count_ = other.count_;让二者共享同一个引用计数。
最后:
++(*count_);引用计数:
1 → 2最终:
p1 ──┐ ├──→ 10 p2 ──┘ count = 22. 析构函数
析构的时候不能直接:
delete ptr_;而是应该:
先减少引用计数因此可以把释放逻辑单独封装成:
void release() { if (!count_) return; --(*count_); if (*count_ == 0) { delete ptr_; delete count_; } }析构函数直接:
~MySharedPtr() { release(); }假设:
count = 3一个对象析构:
3 → 2不会释放资源。
直到:
1 → 0才执行:
delete ptr_; delete count_;为什么除了:
delete ptr_;还需要:
delete count_;因为我们的引用计数本身也是:
new size_t(1)动态申请出来的。
所以引用计数没人使用以后也需要释放。
3. 拷贝赋值
现在考虑:
MySharedPtr<int> p1(new int(10)); MySharedPtr<int> p2(new int(20)); p2 = p1;赋值之前:
p1 → 10 count = 1 p2 → 20 count = 1执行:
p2 = p1;首先不能直接:
p2.ptr_ = p1.ptr_;因为这样:
20原来那块资源就没人管理了,会造成:
内存泄漏所以应该先释放p2原来拥有的资源:
release();然后再接管:
ptr_ = other.ptr_; count_ = other.count_;最后:
++(*count_);完整实现:
MySharedPtr &operator=(const MySharedPtr &other) { if (this != &other) { release(); ptr_ = other.ptr_; count_ = other.count_; if (count_) ++(*count_); } return *this; }最终:
p1 ──┐ ├──→ 10 p2 ──┘ count = 2而原来:
20对应的引用计数已经降为:
0所以被释放。
这里的:
if (this != &other)是为了避免:
p1 = p1;这种自赋值情况。
否则可能先把自己的引用计数减掉甚至释放资源,然后又去访问已经失效的数据。
四、移动语义和完整MySharedPtr实现
虽然shared_ptr支持拷贝,但它同样支持:
移动例如:
MySharedPtr<int> p1(new int(10)); MySharedPtr<int> p2(std::move(p1));移动和拷贝最大的区别在于:
拷贝 ↓ 增加一个所有者 ↓ 引用计数 +1而:
移动 ↓ 只是把当前所有权交给另一个对象 ↓ 所有者数量没有增加所以移动构造:
MySharedPtr(MySharedPtr &&other) noexcept : ptr_(other.ptr_), count_(other.count_) { other.ptr_ = nullptr; other.count_ = nullptr; }注意这里:
引用计数不用+1因为:
p1已经不再是所有者了。
原来:
p1 → 10 count = 1移动以后:
p1 → nullptr p2 → 10 count = 1所有者数量仍然:
1移动赋值同样如此:
MySharedPtr &operator=(MySharedPtr &&other) noexcept { if (this != &other) { release(); ptr_ = other.ptr_; count_ = other.count_; other.ptr_ = nullptr; other.count_ = nullptr; } return *this; }这里同样先:
release();处理自己原来管理的资源。
然后接管:
other的资源。
为了像普通指针一样使用,还可以继续实现:
T &operator*() const { return *ptr_; } T *operator->() const { return ptr_; } T *get() const { return ptr_; } size_t use_count() const { return count_ ? *count_ : 0; } explicit operator bool() const { return ptr_ != nullptr; }完整代码如下:
#include <cstddef> #include <iostream> #include <utility> template <typename T> class MySharedPtr { private: T *ptr_; size_t *count_; // 当前对象不再持有资源 void release() { if (!count_) return; // 当前所有者减少一个 --(*count_); // 已经没有任何shared_ptr管理资源 if (*count_ == 0) { delete ptr_; delete count_; } ptr_ = nullptr; count_ = nullptr; } public: // 普通构造 explicit MySharedPtr(T *ptr = nullptr) : ptr_(ptr), count_(ptr ? new size_t(1) : nullptr) { } // 析构 ~MySharedPtr() { release(); } // 拷贝构造:增加一个共享所有者 MySharedPtr(const MySharedPtr &other) : ptr_(other.ptr_), count_(other.count_) { if (count_) ++(*count_); } // 拷贝赋值 MySharedPtr &operator=(const MySharedPtr &other) { if (this != &other) { release(); ptr_ = other.ptr_; count_ = other.count_; if (count_) ++(*count_); } return *this; } // 移动构造:转移所有权,不增加引用计数 MySharedPtr(MySharedPtr &&other) noexcept : ptr_(other.ptr_), count_(other.count_) { other.ptr_ = nullptr; other.count_ = nullptr; } // 移动赋值 MySharedPtr &operator=(MySharedPtr &&other) noexcept { if (this != &other) { release(); ptr_ = other.ptr_; count_ = other.count_; other.ptr_ = nullptr; other.count_ = nullptr; } return *this; } // 支持*p T &operator*() const { return *ptr_; } // 支持p->member T *operator->() const { return ptr_; } // 获取原始指针 T *get() const { return ptr_; } // 获取当前引用计数 size_t use_count() const { return count_ ? *count_ : 0; } // 支持if(p) explicit operator bool() const { return ptr_ != nullptr; } }; class Student { public: void show() { std::cout << "Student::show()" << std::endl; } }; int main() { MySharedPtr<Student> p1(new Student); std::cout << "count = " << p1.use_count() << std::endl; { MySharedPtr<Student> p2 = p1; std::cout << "count = " << p1.use_count() << std::endl; p2->show(); } std::cout << "count = " << p1.use_count() << std::endl; return 0; }运行过程可以理解成:
创建p1 ↓ count = 1 ↓ p2 = p1 ↓ count = 2 ↓ p2离开作用域 ↓ count = 1 ↓ p1离开作用域 ↓ count = 0 ↓ delete资源因此整个shared_ptr最核心的逻辑其实就是:
ptr_ + 共享引用计数count_每发生一次拷贝:
count++每失去一个所有者:
count--直到:
count == 0真正释放资源。
五、面试中shared_ptr最容易追问什么
首先很容易被问:
shared_ptr和unique_ptr最大区别是什么?
可以回答:
unique_ptr表示独占所有权,不允许拷贝,只能通过移动语义转移所有权;shared_ptr表示共享所有权,可以有多个智能指针共同管理同一块资源,通过引用计数决定什么时候真正释放资源。
可以简单记成:
unique_ptr 一个资源 ↓ 一个所有者 shared_ptr 一个资源 ↓ 多个所有者 ↓ 引用计数管理生命周期第二个常见问题是:
为什么引用计数不能直接写成普通成员变量?
例如:
size_t count_;因为发生拷贝以后:
p1.count_ = 1 p2.count_ = 1两个对象拥有的是两个独立计数。
而我们真正需要的是:
p1 ──┐ ├──→ 同一个count p2 ──┘所以简化实现使用:
size_t *count_;标准库中的shared_ptr则使用更加完整的:
Control Block 控制块控制块中通常不只有:
shared引用计数还会管理:
weak引用计数 删除器 分配器等信息。
所以真正的std::shared_ptr可以大致理解成:
shared_ptr │ ├── 指向实际对象 │ └── 指向Control Block │ ├── strong count ├── weak count └── deleter等信息本文为了突出面试中的引用计数原理,只使用:
size_t *count_;进行了简化。
第三个非常常见的问题是:
shared_ptr的引用计数线程安全吗?
这里要分开说。
标准库的不同shared_ptr对象如果共享同一个控制块:
p1 ─┐ ├→ Control Block p2 ─┘对控制块中引用计数的增减具有线程安全保证。
但是:
shared_ptr引用计数线程安全不等于:
它指向的对象线程安全例如:
std::shared_ptr<int> p = std::make_shared<int>(0);多个线程同时:
++(*p);仍然可能产生数据竞争。
所以应该区分:
控制块引用计数 ↓ 有相应线程安全保证 被管理的T对象 ↓ 是否线程安全取决于T本身和访问方式而我们本文手写的:
size_t *count_;只是普通整数,并没有做线程同步,因此:
这个MySharedPtr本身不是线程安全版本如果想进一步处理,可以考虑:
std::atomic<size_t>维护引用计数。
第四个重点问题就是:
shared_ptr有什么经典问题?
答案就是:
循环引用例如两个对象:
A ↓ shared_ptr<B> B ↓ shared_ptr<A>形成:
A ─────────→ B ↑ │ └────────────┘假设:
A的引用计数 = 1 B的引用计数 = 1即使外部的shared_ptr已经全部销毁:
A还被B引用 B还被A引用因此:
A count != 0 B count != 0最终两个对象都无法释放。
也就是:
循环引用 ↓ 引用计数无法归零 ↓ 内存泄漏而解决这个问题的关键,就是第三种智能指针:
weak_ptrweak_ptr可以观察:
shared_ptr管理的资源但是它:
不会增加强引用计数这样就可以打破:
shared_ptr之间的循环引用如果从面试角度总结这一篇,可以直接这样回答:
shared_ptr用于实现共享所有权,多个shared_ptr可以共同管理同一块资源。它内部需要共享一个引用计数,拷贝构造和拷贝赋值时引用计数加一,对象析构时引用计数减一,当强引用计数变为 0 时才真正释放资源。移动操作只是转移已有所有权,因此不会增加引用计数。标准库实现中引用计数通常位于独立的控制块中,而shared_ptr最大的经典问题是循环引用,需要使用weak_ptr解决。
所以:
unique_ptr ↓ 独占所有权 shared_ptr ↓ 共享所有权 ↓ 引用计数 weak_ptr ↓ 弱引用 ↓ 解决循环引用这三种智能指针的关系也就逐渐串起来了。
0voice · GitHub