7、智能指针(下):自定义删除器、智能指针与数组、智能指针的性能开销、在项目中的最佳实践

好,咱们接着聊智能指针。上一章我们把 unique_ptrshared_ptrweak_ptr 的基本用法过了一遍。这一章,我打算深入一些——聊聊那些你在教科书上可能看不到,但在实际项目中一定会踩到的坑。

说白了,智能指针不是万能的。用不好,它照样会泄漏、会崩溃、会把你坑得怀疑人生。我自己就在这几个地方栽过跟头,今天一并讲给你听。

7.1 自定义删除器:当 delete 不够用的时候

默认情况下,unique_ptrshared_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 的构造函数。

小技巧: 如果你的删除器逻辑简单,直接用 lambda 就行。如果删除器要在多处复用,建议定义一个函数对象(仿函数),代码更干净。

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。如果你不传自定义删除器,程序会直接崩溃。记住:mallocfreenewdelete,别混用。

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]),那就是未定义行为——析构时只释放了第一个元素,剩下的全泄漏了。

核心原则: new 配 delete,new[] 配 delete[]。智能指针只是帮你自动调用,但调用哪个,你得告诉它。

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_sharedmake_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*。只有在需要管理生命周期时,才用智能指针。别滥用。

一句话总结: unique_ptr 是“独生子”,shared_ptr 是“共享房”,weak_ptr 是“旁观者”。选对角色,代码自然就清晰了。

7.5 本章知识体系

下面这张图,帮你把本章的核心逻辑串起来:

智能指针(下)知识体系 自定义删除器 • unique_ptr:删除器是类型的一部分 • shared_ptr:删除器存在控制块中 • 典型场景:fclose、free、资源释放 • 注意:lambda vs 函数对象的选择 智能指针与数组 • unique_ptr<T[]>:C++17 原生支持 • shared_ptr:需手动传 delete[] • 坑:类型不匹配导致未定义行为 • 建议:优先用 vector/array 性能开销 • unique_ptr:零开销抽象(默认删除器) • shared_ptr:控制块 + 原子操作 • make_shared:一次分配,性能更优 • 权衡:安全 vs 性能,按需选择 最佳实践 • 优先用 make_shared / make_unique • 不要裸指针接收内部指针 • 用 weak_ptr 打破循环引用 • 接口设计明确所有权语义 核心:理解所有权语义,选择正确的智能指针

好了,这一章的内容就到这里。智能指针这东西,光看理论是不够的。我建议你打开编译器,把上面的代码都跑一遍,改一改,看看报什么错。只有亲手踩过坑,才能真正记住。


公众号:蓝海资料掘金营,微信deep3321