20. 手写一个简易shared_ptr:理解控制块与引用计数

说实话,shared_ptr 是 C++ 智能指针里最常用的一个,也是最容易出问题的一个。很多人用了一辈子 shared_ptr,却不知道它底层到底怎么工作的。今天我就带你手写一个简易版,把控制块和引用计数这两个核心概念彻底搞明白。

为什么需要控制块?

你想想看,shared_ptrunique_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 控制块内存布局 shared_ptr<T> (sp1) ptr_: 0x1000 control_block_: 0x2000 shared_ptr<T> (sp2) ptr_: 0x1000 control_block_: 0x2000 控制块 (0x2000) ref_count_: 2 weak_count_: 0 deleter_: delete T 对象 (0x1000) 实际数据 两个 shared_ptr 共享同一个控制块,控制块管理对象的生命周期

从图里可以清楚看到:两个 shared_ptrptr_ 指向同一个对象,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 和循环引用的问题——那才是真正考验功底的地方。


公众号:蓝海资料掘金营,微信deep3321