13. 自定义删除器:函数指针、仿函数、lambda 表达式、删除器类型擦除

智能指针的删除器,说白了就是“对象销毁时怎么收尾”。

默认情况下,std::unique_ptr<T>std::shared_ptr<T> 会调用 delete 来释放内存。但现实世界哪有这么简单?我遇到过用 malloc 分配的内存、需要 fclose 的文件句柄、甚至自定义内存池里的对象……这时候,你就得告诉智能指针:“别用 delete,用我这个函数。”

这就是自定义删除器登场的地方。

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

你想想看,C++ 里资源管理不只是“new/delete”这一对。常见的场景包括:

  • C 语言接口fopenfclosemallocfree
  • 第三方库:比如 OpenGL 的 glDeleteBuffers,Windows 的 CloseHandle
  • 特殊清理逻辑:写日志、统计计数、延迟释放

没有自定义删除器,你就只能手动管理这些资源,或者写一堆包装类。嗯,这显然不是我们想要的。

删除器的三种实现方式

C++ 给了我们至少三种方式来实现删除器。我挨个说说它们的用法和坑。

1. 函数指针

最直接的方式。定义一个普通函数,把它的地址传给智能指针。

// 自定义删除器:用 free 释放 malloc 分配的内存
void MallocDeleter(void* p) {
    std::cout << "调用 free 释放内存\n";
    free(p);
}

int main() {
    std::unique_ptr<int, decltype(&MallocDeleter)> 
        ptr(static_cast<int*>(malloc(sizeof(int))), MallocDeleter);
    *ptr = 42;
    // 离开作用域时自动调用 MallocDeleter
    return 0;
}

这里有个细节:unique_ptr 的删除器类型是模板参数的一部分。所以 decltype(&MallocDeleter) 是必须的。我个人觉得这样写有点啰嗦,但胜在清晰。

注意:函数指针作为删除器时,unique_ptr 的大小会增加(因为要存储函数指针)。对于 shared_ptr,删除器是类型擦除的,不会影响指针大小。

2. 仿函数(函数对象)

仿函数就是一个重载了 operator() 的类。它可以携带状态,比函数指针更灵活。

struct FileCloser {
    void operator()(FILE* fp) const {
        if (fp) {
            std::cout << "关闭文件句柄\n";
            fclose(fp);
        }
    }
};

int main() {
    std::unique_ptr<FILE, FileCloser> 
        ptr(fopen("test.txt", "w"), FileCloser{});
    // 使用文件...
    // 离开作用域时自动调用 FileCloser::operator()
    return 0;
}

仿函数的好处是:你可以把额外参数存到类成员里。比如记录释放次数、打印调试信息等。我在项目中就用仿函数做过一个“延迟删除”的删除器——对象销毁后等 5 秒再真正释放,用来排查野指针问题。

3. Lambda 表达式

Lambda 是现代 C++ 最优雅的方式。它本质上是一个匿名仿函数,但写起来简洁得多。

int main() {
    auto deleter = [](int* p) {
        std::cout << "lambda 删除器:delete int\n";
        delete p;
    };

    std::unique_ptr<int, decltype(deleter)> ptr(new int(100), deleter);
    
    // 或者更紧凑的写法
    auto ptr2 = std::unique_ptr<int, void(*)(int*)>(
        new int(200), [](int* p) { delete p; }
    );
    return 0;
}

这里有个陷阱:无捕获的 lambda 可以隐式转换为函数指针,所以第二种写法能编译。但一旦 lambda 捕获了变量(比如 [count]),就不能转成函数指针了,必须用 decltype 推导类型。

我的建议:日常开发优先用 lambda。它可读性好,而且编译器容易内联。只有需要复用删除器逻辑时,才考虑提取成仿函数或普通函数。

删除器类型擦除

刚才提到 unique_ptr 的删除器是类型的一部分。这意味着:

std::unique_ptr<int, void(*)(int*)> p1;
std::unique_ptr<int, MyDeleter> p2;
// p1 和 p2 是不同类型!不能互相赋值

shared_ptr 不一样。它的删除器是类型擦除的——删除器的具体类型被隐藏了,只保留调用接口。

// shared_ptr 的删除器不会影响指针类型
std::shared_ptr<int> sp1(new int(1), [](int* p) { delete p; });
std::shared_ptr<int> sp2(new int(2), std::default_delete<int>{});

// 可以放进同一个容器
std::vector<std::shared_ptr<int>> vec;
vec.push_back(sp1);
vec.push_back(sp2);  // 没问题!

为什么 shared_ptr 能做到?因为它内部有一个“控制块”,删除器就存在控制块里。控制块里存的是 std::function<void(T*)> 或者类似的类型擦除机制。代价是一次额外的堆分配(控制块本身)和间接调用。

核心区别
unique_ptr 的删除器是零开销抽象——编译器知道具体类型,可以内联调用。
shared_ptr 的删除器是类型擦除——灵活但多一次间接调用。

实战:资源管理中的删除器应用

我去年维护过一个遗留系统,里面全是 C 风格的资源句柄。用自定义删除器包装后,代码干净多了:

// 数据库连接句柄的 RAII 包装
using DBConnection = void;  // 假装是某种句柄

void CloseDBConnection(DBConnection* conn) {
    std::cout << "关闭数据库连接\n";
    // 实际调用 db_close(conn);
}

int main() {
    // 用 unique_ptr 管理数据库连接
    auto conn = std::unique_ptr<DBConnection, decltype(&CloseDBConnection)>(
        /* db_open(...) */ nullptr, 
        CloseDBConnection
    );
    
    // 用 shared_ptr 管理多个地方引用的配置
    auto config = std::shared_ptr<Config>(
        LoadConfig("config.json"),
        [](Config* cfg) {
            std::cout << "保存并释放配置\n";
            SaveConfig(cfg);
            delete cfg;
        }
    );
    return 0;
}

你看,删除器让这些资源管理变得统一、安全。不用再担心哪个分支忘了 fclosedb_close

删除器选择指南

场景 推荐方式 理由
简单释放逻辑 Lambda 表达式 简洁、内联、可读性好
需要复用删除器 仿函数 可携带状态,可测试
与 C 代码交互 函数指针 兼容 C 回调,类型简单
需要类型擦除 shared_ptr + lambda 删除器不影响指针类型

知识结构图

自定义删除器知识体系 自定义删除器 函数指针 仿函数(函数对象) Lambda 表达式 特性 • 零开销抽象 • 类型是模板参数 • 可转为函数指针 特性 • 可携带状态 • 可复用、可测试 • 编译器可内联 特性 • 语法最简洁 • 无捕获可转函数指针 • 现代 C++ 首选 shared_ptr 类型擦除:灵活但间接 unique_ptr 零开销 vs shared_ptr 类型擦除 —— 根据场景选择

避坑指南

我曾经在代码里犯过一个低级错误:把 lambda 删除器写成局部变量,然后 lambda 捕获了栈上变量的引用。结果对象延迟释放时,引用已经悬空了。嗯,这 bug 查了我整整一个下午。

所以记住:

  • 如果删除器可能延迟执行(比如 shared_ptr 被拷贝到其他地方),不要捕获引用或指针
  • 如果删除器很简单,优先用无捕获 lambda,它可以退化成函数指针
  • unique_ptr 的删除器类型必须明确指定,不能自动推导(C++17 的 CTAD 可以部分缓解)

自定义删除器是个小功能,但用好了能让资源管理脱胎换骨。说白了,它就是 RAII 理念的最后一公里——让智能指针能管理“任何资源”,而不仅仅是堆内存。

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