前言
智能指针的默认行为是“用delete销毁所指对象”。但现实中的资源不只有new出来的对象:std::fopen得到的文件指针要用std::fclose关闭,std::malloc得到的内存要用std::free释放,数组要用delete[],系统句柄要用对应的关闭函数。把这类指针直接塞进std::unique_ptr<T>或std::shared_ptr<T>,析构时调用的是delete,分配方式与释放方式不匹配,属于未定义行为(undefined behavior, UB)。
删除器(deleter)就是用来替换默认销毁动作的可调用对象。它让智能指针从“只管理 new 出来的对象”扩展为“管理任意需要确定性释放的资源”,这是 RAII 真正落地的地方。
围绕删除器有两个常见误解:一是认为unique_ptr和shared_ptr的删除器写法一样;二是认为std::make_unique、std::make_shared可以顺带指定删除器。前者错在删除器在两种智能指针里的地位完全不同,后者错在这两个工厂函数根本不接受删除器参数。本文把这两种智能指针的删除器分开讲清,并给出文件、内存缓冲区、数组等场景的完整写法。代码以 C++17 为基准,不声称经过编译或运行。
一、默认删除器与它的问题
std::unique_ptr<T>的默认删除器是std::default_delete<T>,它的operator()对指针执行delete。对数组类型,标准库提供了偏特化std::default_delete<T[]>,执行的是delete[]:
// 适用于 C++17
#include <memory>
struct Widget {
~Widget() = default;
};
void demo() {
std::unique_ptr<Widget> one(new Widget);
std::unique_ptr<Widget[]> many(new Widget[4]); // 使用 default_delete<Widget[]>
}std::shared_ptr的情况不同:删除器不是模板参数,而是构造时传入、存放在控制块里的可调用对象。默认构造(例如std::shared_ptr<Widget> p(new Widget))同样使用delete,对数组则要用std::shared_ptr<Widget[]>(C++17 起支持数组特化,默认销毁动作是delete[])。
默认删除器只适用于“由new或new[]分配、由delete或delete[]释放”的对象。以下场景都必须自定义删除器:
| 资源来源 | 正确释放方式 | 默认删除器会做什么 |
|---|
std::fopen | std::fclose | 对FILE*执行delete,UB |
std::malloc | std::free | 对指针执行delete,UB |
new[]得到的数组 | delete[] | 对单个对象执行delete,UB |
| 平台句柄(文件描述符、套接字等) | 对应的 close 函数 | 对句柄值执行delete,UB |
| 需要额外清理的对象 | 自定义清理逻辑 | 只调用析构,遗漏额外步骤 |
二、unique_ptr 的删除器:类型的一部分
std::unique_ptr的完整模板形式是std::unique_ptr<T, D>,D就是删除器类型。删除器是类型的一部分,两个unique_ptr只要删除器类型不同,就是完全不同的类型,不能互相拷贝赋值。
函数指针作为删除器
// 适用于 C++17
#include <cstdio>
#include <memory>
using FilePtr = std::unique_ptr<std::FILE, int (*)(std::FILE*)>;
int main() {
FilePtr fp(std::fopen("data.bin", "rb"), &std::fclose);
if (!fp) {
return 1;
}
// 离开作用域时调用 std::fclose(fp.get())
return 0;
}这里D是int (*)(std::FILE*),构造时必须同时传入指针和删除器。注意不能只写FilePtr fp(std::fopen(...));,因为函数指针类型没有默认值,默认构造出来是空指针,析构时调用空函数指针会崩溃(具体行为是 UB)。即使删除器类型可以默认构造,也建议显式传入,避免出现空删除器。
函数对象作为删除器
函数对象的可读性更好,也更容易携带状态:
// 适用于 C++17
#include <cstdio>
#include <memory>
struct FileCloser {
void operator()(std::FILE* fp) const noexcept {
if (fp != nullptr) {
std::fclose(fp);
}
}
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
int main() {
FilePtr fp(std::fopen("data.bin", "rb"));
if (!fp) {
return 1;
}
return 0;
}FileCloser是无状态类型,常见实现会利用空基类优化,使unique_ptr的大小与裸指针相同。但这是实现层面的优化,不要把它当成可以依赖的 ABI 保证;如果删除器有状态,unique_ptr一定会多存一份状态。
lambda 作为删除器
lambda 也能做删除器,但类型要借助decltype:
// 适用于 C++17
#include <cstdio>
#include <memory>
int main() {
auto closer = [](std::FILE* fp) {
if (fp != nullptr) {
std::fclose(fp);
}
};
std::unique_ptr<std::FILE, decltype(closer)> fp(std::fopen("data.bin", "rb"), closer);
return fp ? 0 : 1;
}无捕获 lambda 在 C++20 起可以默认构造,因此 C++20 下可以不传closer实例;C++17 则必须把实例传进构造函数。有捕获的 lambda 一定要保证捕获对象的生命周期覆盖智能指针,否则删除器执行时访问的是悬垂引用。
数组与自定义数组删除器
// 适用于 C++17
#include <cstdlib>
#include <memory>
struct FreeArray {
void operator()(int* p) const noexcept {
std::free(p);
}
};
int main() {
std::unique_ptr<int[]> numbers(new int[16]);
numbers[0] = 1;
int* raw = static_cast<int*>(std::malloc(sizeof(int) * 16));
if (raw == nullptr) {
return 1;
}
std::unique_ptr<int[], FreeArray> buffer(raw);
buffer[0] = 2;
return 0;
}std::unique_ptr<int[]>的默认删除器是std::default_delete<int[]>,执行delete[];std::unique_ptr<int[], FreeArray>则把删除器换成std::free。两种写法的模板参数不同,不能混用。
unique_ptr 删除器的几个约束
- 删除器类型必须能配合
T*调用,且通常应当可移动构造;unique_ptr本身不可拷贝,但可以移动。 - 删除器一旦确定就不能更换。
reset()只接受新的指针,不接受新的删除器;想换删除器只能构造另一个类型的unique_ptr并转移指针。 get_deleter()可以取回删除器的指针,类型不匹配时返回空指针。- 删除器在
unique_ptr析构、reset、赋值导致旧指针被释放时调用,调用时不应抛异常。析构函数默认是noexcept的,删除器抛出异常会导致std::terminate。 std::make_unique没有接受删除器的重载,自定义删除器只能直接构造unique_ptr。
三、shared_ptr 的删除器:类型擦除的控制块
std::shared_ptr<T>的模板参数只有T,删除器不是类型的一部分。它被拷贝进控制块(control block),与引用计数放在一起,通过类型擦除保存。因此:
// 适用于 C++17
#include <cstdio>
#include <memory>
struct FileCloser {
void operator()(std::FILE* fp) const noexcept {
if (fp != nullptr) {
std::fclose(fp);
}
}
};
int main() {
std::shared_ptr<std::FILE> fp(std::fopen("data.bin", "rb"), FileCloser{});
std::shared_ptr<std::FILE> copy = fp; // 共享同一个控制块
return fp ? 0 : 1;
}fp和copy的类型完全相同,都是std::shared_ptr<std::FILE>,但控制块里保存着FileCloser。当最后一个持有者销毁时,控制块调用一次删除器,然后释放控制块自身。删除器只会执行一次,这与引用计数归零的语义一致。
shared_ptr 删除器的写法
// 适用于 C++17
#include <cstdlib>
#include <memory>
struct FreeDeleter {
void operator()(int* p) const noexcept {
std::free(p);
}
};
int main() {
int* raw = static_cast<int*>(std::malloc(sizeof(int) * 8));
if (raw == nullptr) {
return 1;
}
std::shared_ptr<int> data(raw, FreeDeleter{});
std::shared_ptr<int> alias = data;
FreeDeleter* d = data.get_deleter<FreeDeleter>();
(void)d;
return 0;
}get_deleter<D>()返回控制块中删除器的指针;如果实际删除器不是D类型,返回空指针。这个接口常用于需要访问删除器状态的场景,例如从池中分配的对象要还回哪个池。
shared_ptr还有reset(p, d)重载,可以在重置指针的同时替换删除器:
// 适用于 C++17
std::shared_ptr<int> p;
p.reset(static_cast<int*>(std::malloc(sizeof(int))), FreeDeleter{});但更常见的做法是一开始就构造好,避免中途更换导致的所有权混乱。
从 unique_ptr 转换
std::shared_ptr可以从std::unique_ptr构造(移动语义),并且会接管它的删除器:
// 适用于 C++17
#include <cstdio>
#include <memory>
struct FileCloser {
void operator()(std::FILE* fp) const noexcept {
if (fp != nullptr) {
std::fclose(fp);
}
}
};
int main() {
std::unique_ptr<std::FILE, FileCloser> owner(std::fopen("data.bin", "rb"));
std::shared_ptr<std::FILE> shared(std::move(owner));
// owner 变为空,shared 使用 FileCloser 释放
return shared ? 0 : 1;
}这比“先release()再传裸指针给shared_ptr”安全得多:后者如果shared_ptr构造失败,指针就泄漏了;而且很容易忘记同时传删除器。
数组与 shared_ptr
C++17 起std::shared_ptr<T[]>是合法的,默认删除器使用delete[],并提供operator[]:
// 适用于 C++17
#include <memory>
int main() {
std::shared_ptr<int[]> values(new int[8]);
values[0] = 42;
return values[0] == 42 ? 0 : 1;
}如果写成std::shared_ptr<int> values(new int[8]);,默认删除器会对数组指针执行delete,属于 UB。C++20 起可以用std::make_shared<int[]>(8)创建数组,但在此之前只能直接构造。
shared_ptr 删除器的约束
- 删除器必须是可拷贝构造的,因为控制块需要保存它;
unique_ptr只要求可移动构造。 - 删除器在最后一个
shared_ptr释放时调用,可能发生在任意线程;删除器自身的状态如果需要并发访问,必须自行同步。 - 删除器不应抛出异常。控制块的销毁路径不允许异常逃逸。
std::make_shared不接受删除器;需要自定义删除器时只能直接构造shared_ptr。- 用同一个裸指针构造两个独立的
shared_ptr,会生成两个控制块,最终 double free。正确做法是先构造一个,再拷贝或赋值给其他shared_ptr。
四、两种智能指针的删除器对比
| 维度 | std::unique_ptr<T, D> | std::shared_ptr<T> |
|---|
| 删除器是否属于类型 | 是,D是模板参数 | 否,类型擦除后存入控制块 |
| 删除器存放位置 | 智能指针对象内部 | 堆上的控制块 |
| 对对象大小的影响 | 有状态删除器会增加大小 | 不改变shared_ptr本身大小 |
| 删除器拷贝要求 | 可移动构造 | 可拷贝构造 |
| 能否更换删除器 | 不能,类型固定 | 可通过reset(p, d)更换 |
| 工厂函数能否指定 | make_unique不能 | make_shared不能 |
| 取回删除器 | get_deleter() | get_deleter<D>() |
| 典型开销 | 零额外分配,可能内联 | 控制块一次分配,删除器间接调用 |
选择的原则很简单:独占所有权用unique_ptr,需要共享所有权才用shared_ptr。不要因为“删除器写起来方便”而给所有对象都上shared_ptr,引用计数和控制块的代价是实打实的。
完整示例:用删除器管理文件与缓冲区
// 适用于 C++17
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <iostream>
#include <memory>
struct FileCloser {
void operator()(std::FILE* fp) const noexcept {
if (fp != nullptr) {
std::fclose(fp);
}
}
};
struct FreeDeleter {
void operator()(unsigned char* p) const noexcept {
std::free(p);
}
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
using BufferPtr = std::unique_ptr<unsigned char[], FreeDeleter>;
int main() {
FilePtr input(std::fopen("input.bin", "rb"));
if (!input) {
std::cerr << "cannot open input.bin\n";
return 1;
}
constexpr std::size_t kBufferSize = 4096;
BufferPtr buffer(static_cast<unsigned char*>(std::malloc(kBufferSize)));
if (!buffer) {
std::cerr << "out of memory\n";
return 1;
}
std::size_t total = 0;
std::size_t n = 0;
while ((n = std::fread(buffer.get(), 1, kBufferSize, input.get())) > 0) {
total += n;
}
std::cout << "read " << total << " bytes\n";
return 0;
}FilePtr在离开作用域时调用FileCloser,BufferPtr调用FreeDeleter。两条资源路径都不依赖手写goto cleanup,即使中途return或抛异常也能正确释放。注意BufferPtr的元素类型是unsigned char,删除器接收的也是unsigned char*,与std::fread需要的void*兼容。
常见坑点
- ❌
std::unique_ptr<std::FILE> fp(std::fopen("a.bin", "rb"));,析构时对FILE*执行delete。
✅ 提供调用std::fclose的删除器,并用std::unique_ptr<std::FILE, FileCloser>。
- ❌
std::shared_ptr<int> p(static_cast<int*>(std::malloc(64)));,用delete释放malloc的内存。
✅ 构造时传入调用std::free的删除器,或改用new/std::vector。
- ❌ 用同一个裸指针构造两个
shared_ptr,产生两个控制块。
✅ 只构造一次,其余位置拷贝或赋值;需要从unique_ptr转移时用移动构造。
- ❌ 删除器里抛异常,或删除器声明为可能抛出。
✅ 删除器标记noexcept,内部消化错误;析构路径不允许异常逃逸。
- ❌
std::unique_ptr<int> p(new int[10]);,delete与new[]不匹配。
✅ 用std::unique_ptr<int[]>或std::vector<int>;shared_ptr同理用shared_ptr<int[]>。
- ❌ 以为
std::make_unique<T>(args...)或std::make_shared<T>(args...)能附加删除器。
✅ 需要自定义删除器时直接构造智能指针,把指针和删除器一起传入。
- ❌ 用有捕获的 lambda 做删除器,捕获了已经销毁的局部变量。
✅ 捕获按值,或保证捕获对象生命周期覆盖智能指针;无状态清理优先用函数对象。
- ❌ 给
unique_ptr传一个无状态删除器后,又试图在运行时替换成另一种删除逻辑。
✅ 删除器类型在编译期固定;需要不同释放方式就定义不同的unique_ptr类型。
总结
| 场景 | 推荐写法 |
|---|
new出来的单个对象 | std::unique_ptr<T>或std::make_unique<T> |
new[]出来的数组 | std::unique_ptr<T[]>、std::vector<T> |
fopen得到的文件 | unique_ptr<FILE, FileCloser>或shared_ptr<FILE>(fp, FileCloser{}) |
malloc得到的内存 | 带free删除器的unique_ptr/shared_ptr |
| 共享所有权 | std::shared_ptr<T>,删除器随构造传入 |
| 需要类型擦除的清理逻辑 | shared_ptr加自定义删除器 |
删除器的本质是把“如何释放”从类型系统的默认规则里拿出来,交给调用者显式表达。unique_ptr把删除器编进类型,换来零额外开销和编译期检查;shared_ptr把删除器藏进控制块,换来类型统一和运行期灵活。无论选哪种,都要保证分配方式与释放方式严格配对、删除器不抛异常、删除器捕获的对象活得足够久。这三条守住了,智能指针才真正比裸指针安全。