20. 手写一个简易shared_ptr:理解控制块与引用计数
说实话,shared_ptr 是 C++ 智能指针里最常用的一个,也是最容易出问题的一个。很多人用了一辈子 shared_ptr,却不知道它底层到底怎么工作的。今天我就带你手写一个简易版,把控制块和引用计数这两个核心概念彻底搞明白。
为什么需要控制块?
你想想看,shared_ptr 和 unique_ptr 最大的区别是什么?
是共享。多个 shared_ptr 可以指向同一个对象,最后一个析构时才释放。这就需要一个「共享的记账本」,记录当前有多少个指针在指向这块内存。
这个记账本,就是控制块(Control Block)。
核心要点:控制块里至少存两样东西——引用计数(use_count)和删除器(deleter)。有时候还会存弱引用计数(weak_count),这个我们后面讲 weak_ptr 时再细说。
控制块长什么样?
我个人习惯把控制块定义成一个内部结构体。这样既封装了细节,又方便管理。来看代码:
template<typename T>
class SharedPtr {
private:
T* ptr_; // 裸指针
ControlBlock* control_block_; // 控制块指针
struct ControlBlock {
int ref_count_; // 引用计数
int weak_count_; // 弱引用计数(先不管)
std::function<void(T*)> deleter_; // 删除器
ControlBlock(T* p, std::function<void(T*)> d = nullptr)
: ref_count_(1), weak_count_(0), deleter_(d) {}
};
};
嗯,这里要注意:ref_count_ 初始值是 1。为什么?因为创建控制块的时候,一定有一个 shared_ptr 在指向它。这个 1 就是那个「第一个拥有者」。
引用计数的加减规则
引用计数的变化规则其实很简单,但很多人记混了。我总结成一张表:
| 操作 | 引用计数变化 | 说明 |
|---|---|---|
| 构造(从裸指针) | 设为 1 | 第一个拥有者 |
| 拷贝构造 | +1 | 新指针共享同一控制块 |
| 拷贝赋值 | 旧指针 -1,新指针 +1 | 注意顺序,防止自赋值 |
| 析构 | -1 | 减到 0 时释放对象和控制块 |
| 移动构造 | 不变 | 转移所有权,原指针置空 |
我曾经在项目里遇到过一个 bug:拷贝赋值时先减了旧指针的计数,结果旧指针和新指针指向同一个控制块,导致计数先变成 0,对象被释放了,然后新指针又去加一个已经被释放的控制块……嗯,血泪教训。
避坑指南:拷贝赋值时,一定要先处理新指针(加引用),再处理旧指针(减引用)。否则自赋值或共享同一控制块时会出大问题。
手写核心函数
好,理论说完了,我们直接上手写代码。下面是一个简易 SharedPtr 的核心实现:
template<typename T>
class SharedPtr {
public:
// 从裸指针构造
explicit SharedPtr(T* ptr = nullptr)
: ptr_(ptr), control_block_(nullptr) {
if (ptr_) {
control_block_ = new ControlBlock(ptr_);
}
}
// 拷贝构造
SharedPtr(const SharedPtr& other)
: ptr_(other.ptr_), control_block_(other.control_block_) {
if (control_block_) {
control_block_->ref_count_++;
}
}
// 拷贝赋值
SharedPtr& operator=(const SharedPtr& other) {
if (this != &other) {
// 先处理新指针
SharedPtr temp(other);
// 交换当前指针和临时指针
swap(temp);
}
return *this;
}
// 析构
~SharedPtr() {
release();
}
// 获取引用计数
int use_count() const {
return control_block_ ? control_block_->ref_count_ : 0;
}
// 解引用
T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
private:
void release() {
if (control_block_) {
control_block_->ref_count_--;
if (control_block_->ref_count_ == 0) {
// 最后一个拥有者,释放对象
if (control_block_->deleter_) {
control_block_->deleter_(ptr_);
} else {
delete ptr_;
}
// 控制块也释放(这里简化了,实际还要考虑 weak_count)
delete control_block_;
}
ptr_ = nullptr;
control_block_ = nullptr;
}
}
void swap(SharedPtr& other) noexcept {
std::swap(ptr_, other.ptr_);
std::swap(control_block_, other.control_block_);
}
T* ptr_;
ControlBlock* control_block_;
};
你看,核心逻辑其实就两个函数:release() 和拷贝赋值里的 swap 技巧。我特别喜欢用 copy-and-swap 这个惯用法,它天然解决了自赋值和异常安全的问题。
控制块的内存布局
为了让你更直观地理解,我画了一张图:
从图里可以清楚看到:两个 shared_ptr 的 ptr_ 指向同一个对象,control_block_ 指向同一个控制块。控制块里的 ref_count_ 是 2,表示有两个拥有者。
为什么控制块要单独分配?
你可能会有疑问:为什么不把引用计数直接放在对象里?
原因很简单:对象和控制块的生命周期不同步。对象可能被提前释放(比如 weak_ptr 的场景),但控制块必须活到最后一个 weak_ptr 也析构为止。这个我们后面讲 weak_ptr 时会详细展开。
小技巧:如果你用 make_shared 创建 shared_ptr,控制块和对象会分配在同一块内存里。这样能减少一次内存分配,提高缓存命中率。我一般推荐用 make_shared,除非你需要自定义删除器。
一个完整的示例
最后,我们跑一个完整的例子,看看引用计数是怎么变化的:
#include <iostream>
struct Foo {
int x;
Foo(int v) : x(v) { std::cout << "Foo(" << x << ")\n"; }
~Foo() { std::cout << "~Foo(" << x << ")\n"; }
};
int main() {
// 构造,ref_count = 1
SharedPtr<Foo> sp1(new Foo(42));
std::cout << "use_count: " << sp1.use_count() << "\n"; // 1
{
// 拷贝构造,ref_count = 2
SharedPtr<Foo> sp2 = sp1;
std::cout << "use_count: " << sp1.use_count() << "\n"; // 2
}
// sp2 析构,ref_count = 1
std::cout << "use_count: " << sp1.use_count() << "\n"; // 1
// sp1 析构,ref_count = 0,释放对象和控制块
return 0;
}
输出结果:
Foo(42)
use_count: 1
use_count: 2
use_count: 1
~Foo(42)
看到没?对象在最后一个 shared_ptr 析构时才被释放。这就是引用计数的魔力。
总结一下
手写一个简易 shared_ptr,其实就是在管理两样东西:
- 裸指针:指向实际对象
- 控制块:记录引用计数和删除器
引用计数的加减规则也不复杂,但拷贝赋值时的顺序一定要小心。我建议你把这个简易实现跑一遍,然后试着加一些边界测试(比如自赋值、空指针、自定义删除器),这样理解会更深刻。
嗯,今天就到这里。下一章我们聊聊 weak_ptr 和循环引用的问题——那才是真正考验功底的地方。