21. 自定义删除器:函数对象、lambda表达式

智能指针的删除器,说白了就是「资源释放的最后一道关卡」。

默认情况下,std::unique_ptrstd::shared_ptrdelete 来释放内存。但现实世界哪有这么简单?我做过一个嵌入式项目,内存是从特定池子里分配的,必须调用 pool_free() 来归还。这时候默认删除器就完全不够用了。

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

你想想看,下面这些场景,默认的 delete 能搞定吗?

  • 非堆内存:比如用 malloc 分配的内存,得用 free 释放
  • 文件句柄fopen 打开的文件,得用 fclose 关闭
  • 自定义分配器:从对象池、内存池借来的资源,得归还
  • 特殊清理逻辑:释放前需要写日志、更新统计信息

嗯,这时候就需要自定义删除器登场了。

核心思想:删除器是一个可调用对象,智能指针在析构时会调用它来释放资源。你给它什么,它就执行什么。

三种实现方式

我个人习惯把自定义删除器分成三类:函数指针、函数对象、lambda 表达式。咱们一个一个看。

1. 函数指针

最直接的方式,写一个普通函数当删除器。

// 自定义删除函数
void custom_deleter(int* p) {
    std::cout << "自定义删除器被调用,释放地址: " << p << std::endl;
    delete p;
}

int main() {
    std::unique_ptr<int, decltype(&custom_deleter)> 
        ptr(new int(42), custom_deleter);
    // 离开作用域时,自动调用 custom_deleter
    return 0;
}

注意这里 decltype(&custom_deleter) 指定了删除器类型。为什么是函数指针类型?因为 unique_ptr 的删除器是模板参数的一部分,必须在编译期确定。

注意:函数指针作为删除器有个小坑——它本身不携带状态。如果你需要在删除时访问某些上下文信息(比如日志对象、计数器),函数指针就无能为力了。

2. 函数对象(仿函数)

函数对象可以携带状态,这是它最大的优势。

// 带状态的删除器
struct FileDeleter {
    bool log_enabled;
    
    explicit FileDeleter(bool log) : log_enabled(log) {}
    
    void operator()(FILE* file) const {
        if (log_enabled) {
            std::cout << "[日志] 关闭文件句柄" << std::endl;
        }
        if (file) {
            fclose(file);
            std::cout << "文件已关闭" << std::endl;
        }
    }
};

int main() {
    // 打开文件
    FILE* f = fopen("test.txt", "w");
    if (!f) return -1;
    
    // 使用带日志的删除器
    std::unique_ptr<FILE, FileDeleter> 
        ptr(f, FileDeleter(true));
    
    // 离开作用域时,自动关闭文件并记录日志
    return 0;
}

我在项目中遇到过类似场景:需要统计每个智能指针释放了多少次,释放时还要把信息写入监控系统。函数对象完美解决了这个问题——把计数器、日志句柄都塞进删除器里。

3. Lambda 表达式

说实话,这是我现在最常用的方式。简洁、直观、零开销。

int main() {
    // 用 lambda 作为删除器
    auto deleter = [](int* p) {
        std::cout << "lambda 删除器: " << *p << std::endl;
        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) { 
            std::cout << "内联 lambda" << std::endl;
            delete p; 
        }
    );
    
    return 0;
}

为什么 lambda 这么好用?因为它本质上是匿名函数对象,编译器会为它生成一个独一无二的类型。而且 lambda 可以捕获外部变量,相当于自带状态。

小技巧:如果 lambda 不捕获任何变量,它可以隐式转换为函数指针。这时候你可以用 void(*)(T*) 作为删除器类型,避免模板参数膨胀。

shared_ptr 的特殊之处

这里有个重要区别:shared_ptr 的删除器不是模板参数,而是构造函数的参数。这意味着删除器类型不会影响 shared_ptr 的类型。

// shared_ptr 的删除器是运行时绑定的
auto sp1 = std::shared_ptr<int>(new int(1), [](int* p) {
    std::cout << "删除器A" << std::endl;
    delete p;
});

auto sp2 = std::shared_ptr<int>(new int(2), [](int* p) {
    std::cout << "删除器B" << std::endl;
    delete p;
});

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

嗯,这里要注意:shared_ptr 通过类型擦除(type erasure)来存储删除器,所以会有微小的运行时开销。但换来的是灵活性——你可以在运行时决定用什么删除器。

避坑指南

我曾经踩过一个坑,分享给你:

  1. 删除器必须可调用:别传个 nullptr 或者不完整的类型,编译期直接报错
  2. 异常安全:删除器不应该抛出异常。如果必须抛,记得在删除器内部 catch 住
  3. 空指针检查:删除器被调用时,智能指针内部已经判断过非空了,但如果你手动调用 reset() 传了空指针,删除器还是会被调用
  4. 移动语义unique_ptr 移动时,删除器也会跟着移动。如果删除器是 lambda 且捕获了引用,要小心悬空引用

重要std::make_shared 不支持自定义删除器。如果你需要自定义删除器,必须直接用 std::shared_ptr 的构造函数。这是 make_shared 的一个已知限制。

知识体系图

下面这张图帮你理清自定义删除器的整体脉络:

自定义删除器知识体系 自定义删除器 函数指针 无状态,类型固定 函数对象 可携带状态 Lambda 表达式 简洁灵活,推荐 典型应用场景 非堆内存释放 文件/句柄管理 对象池归还 日志/监控 ⚠ 注意事项 异常安全 · 空指针检查 · 移动语义 · make_shared 限制

总结

自定义删除器是智能指针的「瑞士军刀」。函数指针适合简单场景,函数对象适合需要状态的场景,lambda 则是日常开发的首选。

我个人建议:除非你有明确的理由用函数对象,否则一律用 lambda。代码更短、意图更清晰、维护成本更低。

记住一句话:删除器是你的资源释放策略,别让它成为你的 bug 来源。

一句话总结:自定义删除器让智能指针不再局限于 new/delete,而是可以管理任何需要「获取-释放」模式的资源。


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