C++ shared_ptr

在实际开发中,有时一块堆内存上的数据需要由多个对象或多个函数共同持有。如果使用独占所有权的 std::unique_ptr,同一时刻只能有一个管理者,无法直接复制共享。为了满足共享对象的需求,C++11 标准引入了 “共享式智能指针” std::shared_ptr。

那多个指针一起管同一个对象时,到底什么时候才应该把堆内存释放掉呢?其实,shared_ptr 内部维护了一个 “引用计数(Reference Count)”,用来记录当前共有多少个 shared_ptr 正在共享这个对象。

每当复制出一个新的 shared_ptr 时,引用计数加 1;每当某个 shared_ptr 销毁或重置时,引用计数减 1。只有当最后一个管理者离开、引用计数降为 0 时,堆对象才会被自动销毁。

创建和使用 shared_ptr

在 C++ 中,使用 std::shared_ptr 同样需要引入 <memory> 头文件。创建共享式智能指针最推荐的方式是调用 std::make_shared 函数。

语法:

std::shared_ptr<类型> 指针名1 = std::make_shared<类型>(初始化参数);
std::shared_ptr<类型> 指针名2 = 指针名1;

说明:

  • std::make_shared<类型>(初始化参数):在堆上创建指定类型的对象并返回 shared_ptr,此时初始引用计数为 1。圆括号 () 内填写初始值或构造参数,如果留空写成 std::make_shared<int>() 则默认初始化为 0。
  • 复制共享与访问对象:将 指针名1 直接赋值给 指针名2 后,两个智能指针会共同指向并管理同一个堆对象,引用计数自动加 1。读写目标对象同样使用 * 和 -> 运算符。
  • 指针名.use_count():返回当前共有多少个 shared_ptr 正在共享管理该对象。

示例 1:创建并复制 shared_ptr

#include <iostream>
#include <memory>

int main() {
    std::shared_ptr<int> p1 = std::make_shared<int>(50);
    std::cout << "复制前引用计数:" << p1.use_count() << "\n";

    std::shared_ptr<int> p2 = p1;    // 与 p1 共享同一对象
    std::cout << "复制后引用计数:" << p1.use_count() << "\n";
    std::cout << "*p1 = " << *p1 << ",*p2 = " << *p2 << "\n";

    return 0;
}

运行结果如下。

复制前引用计数:1
复制后引用计数:2
*p1 = 50,*p2 = 50

分析:

刚通过 std::make_shared<int>(50) 创建堆对象时,只有 p1 一个管理者,因此 p1.use_count() 输出 1。

随后执行 std::shared_ptr<int> p2 = p1,让 p2 也加入进来共同管理这个整数,引用计数就变成了 2。此时无论调用 p1.use_count() 还是 p2.use_count(),拿到的结果都是 2。

平时写代码时,我们也可以配合 auto 关键字将创建语句简写为 auto p1 = std::make_shared<int>(50)。接下来定义一个 Document 类,结合局部代码块,观察其中一个 shared_ptr 先销毁时引用计数会发生什么变化。

示例 2:引用计数与销毁时机

#include <iostream>
#include <memory>
#include <string>

class Document {
private:
    std::string title;

public:
    Document(const std::string& t) : title(t) {
        std::cout << "创建文档:" << title << "\n";
    }

    ~Document() {
        std::cout << "销毁文档:" << title << "\n";
    }
};

int main() {
    auto first = std::make_shared<Document>("项目方案.docx");
    std::cout << "刚创建时的计数:" << first.use_count() << "\n";

    {
        auto second = first;
        std::cout << "复制给 second 后的计数:" << first.use_count() << "\n";
    }

    std::cout << "second 销毁后的计数:" << first.use_count() << "\n";
    return 0;
}

运行结果如下。

创建文档:项目方案.docx
刚创建时的计数:1
复制给 second 后的计数:2
second 销毁后的计数:1
销毁文档:项目方案.docx

分析:

在这个例子中,当程序走出内层花括号代码块时,局部指针 second 的生命周期结束并自动销毁,引用计数随之从 2 减回 1。

需要特别注意的是,此时堆上的 Document 对象并没有销毁,因为外层的 first 依然在管理着它。直到 main() 函数结束、外层的 first 也离开作用域销毁时,引用计数才真正减为 0,触发析构函数输出 “销毁文档:项目方案.docx”。

提示 use_count() 主要用于在学习和调试阶段观察引用计数的变化。在实际项目开发中,不建议把 use_count() 的返回值当作业务逻辑的判断条件。

多个指针操作同一个对象

初学 shared_ptr 时,有些小伙伴看到 auto second = first 可以直接赋值,容易误以为把堆上的目标对象也复制了一份。实际上并非如此:复制 shared_ptr 时,复制的只是智能指针本身,而底层的堆对象始终只有一个。

因此,只要我们通过其中任意一个 shared_ptr 修改了对象的数据,其他所有共享该对象的 shared_ptr 读取到的都是修改后的新结果。下面我们来看一个例子。

示例 3:多指针读写同一对象

#include <iostream>
#include <memory>
#include <string>

struct Task {
    std::string name;
    int progress;

    Task(const std::string& n, int p) : name(n), progress(p) {}
};

int main() {
    auto first = std::make_shared<Task>("数据备份", 25);
    auto second = first;

    second->progress = 80;    // 修改任务进度

    std::cout << "first 看到的进度:" << first->progress << "%\n";
    std::cout << "second 看到的进度:" << second->progress << "%\n";
    return 0;
}

运行结果如下。

first 看到的进度:80%
second 看到的进度:80%

分析:

从运行结果可以看出,执行 second->progress = 80 把进度修改为 80 后,无论通过 first 还是 second 读取进度,拿到的结果都是 80%。这是因为两个智能指针指向的是堆内存里的同一个 Task 对象。

如果我们在业务上确实需要两个互不影响的独立对象,就应该调用两次 std::make_shared<Task>("数据备份", 25) 分别创建,而不是直接复制智能指针。

shared_ptr 的函数传参方式

把 shared_ptr 管理的堆对象传给普通函数时,应该传智能指针还是传对象引用呢?在实际开发中,我们可以按以下两条原则区分:

仅临时读写对象:如果函数只是在执行期间读取或修改对象内容,不需要保存指针副本,形参直接写成 const Task& 或 Task& 引用,调用时传入 *first 即可,这样能省去增减引用计数的额外开销。

需要共同持有对象:只有当函数内部需要把指针存进容器或成员变量、共同延长对象存活时间时,才将形参写成 std::shared_ptr<Task> 按值传递。

使用 reset() 放弃管理权

如果某个 shared_ptr 在离开作用域之前,想要提前放弃对共享对象的管理权,我们可以调用 reset() 成员函数。每次对非空的 shared_ptr 调用 reset(),都会将该指针置为空指针 nullptr,同时使目标对象的引用计数减 1。

示例 4:提前放弃管理权

#include <iostream>
#include <memory>
#include <string>

class Document {
private:
    std::string title;

public:
    Document(const std::string& t) : title(t) {
        std::cout << "创建文档:" << title << "\n";
    }

    ~Document() {
        std::cout << "销毁文档:" << title << "\n";
    }
};

int main() {
    auto p1 = std::make_shared<Document>("年度总结.docx");
    auto p2 = p1;
    std::cout << "初始引用计数:" << p2.use_count() << "\n";

    p1.reset();    // p1 放弃管理权
    std::cout << "p1 重置后,p2 的计数:" << p2.use_count() << "\n";

    p2.reset();    // 最后一个管理者重置
    std::cout << "准备退出 main()\n";

    return 0;
}

运行结果如下。

创建文档:年度总结.docx
初始引用计数:2
p1 重置后,p2 的计数:1
销毁文档:年度总结.docx
准备退出 main()

分析:

第一次执行 p1.reset() 时,p1 主动放弃管理权并变为空指针,引用计数从 2 减为 1。此时还有 p2 管着这份文档,所以对象不会销毁。直到执行 p2.reset() 时,引用计数降为 0,文档对象立即在 “准备退出 main()” 之前销毁。

跟 std::unique_ptr 对比一下可以发现:shared_ptr 同样提供了 get() 用来临时读取底层裸指针,但它没有提供交出控制权的 release() 函数。因为在多个指针共享的场景下,某一个指针无权私自把底层对象的控制权单独转走。

而在所有权的转换方向上,如果某个函数返回了一个独占式的 unique_ptr,后续决定让多个地方共享这份对象,我们可以通过 std::move() 将它升级转换为 shared_ptr:

auto up = std::make_unique<int>(50);
std::shared_ptr<int> sp = std::move(up);    // 转为共享管理

转换完成后,原来的 up 变为空指针,堆对象改由 sp 通过引用计数来管理。不过反过来是不行的,一旦对象交由 shared_ptr 共享管理,就不能再转回独占的 unique_ptr 了。

shared_ptr 注意事项

在使用 shared_ptr 时,有两个初学者容易踩中的坑需要特别注意:

  • 不要拿同一个裸指针初始化多个 shared_ptr:如果先通过 new 创建出一个裸指针,再拿这个裸指针分别去初始化两个 shared_ptr,会导致同一个堆对象被重复释放两次,直接引发程序崩溃。
  • 注意防范循环引用问题:如果两个类对象各自使用 shared_ptr 互相指向对方,双方的引用计数会卡在 1 无法降为 0,导致内存无法释放。遇到这类双向关联场景时,我们需要配合弱引用智能指针 std::weak_ptr 来打破环路。

对于第一条禁忌,我们可以对比一下下面的错误写法与正确写法:

// 错误方式
int* raw = new int(50);
std::shared_ptr<int> p1(raw);
std::shared_ptr<int> p2(raw);

// 正确方式
auto p1 = std::make_shared<int>(50);
std::shared_ptr<int> p2 = p1;

可能有些小伙伴会觉得奇怪:“在错误方式里,p1 和 p2 指向的不也是同一个整数吗?为什么就会重复释放呢?”

这是因为堆上的 int 整数本身只是一块存放数值的普通内存,它身上并没有地方记录引用计数。真正的引用计数,保存在创建 shared_ptr 时单独分配的一张 “管理登记表” 里。

如果像错误方式那样,拿同一个裸指针 raw 分别去构造 p1 和 p2,它们彼此不知道对方的存在,就会各自建一张计数为 1 的独立登记表。等离开作用域时,p2 和 p1 都会以为自己是最后一个管理者,于是把同一块内存重复释放两次。

而采用正确方式时,我们先通过 std::make_shared 创建 p1,再通过 p2 = p1 直接复制智能指针,这样双方就会共用同一张登记表,引用计数正常变为 2。

因此,小伙伴们平时创建共享智能指针时,统一使用 std::make_shared 就可以了。这样不仅从源头上避开了裸指针误用,还能把堆对象和登记表合并在一次内存分配里完成,运行效率也更高。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号