7、智能指针(下):自定义删除器、智能指针与数组、智能指针的性能开销、在项目中的最佳实践
好,咱们接着聊智能指针。上一章我们把 unique_ptr、shared_ptr、weak_ptr 的基本用法过了一遍。这一章,我打算深入一些——聊聊那些你在教科书上可能看不到,但在实际项目中一定会踩到的坑。
说白了,智能指针不是万能的。用不好,它照样会泄漏、会崩溃、会把你坑得怀疑人生。我自己就在这几个地方栽过跟头,今天一并讲给你听。
7.1 自定义删除器:当 delete 不够用的时候
默认情况下,unique_ptr 和 shared_ptr 析构时调用 delete。但现实世界哪有这么简单?
举个例子。我在项目中对接过一个 C 语言的 SDK,它用 fopen 打开文件,要求用 fclose 关闭。你不能 delete 一个 FILE*,对吧?这时候就需要自定义删除器。
7.1.1 unique_ptr 的自定义删除器
unique_ptr 的删除器是类型的一部分。这意味着,如果你用 lambda 做删除器,类型会变得很“奇怪”。
#include <memory>
#include <cstdio>
// 自定义删除器:用 fclose 代替 delete
auto file_deleter = [](FILE* fp) {
if (fp) {
fclose(fp);
std::cout << "文件已关闭\n";
}
};
int main() {
// 注意:删除器类型是 lambda,所以模板参数要写 decltype
std::unique_ptr<FILE, decltype(file_deleter)>
fp(fopen("test.txt", "w"), file_deleter);
// 正常使用
fprintf(fp.get(), "Hello, Smart Pointer!\n");
// 离开作用域时自动调用 fclose
return 0;
}
嗯,这里要注意:decltype(file_deleter) 这个写法有点啰嗦。我个人习惯用 auto 配合 make_unique?不行,make_unique 不支持自定义删除器。所以只能手写 unique_ptr 的构造函数。
7.1.2 shared_ptr 的自定义删除器
shared_ptr 的删除器就友好多了。它不参与类型,你可以在构造时随便传。
std::shared_ptr<FILE> fp(fopen("test.txt", "r"),
[](FILE* f) { fclose(f); });
// 类型还是 shared_ptr<FILE>,清爽!
为什么 shared_ptr 可以这样?因为它内部有“控制块”,删除器就存在控制块里。类型擦除了,所以运行时多了一点开销。这个我们后面讲性能时再说。
shared_ptr 管理 malloc 分配的内存。默认删除器是 delete,不是 free。如果你不传自定义删除器,程序会直接崩溃。记住:malloc 配 free,new 配 delete,别混用。
7.2 智能指针与数组:一个容易翻车的地方
C++11 之前,很多人用 shared_ptr<int> 管理数组,然后传一个 delete[] 删除器。C++17 之后,unique_ptr 直接支持数组特化版本。
7.2.1 unique_ptr<T[]>
std::unique_ptr<int[]> arr(new int[100]);
arr[0] = 42; // 支持下标访问
// 析构时自动调用 delete[]
注意:unique_ptr<int> 和 unique_ptr<int[]> 是两种不同的类型。前者用 delete,后者用 delete[]。你如果写 unique_ptr<int> p(new int[10]),那就是未定义行为——析构时只释放了第一个元素,剩下的全泄漏了。
7.2.2 shared_ptr 与数组
shared_ptr 没有 T[] 特化版本。你要管理数组,必须手动传删除器:
std::shared_ptr<int> sp(new int[100],
std::default_delete<int[]>());
// 或者用 lambda
std::shared_ptr<int> sp(new int[100],
[](int* p) { delete[] p; });
但这样有个问题:你不能用 sp[0] 访问元素,因为类型是 shared_ptr<int>,不是 shared_ptr<int[]>。你得用 sp.get()[0]。说实话,这很不优雅。
我个人建议:如果非要用 shared_ptr 管理数组,不如直接用 std::vector 或者 std::array。智能指针不是万能的,别硬用。
7.3 智能指针的性能开销:别被“智能”两个字骗了
很多人觉得智能指针“慢”。其实要看你怎么用。咱们来拆解一下。
7.3.1 unique_ptr 的性能
unique_ptr 在默认删除器下,和裸指针性能完全一样。为什么?因为它不包含虚函数,没有控制块,所有操作都是编译期确定的。说白了,它就是裸指针的一层薄包装,编译器优化后连包装都没了。
| 操作 | 裸指针 | unique_ptr | shared_ptr |
|---|---|---|---|
| 构造 | 1 次分配 | 1 次分配 | 2 次分配(对象 + 控制块) |
| 拷贝 | 复制地址 | 禁止拷贝 | 原子操作 +1 |
| 析构 | 手动 delete | delete | 原子操作 -1,可能 delete |
| 移动 | 复制地址 | 复制地址 + 置空 | 复制地址 + 置空 |
你看,unique_ptr 的构造和析构就是一次 new/delete,和裸指针一模一样。所以别再说什么“智能指针慢”了,那是你没用对。
7.3.2 shared_ptr 的性能
shared_ptr 确实有开销,主要体现在两点:
- 控制块分配:
make_shared可以一次分配对象和控制块,但如果你用new构造,就是两次分配。 - 引用计数的原子操作: 拷贝和析构时都要做原子加减。原子操作比普通整数操作慢一个数量级。
我在项目中做过压测:在 8 核机器上,大量拷贝 shared_ptr 比拷贝裸指针慢了大约 3-5 倍。但注意,这是极端情况。如果你的业务逻辑本身就很重(比如数据库查询、文件读写),这点开销根本不算什么。
unique_ptr 就别用 shared_ptr。所有权明确时,unique_ptr 既安全又高效。只有当你确实需要共享所有权时,才考虑 shared_ptr。
7.4 项目中的最佳实践:我这些年总结的几条铁律
好,理论讲完了,咱们来点实在的。下面这几条,是我在多个 C++ 项目中摸爬滚打总结出来的。你照着做,至少能避开 80% 的坑。
7.4.1 优先使用 make_shared 和 make_unique
// 推荐
auto p1 = std::make_shared<MyClass>(arg1, arg2);
auto p2 = std::make_unique<MyClass>(arg1, arg2);
// 不推荐
std::shared_ptr<MyClass> p3(new MyClass(arg1, arg2));
std::unique_ptr<MyClass> p4(new MyClass(arg1, arg2));
为什么?两个原因:
- 异常安全: 如果你用
new,在构造智能指针之前如果抛出异常,内存就泄漏了。make_shared和make_unique不会出现这个问题。 - 性能:
make_shared一次分配对象和控制块,减少一次内存分配。
唯一的例外是:你需要自定义删除器,或者你用的是 std::unique_ptr<T[]>(C++14 之前 make_unique 不支持数组)。
7.4.2 不要用裸指针接收智能指针的内部指针
// 危险!
auto sp = std::make_shared<int>(42);
int* raw = sp.get();
// 如果 sp 被析构,raw 就成了悬空指针
我曾经在代码里犯过这个错:把一个 shared_ptr 的裸指针传给了一个回调函数,回调执行时 shared_ptr 已经被析构了。程序崩溃,查了两天才找到原因。
如果你一定要传裸指针,确保智能指针的生命周期覆盖裸指针的使用周期。或者,干脆传 shared_ptr 本身。
7.4.3 避免循环引用
这是 shared_ptr 最经典的坑。两个对象互相持有对方的 shared_ptr,引用计数永远降不到 0,内存泄漏。
struct Node {
std::shared_ptr<Node> next;
// 如果 next 指向自己,或者形成环,就泄漏了
};
// 解决方案:用 weak_ptr 打破环
struct Node {
std::weak_ptr<Node> next; // 不增加引用计数
};
嗯,这里要注意:weak_ptr 不能直接访问对象,你得先调用 lock() 提升为 shared_ptr。如果对象已经被释放,lock() 返回空。
7.4.4 在接口设计中明确所有权语义
函数参数和返回值用什么指针,直接反映了你的设计意图:
- unique_ptr 作为返回值: 表示“我给你一个对象,所有权归你”。
- unique_ptr 作为参数: 表示“我把所有权转移给你”。
- shared_ptr 作为参数: 表示“我要共享这个对象,你得保证它活着”。
- 裸指针或引用作为参数: 表示“我只是临时用一下,不关心生命周期”。
我个人习惯:如果函数只是读取对象,不修改所有权,就用 const T& 或 T*。只有在需要管理生命周期时,才用智能指针。别滥用。
7.5 本章知识体系
下面这张图,帮你把本章的核心逻辑串起来:
好了,这一章的内容就到这里。智能指针这东西,光看理论是不够的。我建议你打开编译器,把上面的代码都跑一遍,改一改,看看报什么错。只有亲手踩过坑,才能真正记住。