【C++面试】手写智能指针(二):从shared_ptr理解引用计数与共享所有权

一、为什么有了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_
 ↓
1

1. 拷贝构造

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 = 2

2. 析构函数

析构的时候不能直接:

复制代码
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_ptr

weak_ptr 可以观察:

复制代码
shared_ptr管理的资源

但是它:

复制代码
不会增加强引用计数

这样就可以打破:

复制代码
shared_ptr之间的循环引用

如果从面试角度总结这一篇,可以直接这样回答:

shared_ptr 用于实现共享所有权,多个 shared_ptr 可以共同管理同一块资源。它内部需要共享一个引用计数,拷贝构造和拷贝赋值时引用计数加一,对象析构时引用计数减一,当强引用计数变为 0 时才真正释放资源。移动操作只是转移已有所有权,因此不会增加引用计数。标准库实现中引用计数通常位于独立的控制块中,而 shared_ptr 最大的经典问题是循环引用,需要使用 weak_ptr 解决。

所以:

复制代码
unique_ptr
    ↓
独占所有权

shared_ptr
    ↓
共享所有权
    ↓
引用计数

weak_ptr
    ↓
弱引用
    ↓
解决循环引用

这三种智能指针的关系也就逐渐串起来了。

0voice · GitHub

相关推荐
一条大祥脚1 小时前
26 ICPC上海 交互|二分|重心|构造|二分图|贪心|SOSDP|三进制枚举|线性基|字典序贪心|DFS|分块|单侧递归线段树
算法·深度优先·交互·分块·icpc上海站·单侧递归线段树
无名猿1 小时前
构造函数详解:默认、委托、转换与 explicit 到底写在哪
c++·现代c++·语法基础
吹什么轩1 小时前
数据结构复习:二叉搜索树
数据结构·算法
H.莓飛1 小时前
【数据结构】堆
linux·数据结构·算法
纪念 2291 小时前
C++ string(一)
android·开发语言·c++
纪念 2291 小时前
C++算法(二)
开发语言·c++·算法
lie..2 小时前
30天从零开始学AI应用开发(Day 16):Embedding 是什么?用人话讲明白“向量检索”
人工智能·算法·embedding
AC赳赳老秦10 小时前
采集行为合规自检:OpenClaw 自动校验 robots 协议与采集频率,规避违规采集风险
java·开发语言·c++·python·php·deepseek·openclaw
无名猿11 小时前
C++ 查找算法:std::find 与容器成员 find 的性能差距
c++·性能优化·stl·标准库