第二章 智能指针深度剖析:从裸指针到安全内存管理
智能指针这个话题,我每次讲课时都会多说几句。为什么?因为太多线上事故跟内存管理有关了。我自己早年也踩过坑——有一次在某个高并发服务里,一个裸指针被多处共享,结果某处提前 delete 了,另一处还在用,查了整整两天才定位到问题。从那以后,我对智能指针的态度就变成了:能不用裸指针,就尽量别用。
今天咱们就把 C++ 的三种智能指针彻底聊透:unique_ptr、shared_ptr、weak_ptr,再加上自定义删除器这个实用技巧。
2.1 unique_ptr:独占所有权的利器
unique_ptr 说白了就是「这把钥匙只有一个人拿着」。它不允许拷贝,只能移动。你想想看,如果一份资源同时被两个 unique_ptr 指向,那到底谁负责释放?所以 C++11 的设计者干脆禁止了拷贝。
核心语义:独占所有权 + 移动语义。离开作用域自动释放,零额外开销。
#include <memory>
#include <iostream>
class Resource {
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource released\n"; }
void doWork() { std::cout << "Working...\n"; }
};
void demo() {
auto ptr = std::make_unique<Resource>();
ptr->doWork();
// auto ptr2 = ptr; // 编译错误!不能拷贝
auto ptr3 = std::move(ptr); // 可以移动
// 此时 ptr 为空,ptr3 持有资源
} // 离开作用域,ptr3 自动释放 Resource
我个人习惯在工厂函数里直接返回 unique_ptr。这样调用方一眼就能看出:这个对象归我管,别人别碰。
小技巧:用 std::make_unique 而不是 new。前者更安全,避免异常导致的内存泄漏。C++14 才引入,C++11 需要自己实现一个。
2.2 shared_ptr:引用计数的双刃剑
shared_ptr 允许多个指针共享同一份资源。它内部维护一个引用计数,每多一个 shared_ptr 指向资源,计数加一;每销毁一个,计数减一。计数归零时,资源自动释放。
听起来很美好对吧?但我在项目中遇到过一个问题:引用计数本身是线程安全的,但指向的对象不是。很多人以为用了 shared_ptr 就万事大吉了,结果多线程下数据竞争照出不误。
#include <memory>
#include <thread>
void shared_ptr_demo() {
auto sp1 = std::make_shared<int>(42);
std::cout << "引用计数: " << sp1.use_count() << "\n"; // 1
{
auto sp2 = sp1;
std::cout << "引用计数: " << sp1.use_count() << "\n"; // 2
} // sp2 销毁,计数变回 1
std::cout << "引用计数: " << sp1.use_count() << "\n"; // 1
} // sp1 销毁,计数归零,int 被释放
性能警告:引用计数的增减是原子操作,有性能开销。高频场景下,shared_ptr 的拷贝可能比裸指针慢一个数量级。我建议只在真正需要共享所有权时才用,否则优先用 unique_ptr。
2.3 weak_ptr:破解循环引用的钥匙
循环引用是 shared_ptr 最经典的坑。两个对象互相持有对方的 shared_ptr,结果引用计数永远不为零,内存泄漏了。
我曾经在一个图形引擎项目里遇到过这个问题:场景中的节点互相引用父节点和子节点,结果整个场景树析构时,节点一个都没释放。排查了半天才发现是循环引用。
#include <memory>
#include <iostream>
struct Node {
std::shared_ptr<Node> next;
~Node() { std::cout << "Node destroyed\n"; }
};
void circular_reference() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a; // 循环引用!两个 Node 都不会被释放
// 离开作用域后,a 和 b 的引用计数都是 1,内存泄漏
}
解决办法?用 weak_ptr。它不增加引用计数,只是「弱弱地」观察资源是否还活着。使用时需要先通过 lock() 提升为 shared_ptr,如果资源已被释放,lock() 返回空。
struct NodeFixed {
std::shared_ptr<NodeFixed> next;
std::weak_ptr<NodeFixed> prev; // 用 weak_ptr 打破循环
~NodeFixed() { std::cout << "NodeFixed destroyed\n"; }
};
void fixed_circular() {
auto a = std::make_shared<NodeFixed>();
auto b = std::make_shared<NodeFixed>();
a->next = b;
b->prev = a; // weak_ptr,不增加计数
// 使用前需要 lock()
if (auto sp = b->prev.lock()) {
std::cout << "a 还活着\n";
}
} // 正常释放,没有泄漏
黄金法则:在双向关系中,让「父」持有「子」的 shared_ptr,「子」持有「父」的 weak_ptr。这样既安全又不会泄漏。
2.4 自定义删除器:掌控资源释放的最后一公里
默认情况下,unique_ptr 和 shared_ptr 用 delete 释放资源。但现实世界哪有这么简单?文件句柄要用 fclose,套接字要用 close,malloc 出来的要用 free。这时候就需要自定义删除器了。
我记得有一次对接一个 C 库,它返回一个 FILE*,要求调用方用 fclose 关闭。用裸指针很容易忘记,但包一层 unique_ptr 加自定义删除器,就再也不用担心了。
#include <memory>
#include <cstdio>
// 自定义删除器:函数对象
struct FileDeleter {
void operator()(FILE* fp) const {
if (fp) {
fclose(fp);
std::cout << "File closed\n";
}
}
};
void file_demo() {
// 使用自定义删除器
std::unique_ptr<FILE, FileDeleter> fp(fopen("test.txt", "w"));
if (fp) {
fprintf(fp.get(), "Hello, smart pointer!\n");
}
// 离开作用域自动 fclose
}
// 也可以直接用 lambda
void lambda_deleter() {
auto deleter = [](FILE* fp) {
if (fp) fclose(fp);
};
std::unique_ptr<FILE, decltype(deleter)> fp(fopen("test.txt", "r"), deleter);
// 用法同上
}
注意:shared_ptr 的自定义删除器类型不会影响 shared_ptr 的类型,但 unique_ptr 的删除器是类型的一部分。所以 unique_ptr<FILE, FileDeleter> 和 unique_ptr<FILE, decltype(lambda)> 是两种不同的类型。
2.5 知识体系总览
下面这张图把三种智能指针的关系和适用场景梳理清楚了。我建议你把它存下来,写代码前瞄一眼,能少走很多弯路。
2.6 避坑指南
最后分享几个我踩过的坑,希望能帮你省点时间:
- 不要混用智能指针和裸指针管理同一份资源。 我曾经在一个遗留代码里看到有人 new 了一个对象,既传给
shared_ptr又自己拿着裸指针用。结果shared_ptr析构时把对象删了,裸指针成了野指针。 - 小心
shared_ptr的循环引用。 特别是回调、观察者模式、树形结构里。一旦发现对象析构函数没被调用,先怀疑循环引用。 - 自定义删除器不要抛异常。 智能指针的析构函数默认不抛异常,如果删除器抛了,程序会直接 terminate。
- 能用
make_shared就用它。 它把控制块和对象分配在同一块内存里,减少一次分配,还能提高缓存局部性。
一句话总结:unique_ptr 管独占,shared_ptr 管共享,weak_ptr 破循环。自定义删除器管特殊资源。记住这三句话,智能指针基本不会用错。
公众号:蓝海资料掘金营,微信deep3321