12. unique_ptr:自定义删除器、数组支持

unique_ptr 可以说是现代 C++ 里我最常用的智能指针了。它轻量、高效,而且语义清晰——「独占所有权」。但很多人用着用着就发现,有些场景下默认的 delete 不够用,或者需要管理数组。今天我们就来聊聊这两个进阶话题。

为什么需要自定义删除器?

默认情况下,unique_ptr 析构时调用 delete。但现实世界没那么简单。我在项目中遇到过好几次这样的情况:

  • 对象是通过 malloc 分配的,得用 free 释放
  • 资源来自 C 库的 fopen,需要 fclose
  • 自定义内存池分配的对象,需要归还给池子

这时候,自定义删除器就派上用场了。

自定义删除器的基本用法

删除器可以是一个函数指针、一个 lambda,或者一个函数对象。我个人习惯用 lambda,因为代码更紧凑。

// 方式一:函数指针
void close_file(FILE* fp) {
    if (fp) fclose(fp);
}

std::unique_ptr<FILE, decltype(&close_file)> 
    file_ptr(fopen("test.txt", "r"), close_file);

// 方式二:lambda(推荐)
auto file_deleter = [](FILE* fp) {
    if (fp) {
        fclose(fp);
        std::cout << "文件已关闭\n";
    }
};

std::unique_ptr<FILE, decltype(file_deleter)> 
    file_ptr2(fopen("test.txt", "r"), file_deleter);

// 方式三:函数对象
struct FreeDeleter {
    void operator()(void* p) const {
        free(p);
        std::cout << "内存已释放\n";
    }
};

std::unique_ptr<int, FreeDeleter> 
    int_ptr(static_cast<int*>(malloc(sizeof(int))));
我的建议:如果删除逻辑简单,用 lambda 最方便。如果删除逻辑复杂或需要复用,定义函数对象更合适。

删除器类型对 unique_ptr 大小的影响

嗯,这里有个容易被忽略的点——删除器的类型会影响 unique_ptr 的大小。

删除器类型 unique_ptr 大小 说明
默认删除器(default_delete) 8 字节(64位) 无额外开销
无状态函数对象(如 lambda 无捕获) 8 字节 空基类优化(EBO)生效
有状态函数对象(如 lambda 捕获了变量) 8 + 捕获变量大小 额外存储捕获的数据
函数指针 16 字节 指针本身占 8 字节

为什么会这样?因为 unique_ptr 内部把删除器作为成员存储。无状态的函数对象可以通过空基类优化压缩掉,但函数指针不行——它实实在在占 8 个字节。

关键点:如果你对内存占用敏感,优先用无捕获的 lambda 或无状态的函数对象。函数指针会多占 8 字节。

数组支持:unique_ptr<T[]>

很多人不知道,unique_ptr 对数组有专门的支持。默认的 unique_ptr<T> 调用 delete,而 unique_ptr<T[]> 调用 delete[]。这两者不能混用。

// 错误用法:用 delete 释放数组
std::unique_ptr<int> bad(new int[10]);  // 未定义行为!

// 正确用法
std::unique_ptr<int[]> good(new int[10]);

// 访问元素
good[0] = 42;   // 支持下标访问
good[1] = 100;

// 不支持 * 和 -> 操作符
// *good;  // 编译错误

我记得有一次代码审查,看到同事用 unique_ptr<int> 管理数组,当时就提醒他改成了 unique_ptr<int[]>。这种错误很难在运行时发现,但后果可能是内存泄漏或崩溃。

数组版本的独特之处

unique_ptr<T[]> 和 unique_ptr<T> 有几个关键区别:

  • 下标操作符ptr[i] 直接可用,不用先解引用
  • 没有 * 和 ->:因为数组不是单个对象
  • 删除器默认是 delete[]:自动匹配
  • 自定义删除器同样支持:但签名要匹配数组释放
// 数组 + 自定义删除器
struct ArrayDeleter {
    void operator()(int* p) const {
        std::cout << "释放数组,元素个数: " 
                  << _msize(p) / sizeof(int) << "\n";
        delete[] p;
    }
};

std::unique_ptr<int[], ArrayDeleter> 
    arr(new int[100]);

避坑指南:我曾经踩过的坑

我曾经在项目里犯过一个低级错误——用 unique_ptr<T> 管理通过 new T[N] 分配的内存。当时代码跑得好好的,直到某次压力测试,程序随机崩溃。查了两天才发现是 delete 和 delete[] 不匹配导致的堆损坏。

从那以后,我给自己定了个规矩:

  • 看到 new T[N],立刻用 unique_ptr<T[]>
  • 看到 malloc,立刻用自定义删除器调用 free
  • 看到 C 库资源(文件、socket),立刻封装成 RAII 类或用 unique_ptr 管理
警告:unique_ptr<T[]> 不支持 std::make_unique<T[]>(n) 的初始化列表语法。C++20 之前,你需要手动 new T[n]。C++20 引入了 std::make_unique<T[]>(n),但注意它会对元素进行值初始化。

知识体系图

下面这张图总结了 unique_ptr 在自定义删除器和数组支持方面的核心逻辑:

unique_ptr 自定义删除器 & 数组支持 unique_ptr 自定义删除器 数组支持 unique_ptr<T[]> 函数指针 lambda(推荐) 函数对象 下标访问 ptr[i] 自动 delete[] 无 * 和 -> 注意事项 • 删除器类型影响 unique_ptr 大小(函数指针多占 8 字节) • 数组版本必须用 unique_ptr<T[]>,否则未定义行为 • 无状态 lambda 可通过 EBO 优化,不增加内存占用

实际项目中的最佳实践

说了这么多,总结几条我在项目中实际遵循的原则:

  1. 优先用 make_unique:除非你需要自定义删除器或数组
  2. 数组一律用 unique_ptr<T[]>:别给自己挖坑
  3. 删除器用无捕获 lambda:性能好,代码也清晰
  4. 复杂资源管理考虑封装成 RAII 类:如果删除逻辑太复杂,不如写个专门的类
// 一个实际例子:管理 OpenGL 纹理
auto texture_deleter = [](GLuint* id) {
    if (id) {
        glDeleteTextures(1, id);
        delete id;
    }
};

std::unique_ptr<GLuint, decltype(texture_deleter)> 
    texture(new GLuint(0), texture_deleter);

glGenTextures(1, texture.get());
// 使用纹理...
// 离开作用域时自动释放

你看,自定义删除器让 unique_ptr 能管理各种资源,不仅仅是堆内存。这也是我为什么说 unique_ptr 是现代 C++ 资源管理的基石——它足够灵活,又足够安全。

小技巧:如果你经常用某种自定义删除器,可以定义一个类型别名:
template<typename T> using unique_ptr_free = std::unique_ptr<T, FreeDeleter>;
这样用起来就跟普通 unique_ptr 一样方便了。

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