13. 自定义删除器:函数指针、仿函数、lambda 表达式、删除器类型擦除
智能指针的删除器,说白了就是“对象销毁时怎么收尾”。
默认情况下,std::unique_ptr<T> 和 std::shared_ptr<T> 会调用 delete 来释放内存。但现实世界哪有这么简单?我遇到过用 malloc 分配的内存、需要 fclose 的文件句柄、甚至自定义内存池里的对象……这时候,你就得告诉智能指针:“别用 delete,用我这个函数。”
这就是自定义删除器登场的地方。
为什么需要自定义删除器?
你想想看,C++ 里资源管理不只是“new/delete”这一对。常见的场景包括:
- C 语言接口:
fopen配fclose,malloc配free - 第三方库:比如 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 推导类型。
删除器类型擦除
刚才提到 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;
}
你看,删除器让这些资源管理变得统一、安全。不用再担心哪个分支忘了 fclose 或 db_close。
删除器选择指南
| 场景 | 推荐方式 | 理由 |
|---|---|---|
| 简单释放逻辑 | Lambda 表达式 | 简洁、内联、可读性好 |
| 需要复用删除器 | 仿函数 | 可携带状态,可测试 |
| 与 C 代码交互 | 函数指针 | 兼容 C 回调,类型简单 |
| 需要类型擦除 | shared_ptr + lambda | 删除器不影响指针类型 |
知识结构图
避坑指南
我曾经在代码里犯过一个低级错误:把 lambda 删除器写成局部变量,然后 lambda 捕获了栈上变量的引用。结果对象延迟释放时,引用已经悬空了。嗯,这 bug 查了我整整一个下午。
所以记住:
- 如果删除器可能延迟执行(比如
shared_ptr被拷贝到其他地方),不要捕获引用或指针 - 如果删除器很简单,优先用无捕获 lambda,它可以退化成函数指针
unique_ptr的删除器类型必须明确指定,不能自动推导(C++17 的 CTAD 可以部分缓解)
自定义删除器是个小功能,但用好了能让资源管理脱胎换骨。说白了,它就是 RAII 理念的最后一公里——让智能指针能管理“任何资源”,而不仅仅是堆内存。