在实际开发中,有时一块堆内存上的数据需要由多个对象或多个函数共同持有。如果使用独占所有权的 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 就可以了。这样不仅从源头上避开了裸指针误用,还能把堆对象和登记表合并在一次内存分配里完成,运行效率也更高。
