资源清理是 C++ 里永远绕不开的话题,unique_ptr的自定义删除器(custom deleter)就是为这件事设计的,而用lambda表达式实现自定义删除器,是我目前见过最清爽的写法。这篇文章从“为什么需要它”一路讲到“怎么写不踩坑”,包含一个可以直接抄进业务代码的临时文件自动清理示例,适合想深入理解智能指针、或者正在处理资源泄漏问题的 C++ 开发者。
先强调一个容易被忽略的事实:unique_ptr不是只能用来管理new出来的对象。它的真正价值在于“独占所有权”和“析构时自动释放”,至于释放的动作是什么,完全由删除器决定。默认删除器只是其中一种策略,真实代码里那些奇奇怪怪的资源句柄,才更需要你自己定义“怎么释放”。
1. 默认删除器解决不了的那一类问题
1.1 默认删除器的能力边界
std::unique_ptr<T>的默认删除器是std::default_delete<T>,它在析构时对内部指针执行delete。这句话看起来简单,但反过来理解就会明白它的局限:凡是不能用delete释放的资源,默认删除器全都管不了。
举几个常见例子:
std::fopen返回的FILE *,要用std::fclose关闭;- POSIX 文件描述符
int fd,要用close关闭; mkstemp创建的临时文件,除了关闭还要unlink清理;malloc分配的内存,要用free释放;- 动态库句柄,要用对应的
dlclose/ 释放函数处理; - 各种硬件抽象层或第三方库返回的句柄对象,都有各自专门的释放 API。
如果把这些资源直接交给普通的std::unique_ptr<FILE>,编译器不会拦你,但析构时会尝试delete一个不完整的FILE对象。这通常不会直接报错,但运行时的行为完全不收控制。有人觉得“反正都是释放,区别不大”,这想法很危险:delete和fclose背后走的完全是两套机制。
所以自定义删除器的第一动力,就是把“释放资源”的权利从delete手里抢回来,改由我们自己指定。
1.2 unique_ptr 为什么把删除器写进类型签名
注意到std::unique_ptr的模板参数有两个:template <typename T, typename Deleter = std::default_delete<T>>。第二个模板参数就是删除器类型。
这意味着std::unique_ptr<FILE, CloseDeleter1>和std::unique_ptr<FILE, CloseDeleter2>是两种完全不同的类型。哪怕它们管理的指针类型一样,只要删除器类型不同,就不能互相赋值、不能直接作为同一个函数参数传入。
初看这是个约束,但换个角度想,这正是 unique_ptr 能实现“零开销抽象”的关键。删除器类型在编译期就确定下来,编译器有机会直接内联删除逻辑,而不是在运行时通过虚拟函数或函数指针间接调用。
为什么 shared_ptr 不这么做?因为 shared_ptr 要在多个对象之间共享同一个控制块,删除器会被放进控制块里做类型擦除,类型本身不体现在std::shared_ptr<T>上。unique_ptr 没有共享需求,它生来就是独占的,连拷贝构造和拷贝赋值都被删除了。既然不允许对象间复制删除器,那么把删除器类型暴露出来,反而让调用关系更清晰。
1.3 shared_ptr 为什么显得更“随意”
不少读者是从 shared_ptr 转到 unique_ptr 的,容易踩一个误区:以为删除器还能像 shared_ptr 那样随便传。
// shared_ptr 的写法:删除器类型不需要写进尖括号 std::shared_ptr<FILE> sp( std::fopen("a.txt", "w"), [](FILE* f) { if (f) std::fclose(f); } );这段代码里的 lambda 被隐式转换成std::shared_ptr<FILE>内部的删除器,对使用者来说,函数签名里只看到一个shared_ptr<FILE>,清爽得很。
但换成 unique_ptr,同样写法就不行:
std::unique_ptr<FILE> up( std::fopen("a.txt", "w"), [](FILE* f) { if (f) std::fclose(f); } ); // 编译失败:这里明明用了两个参数构造,但类型里没有删除器原因就是 1.2 说的:unique_ptr 的删除器参与类型,shared_ptr 的删除器只参与构造。理解这一点,后续所有写法上的取舍都顺了。
2. 自定义删除器的三条路线,别急着写 lambda
很多人一提到自定义删除器就直接lambda,这没错,但为了真正理解 lambda 方案好在哪,最好先知道另外两条路是什么,以及它们各自牺牲了什么。
2.1 函数指针方案:能用但不灵活
最朴素的自定义删除器是函数指针:
using FileDeleter = int (*)(FILE*); std::unique_ptr<FILE, FileDeleter> fp( std::fopen("a.txt", "w"), std::fclose );std::fclose是普通函数,可以直接作为函数指针使用。这种方式能跑,两个缺点也很明显:
一是删除器不能携带任何状态。如果你希望在关闭文件时打日志、统计次数、或者根据配置决定是否做额外清理,函数指针做不到。
二是运行时多一次间接调用。虽然编译器在优化时可能通过常量传播把函数地址固化,但严格来说,函数指针本身是一层间接寻址。更何况 unique_ptr 要额外存储一个 8 字节的函数指针,不像空类型删除器那样有机会做空基类优化。
不过函数指针也不是一无是处。它的类型稳定,同一个FileDeleter可以接纳任何签名匹配的普通函数,适合那种“资源种类固定、清理逻辑全局唯一”的老代码。
2.2 仿函数方案:稳定,但代码量不受欢迎
仿函数,也就是写一个重载了operator()的类,是 C++98 时代就存在的标准解法:
struct FileCloser { void operator()(FILE* f) const { if (f) std::fclose(f); } }; std::unique_ptr<FILE, FileCloser> fp( std::fopen("a.txt", "w"), FileCloser{} );仿函数的好处是可携带状态,类型稳定,且在类型确定时编译器可以内联调用。比如可以在FileCloser里存一个bool removeAfterClose配置,析构时判断是不是要顺带清理文件。
缺点是代码结构重。一个简单资源要专门定义一个类,位置放哪儿、类名怎么起、要不要暴露在头文件里,都是麻烦事。如果一个项目里同时要管理文件、内存、互斥锁、网络连接,就得写一堆结构,看着都累。
2.3 lambda 方案:匿名类对象撞上了模板参数
lambda 的本质其实就是一个匿名仿函数对象。编译器会为每个 lambda 表达式生成一个独一无二的闭包类型,并在里面保存捕获的变量。
重点在于:这个“独一无二的类型”和 unique_ptr 的Deleter模板参数,天生就能配合。lambda 的出现让你不需要为每个资源手动定义一个结构体,直接在调用处写清理逻辑:
auto closeFile = [](FILE* f) { if (f) std::fclose(f); }; std::unique_ptr<FILE, decltype(closeFile)> fp( std::fopen("a.txt", "w"), closeFile );decltype(closeFile)得到的就是那个匿名闭包类型。lambda 可以捕获状态,所以仿函数能做的它都能做;lambda 写起来比仿函数短很多,代码局部性强,别人看这一行就知道资源是怎么释放的。
如果项目标准是 C++17,还能用类模板实参推导(CTAD)省掉显式类型:
std::unique_ptr fp( std::fopen("a.txt", "w"), closeFile );编译器能推导出第一个模板参数是FILE,第二个是decltype(closeFile),代码从两行变一行。
2.4 三个方案放到一起做选择
| 维度 | 函数指针 | 仿函数 | lambda |
|---|---|---|---|
| 能否携带状态 | 不能 | 能 | 能 |
| 调用开销 | 通常间接调用 | 可内联 | 可内联 |
| 是否参与类型 | 是 | 是 | 是 |
| 代码量 | 少 | 多 | 最少 |
| 推荐场景 | 兼容 C API、全局清理函数 | 状态复杂、希望类型稳定复用 | 现代 C++ 默认选择 |
我的默认建议:能用 lambda 就用 lambda。只有当删除器类型需要在多个函数签名之间流传、或者逻辑太复杂需要单独维护时,才退回仿函数。函数指针在现代代码里不占优势,除非你在写 C 和 C++ 混编的老项目。
3. 实操:lambda 删除器从能跑到好用
3.1 最小示例:托管 FILE* 并看懂 decltype
下面是最小可用版本:
#include <cstdio> #include <memory> int main() { auto closeFile = [](FILE* f) { if (f) std::fclose(f); }; std::unique_ptr<FILE, decltype(closeFile)> fp( std::fopen("demo.txt", "w"), closeFile ); if (!fp) return 1; std::fprintf(fp.get(), "hello\n"); return 0; }注意三件事。
第一,lambda 变量closeFile和std::unique_ptr的第二个模板参数之间是decltype(closeFile)的关系。闭包类型通常是空类,存储成本很低,大多数标准库实现会通过类似空基类优化的办法让sizeof(unique_ptr<FILE, decltype(closeFile)>)和sizeof(FILE*)保持一致。
第二,unique_ptr 构造时要显式传入删除器对象closeFile。这是因为在 C++20 之前,即使是无捕获 lambda,闭包类型也没有默认构造函数。你只有把 lambda 传进构造函数的第二个参数,unique_ptr 才拿得到那个删除器实例。
第三,删除器里if (f)不是必须的,因为 unique_ptr 持有的空指针不会触发删除器调用。但加上它成本极低,也避免了“某些代码路径手动把空指针塞进来”这种意外。我建议保留。
3.2 带状态的删除器:小心栈引用悬垂
lambda 支持捕获,所以可以在删除器里累积统计数据:
size_t closeCount = 0; auto closeFileWithLog = [&closeCount](FILE* f) { if (!f) return; ++closeCount; std::fclose(f); }; std::unique_ptr<FILE, decltype(closeFileWithLog)> fp( std::fopen("demo.txt", "w"), closeFileWithLog );这里[&closeCount]捕获的是引用。如果closeCount的生命周期比 unique_ptr 短,删除器在析构时就会访问一个已经失效的引用,属于未定义行为。
最常见的翻车方式是这样的:
std::unique_ptr<FILE, decltype(closer)> make() { size_t count = 0; auto closer = [&count](FILE* f) { /* 引用外部栈变量 */ }; return std::unique_ptr<FILE, decltype(closer)>(std::fopen(...), closer); // 返回后,count 已经销毁 }unique_ptr 移动返回时,删除器对象会跟着走,但引用捕获的count不会因此延长生命周期。
所以,删除器里需要访问外部变量时,优先考虑用值捕获,或者用下面要讲的 init-capture。
3.3 init-capture:把上下文安全拷进删除器
C++14 开始支持初始化捕获(init-capture),这名字听起来抽象,实际就是给 lambda 数据成员直接初始化:
auto closer = [ logPrefix = std::string("[resource] ") ](FILE* f) { if (!f) return; std::fprintf(stderr, "%s closing file\n", logPrefix.c_str()); std::fclose(f); };不想再用引用时,就直接把变量复制进来。这样一个删除器就能自己携带完整上下文,不再依赖外部对象是否存活。
在 C++11 上要用类似效果,只能靠仿函数;这也是 lambda 方案在 C++14 之后成为首选的原因之一。
3.4 完整场景:mkstemp 临时文件自动关闭并清理
临时文件这个场景最能体现自定义删除器的价值,因为临时文件要做的清理动作不止一个:关闭文件描述符,还得删掉磁盘上的文件。放在以前,要么手动在每条 return 前补清理语句,要么靠 RAII 包装类。用 lambda 删除器和 unique_ptr 组合,几行就解决问题。
#include <cstdio> #include <memory> #include <string> bool useTempFile() { char path[] = "/tmp/tmp_XXXXXX"; int fd = ::mkstemp(path); if (fd < 0) return false; auto cleaner = [savedPath = std::string(path)](int* p) { if (p && *p >= 0) { ::close(*p); std::remove(savedPath.c_str()); } delete p; }; std::unique_ptr<int, decltype(cleaner)> fdPtr( new int(fd), cleaner ); // 业务代码可以直接用 *fdPtr 作为文件描述符 // 不写业务时,函数结束时 fdPtr 自动完成 close + remove return true; }这里有几个细节值得展开。
mkstemp要求传入一个可写的字符数组,模板字符串必须以XXXXXX结尾,函数会把后缀替换成随机内容,并直接改传入的缓冲区。所以path必须是char[],不能传字符串字面量。
mkstemp修改了path之后,里面就是真正创建出的临时文件名。这个信息对清理很关键,所以我在 lambda 里用savedPath = std::string(path)做了一次拷贝。如果图省事写[&path],等函数返回时path已经离开作用域,删除器再取path.c_str()就是悬垂访问。
unique_ptr 管理的是new int(fd),也就是一个堆上的 int 包装。删除器拿到int* p后,先关闭真正的 fd,再删除临时文件,最后delete p释放包装对象自己。这套组合看起来有点绕,但好处是能把“关闭 fd、移除文件、释放包装”收成一个自动动作。
前面几行业务代码如果中途异常退出,或者某条分支直接return false,也不用担心漏清理。这就是 RAII 带来的最大舒适感。
3.5 让删除器保持“无捕获”的两个好理由
无捕获的 lambda 闭包类型是一个空类,存储成本极低。大多数标准库实现会让空删除器不额外占用空间,于是unique_ptr大小和一个裸指针一样。
有捕获的 lambda 就不同。捕获一个int,闭包对象里就多一个int;捕获一个std::string,闭包对象里就多一个 string。这一切都会反映到unique_ptr的sizeof上,而unique_ptr经常被放进 vector、unordered_map 等容器里,对象变大,缓存命中率跟着变差。
第二,无捕获 lambda 可以很方便地转换成函数指针。这意味着在需要和 C 接口打交道时,可以拿它直接匹配void(*)(void*)这类回调签名,减少一层胶水代码。
所以我的规则是:删除器优先写成无捕获形式,确实需要上下文时,优先考虑把上下文放进被管理对象本身,其次才考虑捕获。
4. 删除器与资源生命周期管理的关系
4.1 delete 不是删除器,unique_ptr 也不直接 delete
默认删除器里执行的是delete ptr,所以很多人误以为 unique_ptr 就是“自动 delete 的指针”。这话只对了一半。真正准确的描述是:unique_ptr 在析构时调用“删除器”,删除器负责释放资源;默认删除器恰好使用delete而已。
一旦设置了自定义删除器,unique_ptr 本身不再关心资源是怎么释放的。你可以用free、fclose、close、自定义的库函数,甚至是空操作。只要删除器接受T*并完成该做的善后即可。
这个认知很重要。因为一旦把人家的资源改成自定义删除器,就不能继续混用delete了。典型错误是这样:
int* raw = static_cast<int*>(std::malloc(sizeof(int))); auto deleter = [](int* p) { /* 空操作,假装不释放 */ }; std::unique_ptr<int, decltype(deleter)> p(raw, deleter);如果删除器真的什么都不做,内存就泄漏了。如果删除器写成delete p,而实际内存由malloc分配,释放方式不匹配,同样是未定义行为。正确的写法是std::free(p)。
一句话总结:删除器和delete是两回事,用什么分配就要用什么配套释放。
4.2 release、move、reset 对删除器的影响
不少人在自定义删除器之后,对 release、move、reset 的语义开始犯迷糊。其实规则和默认删除器时完全一样,只是心里要记着“删除器对象本身也会跟着 unique_ptr 走”。
auto deleter = [](FILE* f) { if (f) std::fclose(f); }; std::unique_ptr<FILE, decltype(deleter)> fp( std::fopen("a.txt", "w"), deleter );release()会把内部指针置空,并返回原来的裸指针。此时 unique_ptr 不再持有资源,删除器自然不会被调用。拿到裸指针的人要自己负责后续释放。这是所有权的交接,不是简单“关掉”。
reset()会先对旧指针调用删除器,再让 unique_ptr 持有新指针。reset(nullptr)也是一样的流程,先把旧资源清理掉,然后置空。
移动构造和移动赋值时,删除器对象本身也会被转移。对无捕获 lambda 来说,这通常就是一次平凡移动,代价很低。但有引用捕获的 lambda 要小心:如果引用指向的变量已经失效,移动过去后的删除器一样是坏的。
还有个底层细节:unique_ptr 的移动赋值要求删除器类型满足可移动赋值。绝大多数 lambda 和仿函数都满足,不用担心,但如果你用了某个奇葩的仿函数,连移动赋值都禁用,编译会直接报错。
4.3 make_unique 对自定义删除器说了不
std::make_unique<T>(args...)不接受删除器参数,这是很多人反复踩的坑。
make_unique的职责是在堆上构造对象,再用new拿到的指针构造 unique_ptr。它内部根本不留位置给你传第二个参数。就算你想写:
auto ptr = std::make_unique<FILE>(...); // 不可能把 fclose 传进去也无从谈起。所以只要你想用自定义删除器,就要回退到最原始的构造函数形式:
std::unique_ptr<FILE, decltype(closer)> fp( std::fopen("a.txt", "w"), closer );C++17 之后多了std::make_unique_for_overwrite,但同样不支持删除器。这一点对性能没有影响,只是代码写法要从习惯性使用 factory 函数切换到显式构造。
4.4 换成 shared_ptr 时删除器的入乡随俗
当一个函数对外暴露的接口从 unique_ptr 换成 shared_ptr,删除器的写法要随之改变。
// unique_ptr 版本:删除器在类型里 auto deleter = [](FILE* f) { if (f) std::fclose(f); }; std::unique_ptr<FILE, decltype(deleter)> up( std::fopen("a.txt", "w"), deleter ); // shared_ptr 版本:删除器在构造参数里 std::shared_ptr<FILE> sp( std::fopen("a.txt", "w"), deleter );函数签名从std::unique_ptr<FILE, SomeCustomDeleter>变成干净的std::shared_ptr<FILE>,这对接口更友好。但也有代价:shared_ptr 的删除器被放进堆上的控制块里,类型被擦除,调用时多了一层间接操作。
反过来说,如果你需要一个删除器类型不体现在接口上的独占资源,shared_ptr 反而比 unique_ptr 合适。这是由两者底层机制决定的,不存在谁绝对更好。
5. 常见坑与避坑清单
5.1 不完整类型导致的析构报错
如果T是不完整类型,默认删除器delete T*就没办法完成析构。这个问题在自定义删除器时也会出现,而且更隐蔽。
比如在头文件里声明:
// 某头文件 struct ResourceImpl; std::unique_ptr<ResourceImpl, MyDeleter> createResource();ResourceImpl的定义放在 cpp 文件里。如果你的自定义删除器要在析构时访问ResourceImpl的成员,或者以其作为参数调用某个函数,那删除器定义处和析构实例化处都必须能看到完整类型。
解决办法很简单:把删除器的实现和 unique_ptr 的实例化放到同一个翻译单元中,或者保证删除器只依赖前置声明就可以完成的动作。
这里有一个区分点:unique_ptr 本身的析构要求T完整,这是标准要求;自定义删除器做的额外动作,比如free(ptr->handle),则额外要求T的布局可见。不要混为一谈。
5.2 引用捕获带来的悬垂引用
5.3 节里那个[&count]的例子,我再展开说一次。
删除器是一个会长期存在的对象,它可能在 unique_ptr 析构那一刻才被调用。你捕获的外部变量的生命周期,必须覆盖到删除器实际执行的时间点,这个时间点经常比创建 unique_ptr 的作用域长得多。
比如一个 lambda 捕获了栈上的std::string logPath,然后 unique_ptr 被移动到函数外部继续使用。等函数栈帧销毁后,logPath就没了,删除器再访问它必然是未定义行为。
规避手段只有三种:
- 值捕获,把外部变量拷贝进 lambda;
- 用 init-capture 构造新的副本,比如
[copied = std::string(path)]; - 使用仿函数,把需要的数据作为成员变量保存。
代码里如果出现了[&xxx]或者[this]的删除器,先停下来想想:谁借走了这个引用?借走之后,它还能活多久?
5.3 删除器的体积可能改变 unique_ptr 的 sizeof
默认的std::unique_ptr<int>大小通常和一个指针一样,这让很多人形成了“unique_ptr 就是裸指针体积”的直觉。一旦换成自定义删除器,直觉可能失效。
有状态的 lambda 闭包对象不是空类,存储它会额外占内存。捕获一个 8 字节的uint64_t,unique_ptr 的体积多半会从 8 字节变成 16 字节;捕获一个std::string,体积涨得更明显。
大量 unique_ptr 放在容器里时,这个差异会直接影响内存占用和缓存性能。所以还是那句老话:删除器越简单越好,完全没有捕获最好。
如果你想知道当前实现在某平台上的大小,可以编译期断言辅助检查:
static_assert(sizeof(std::unique_ptr<FILE, decltype(closeFile)>) == sizeof(FILE*));注意这个断言不一定在所有编译器上都成立,标准没有强制要求空删除器必须零占用,但主流的几种实现都做了类似优化,实测基本能通过。
5.4 lambad 还是 lambda:先拼对名字,再注意 decltype 的位置
标题里写的“lambad 表达式”应该是手误,正确的拼写是lambda。搜索引擎里偶尔能看到同样拼错的代码注释,写代码时还是保持标准拼写更好。
比拼写更值得注意的,是decltype和 lambda 的配合位置。
C++17 以及更早的版本,lambda 表达式不能直接出现在未求值上下文中,比如decltype:
// C++17 会报错的写法 using FilePtr = std::unique_ptr<FILE, decltype([](FILE* f) { std::fclose(f); })>;这个代码在 C++20 之前编译不过。想兼容 C++17,必须先给 lambda 一个变量名:
auto closeFile = [](FILE* f) { if (f) std::fclose(f); }; using FilePtr = std::unique_ptr<FILE, decltype(closeFile)>;如果项目标准是 C++20,可以允许直接在decltype里写 lambda,风格会短一些,但很多人仍然觉得先起变量名更可读。我自己的习惯是:始终给删除器起名,既不踩兼容性问题,也方便复用。
5.5 常见场景速查
| 场景 | 推荐写法 |
|---|---|
管理FILE* | 无捕获 lambda,删除器内std::fclose |
| 管理文件描述符 | 无捕获或值捕获 lambda,删除器内close |
| 临时文件自动清理 | init-capture 保存路径,删除器内close + remove |
管理malloc内存 | 删除器内使用std::free |
| 删除器需要长期状态 | 仿函数,或 shared_ptr 构造时传 lambda |
| 接口类型要稳定 | 仿函数定义删除器类型 |
| 接口不让删除器类型外露 | 换成 shared_ptr |
| 容器内存放大量指针 | 优先无捕获 lambda,控制 unique_ptr 体积 |
最后分享一个我自己写代码时的小习惯:不管删除器多简单,几乎都会用一个有名字的 lambda 变量,而不是在 unique_ptr 构造时临时写一个匿名 lambda。原因很简单——有名字就能复用,在 C++20 之前也规避了 lambda 不能出现在decltype未求值上下文的问题。需要给 unique_ptr 起类型别名时,这个变量名配合decltype用起来特别顺手。
踩过几次坑之后我的体会是,自定义删除器本身不难,难的是想清楚资源的生命周期边界:谁创建、谁拥有、谁清理、清理时机由什么保证。把这些问题交给 unique_ptr 和 lambda,代码会干净很多,你也能把大脑留给真正复杂的业务逻辑。