6、智能指针(上):std::unique_ptr 详解、std::shared_ptr 详解、std::weak_ptr 详解、循环引用的解决方案
说到C++11最让我兴奋的特性,智能指针绝对排前三。在它出现之前,我几乎每个项目都要花大量时间调试内存泄漏和悬空指针。说实话,那段日子真不好过。后来智能指针来了,嗯,世界清静多了。
今天咱们就好好聊聊三种智能指针:unique_ptr、shared_ptr 和 weak_ptr。它们各自解决什么问题?怎么用?有什么坑?我把自己踩过的坑都翻出来给你看。
核心要点:智能指针的本质是 RAII(资源获取即初始化)思想在动态内存管理上的应用。说白了,就是让对象的生命周期与指针对象的生命周期绑定,自动释放资源。
6.1 std::unique_ptr:独占所有权,轻量高效
unique_ptr 是我用得最多的智能指针。为什么?因为它零开销。你想想看,它和裸指针大小一样,性能也一样,但多了一个「独占所有权」的语义。
什么叫独占所有权?就是同一时刻只能有一个 unique_ptr 指向某个对象。不能拷贝,只能移动。这设计太妙了——从根源上杜绝了悬空指针的问题。
#include <memory>
std::unique_ptr<int> p1 = std::make_unique<int>(42);
// std::unique_ptr<int> p2 = p1; // 编译错误!不能拷贝
std::unique_ptr<int> p2 = std::move(p1); // 可以移动,p1 变为空
if (p1) {
// 不会执行,p1 已经被转移
}
if (p2) {
std::cout << *p2 << std::endl; // 输出 42
}
我的习惯:能用 unique_ptr 的地方绝不用 shared_ptr。性能是一方面,更重要的是语义清晰——看到 unique_ptr 就知道这个对象只有一个拥有者,代码意图一目了然。
自定义删除器是 unique_ptr 的一个强大特性。我在项目中遇到过需要管理文件句柄的场景,用自定义删除器就特别顺手:
auto file_deleter = [](FILE* f) {
if (f) {
fclose(f);
std::cout << "文件已关闭" << std::endl;
}
};
std::unique_ptr<FILE, decltype(file_deleter)>
file_ptr(fopen("test.txt", "r"), file_deleter);
// 离开作用域时自动调用 fclose
注意:自定义删除器会改变 unique_ptr 的大小。如果删除器是无状态的函数对象(比如 lambda 无捕获),则大小不变;如果是有状态的,大小会增加。我曾经因为没注意这一点,在嵌入式项目里踩了坑。
6.2 std::shared_ptr:共享所有权,引用计数
shared_ptr 解决的是「多个所有者」的问题。它内部维护一个引用计数,当最后一个 shared_ptr 被销毁时,才释放管理的对象。
引用计数的实现很有意思。每个 shared_ptr 内部有两个指针:一个指向管理的对象,一个指向控制块(包含引用计数)。控制块是线程安全的,所以 shared_ptr 的拷贝和析构都是原子操作。
std::shared_ptr<int> sp1 = std::make_shared<int>(100);
std::shared_ptr<int> sp2 = sp1; // 引用计数变为 2
std::cout << sp1.use_count() << std::endl; // 输出 2
sp1.reset(); // sp1 置空,引用计数变为 1
sp2.reset(); // sp2 置空,引用计数变为 0,对象被释放
性能对比:make_shared 比直接 new 再构造 shared_ptr 更高效。它只分配一次内存(对象和控制块一起),而分开做需要两次分配。我在性能敏感的项目中,一律使用 make_shared。
不过 shared_ptr 也不是没有代价。引用计数的原子操作有开销,而且控制块本身也占用内存。如果你只是简单传递所有权,用 unique_ptr 更合适。
6.3 std::weak_ptr:弱引用,打破循环
weak_ptr 是个「旁观者」。它指向 shared_ptr 管理的对象,但不增加引用计数。它不能直接访问对象,必须通过 lock() 方法提升为 shared_ptr 才能使用。
你可能会问:这有什么用?嗯,它的核心价值在于解决循环引用问题。
std::shared_ptr<int> sp = std::make_shared<int>(200);
std::weak_ptr<int> wp = sp; // 引用计数仍为 1
// 使用前必须 lock()
if (auto locked = wp.lock()) {
std::cout << *locked << std::endl; // 输出 200
} else {
std::cout << "对象已被释放" << std::endl;
}
sp.reset(); // 对象被释放
// wp.expired() 返回 true
使用场景:weak_ptr 特别适合观察者模式、缓存系统、以及树形结构中父节点指向子节点的反向引用。我习惯用它来避免「强引用环」。
6.4 循环引用的解决方案
循环引用是 shared_ptr 最经典的坑。两个对象互相持有对方的 shared_ptr,导致引用计数永远降不到零,内存泄漏就这么静悄悄地发生了。
我曾经在一个图形引擎项目里遇到过这个问题。场景节点和子节点互相引用,结果整个场景树释放不掉,内存占用越来越高。排查了好久才发现是循环引用。
// 错误的做法:循环引用
struct Node {
std::shared_ptr<Node> parent;
std::vector<std::shared_ptr<Node>> children;
~Node() { std::cout << "Node 析构" << std::endl; }
};
{
auto root = std::make_shared<Node>();
auto child = std::make_shared<Node>();
root->children.push_back(child);
child->parent = root; // 循环引用!
}
// 两个 Node 都不会析构,内存泄漏
解决方案很简单:把其中一个方向的引用改为 weak_ptr。通常的做法是「父强子弱」——父节点用 shared_ptr 持有子节点,子节点用 weak_ptr 指向父节点。
// 正确的做法:使用 weak_ptr 打破循环
struct Node {
std::weak_ptr<Node> parent; // 弱引用,不增加计数
std::vector<std::shared_ptr<Node>> children;
~Node() { std::cout << "Node 析构" << std::endl; }
};
{
auto root = std::make_shared<Node>();
auto child = std::make_shared<Node>();
root->children.push_back(child);
child->parent = root; // weak_ptr,不增加引用计数
}
// 两个 Node 都能正确析构
避坑指南:我曾经在代码审查时发现有人把父子节点都用了 shared_ptr,还信誓旦旦说「没问题」。结果一跑压力测试,内存直接爆了。记住:只要出现双向引用,至少有一边要用 weak_ptr。
还有一个常见的循环引用场景是回调函数。如果 lambda 捕获了 shared_ptr,而 lambda 又被这个对象持有,就会形成闭环。解决方案是用 weak_ptr 捕获,在回调里 lock() 提升。
class Timer {
public:
void setCallback(std::function<void()> cb) { callback_ = cb; }
void fire() { if (callback_) callback_(); }
private:
std::function<void()> callback_;
};
class Widget {
public:
void startTimer() {
auto weak_this = std::weak_ptr<Widget>(shared_from_this());
timer_.setCallback([weak_this]() {
if (auto self = weak_this.lock()) {
self->onTimer();
}
});
}
void onTimer() { /* 处理定时器 */ }
private:
Timer timer_;
};
总结一下:unique_ptr 是默认选择,shared_ptr 用于共享所有权,weak_ptr 专门解决循环引用。记住这个原则,大部分场景都不会出错。
嗯,智能指针的内容其实还有很多细节,比如别名构造、数组支持、线程安全边界等等。不过今天咱们先把核心概念理清楚,后面再深入那些进阶话题。