☰
自定义删除器:让智能指针管住 FILE*、socket 与数据库连接
2026/10/11 1:40:37 网站建设 项目流程

std::unique_ptr的默认删除器是delete,所以它能漂亮地管住一块new出来的内存;可现实里大量资源根本不是new出来的:std::fopen返回FILE*、socket()返回文件描述符、数据库连接是句柄:它们的释放函数是fclose/close/ 各自的 disconnect。直接塞进裸unique_ptr用默认删除器去delete,不是编译错,而是运行时把错误的释放函数套在错误的资源上。这篇讲怎么用自定义删除器(custom deleter)把任何资源都装进 RAII,并实测它对sizeof的影响。

1. 引子:默认删除器管不了的资源

看这段意图把FILE*交给unique_ptr的代码:

// 片段(无 main,展示问题)#include<memory>std::unique_ptr<FILE>f(std::fopen("a.txt","r"));// 反例,不要这么写// 析构时会调用 delete 去释放一个 FILE* —— 类型不匹配,行为未定义

unique_ptr<T>默认用std::default_delete<T>,里面是delete ptr。把FILE*交给它,析构时拿delete去释放一个FILE*,类型与释放方式全错。正确做法是给第二个模板参数传一个自定义删除器:一个「知道怎么正确释放这个资源」的可调用对象。

官方文档:std::unique_ptr 删除器 — cppreference · default_delete — cppreference

2. 三种删除器写法

删除器的核心要求只有一个:签名像void operator()(T*)的可调用对象。常见三种写法:

写法类型是否携带状态适合场景
函数指针void(*)(T*)否(但类型是「值」)释放逻辑就是现成函数
无状态 lambda / 空仿函数闭包类型 / 空 struct否大多数场景,零开销
std::function<void(T*)>类型擦除包装可携带状态需要运行时换删除器,有开销

需要注意「删除器签名」里传进来的是指针:void operator()(T*)中的T*就是unique_ptr的第一个模板参数。对FILE*而言T是std::FILE,删除器收的是std::FILE*;如果你的资源是个句柄而非指针(比如 POSIX 的int文件描述符、Windows 的HANDLE),就先想清楚「用什么类型当unique_ptr的第一个参数」:常见做法是包一层薄薄的句柄类,或者干脆用unique_ptr<int, Closer>这种「拿指向句柄的指针当句柄」的技巧,但后者可读性差,不推荐。

下面是 C++ 里最常见的几类「非new资源」以及它们真正的释放函数,先记住这张对照表,选删除器时就不会拿错:

资源获取函数释放函数unique_ptr的T写什么
FILE*std::fopenstd::fclosestd::FILE
POSIX 文件描述符openclose建议自己包一个Fd句柄类
socketsocketclose/closesocket同上,句柄类更清晰
目录流opendirclosedirDIR
动态库句柄dlopendlclosevoid(unique_ptr<void, D>)
sqlite3*sqlite3_opensqlite3_closesqlite3
自定义 C API 句柄xxx_createxxx_destroy该句柄的结构体名

规律很简单:凡是「成对出现」的 create/destroy、open/close、alloc/free 函数,就天然适合用自定义删除器包成 RAII。C++ Core Guidelines 的 C.149(「用unique_ptr/shared_ptr代替裸的new/delete」)讲的是同一个思想:把「谁负责释放」写进类型里,而不是写在文档和注释里靠人记。

下面先用无状态 lambda把FILE*完整包成一个 RAII 对象,真跑一遍写文件再读回来:

// deleter_file.cpp — 编译: g++ -std=c++17 -Wall -O2 deleter_file.cpp -o df#include<cstdio>#include<memory>intmain(){constchar*path="miao_demo_file.txt";// 无状态 lambda 作为删除器:离开作用域自动 fcloseautocloser=[](std::FILE*f)noexcept{if(f)std::fclose(f);std::printf("[deleter] fclose 已调用\n");};{// 写std::unique_ptr<std::FILE,decltype(closer)>file(std::fopen(path,"w"),closer);if(!file){std::printf("打开文件失败\n");return1;}std::fprintf(file.get(),"hello miao\n");}// 作用域结束 -> lambda 删除器自动 fclose// 读,验证文件被正确写入且被正确关闭std::unique_ptr<std::FILE,decltype(closer)>in(std::fopen(path,"r"),closer);charbuf[64]={0};while(std::fgets(buf,sizeof(buf),in.get())){std::printf("读到: %s",buf);}std::printf("unique_ptr<FILE, lambda> 的 sizeof = %zu\n",sizeof(std::unique_ptr<std::FILE,decltype(closer)>));std::printf("裸指针 FILE* 的 sizeof = %zu\n",sizeof(std::FILE*));std::remove(path);// 用完删掉临时文件std::printf("临时文件已删除\n");}
[deleter] fclose 已调用 读到: hello miao unique_ptr<FILE, lambda> 的 sizeof = 8 裸指针 FILE* 的 sizeof = 8 临时文件已删除 [deleter] fclose 已调用

fopen句柄全程没有手写fclose:第一个unique_ptr离开内层作用域时删除器自动收尾(第一个[deleter]打印),第二个读完也自动收尾(第二个[deleter]打印)。关键看最后两行:无状态 lambda 删除器下,unique_ptr<FILE>的sizeof仍是 8,和裸指针一样大。

3. 关键性能点:删除器类型决定 unique_ptr 的大小

unique_ptr<T, D>对象内部嵌了一个D类型的删除器成员。不同D占的空间天差地别,下面实测三种 +std::function:

// deleter_sizeof.cpp — 编译: g++ -std=c++17 -Wall -O2 deleter_sizeof.cpp -o ds#include<cstdio>#include<memory>#include<functional>structStatelessDeleter{// 空仿函数(无状态)voidoperator()(int*p)constnoexcept{deletep;}};voidfn_deleter(int*p)noexcept{deletep;}// 普通函数intmain(){usingP0=std::unique_ptr<int>;// 默认删除器(无状态)usingP1=std::unique_ptr<int,StatelessDeleter>;// 无状态自定义删除器usingP2=std::unique_ptr<int,void(*)(int*)>;// 函数指针删除器(携带值)usingP3=std::unique_ptr<int,std::function<void(int*)>>;// std::function 删除器std::printf("默认删除器 sizeof = %zu\n",sizeof(P0));std::printf("无状态自定义删除器 sizeof = %zu\n",sizeof(P1));std::printf("函数指针删除器 sizeof = %zu\n",sizeof(P2));std::printf("std::function 删除器 sizeof = %zu\n",sizeof(P3));std::printf("裸指针 int* sizeof = %zu\n",sizeof(int*));}
默认删除器 sizeof = 8 无状态自定义删除器 sizeof = 8 函数指针删除器 sizeof = 16 std::function 删除器 sizeof = 40 裸指针 int* sizeof = 8

为什么P1(无状态)还是 8,而P2(函数指针)变成 16?这就是空基类优化(EBO,Empty Base Optimization):当删除器D是无状态类型(默认default_delete、空仿函数、无捕获 lambda)时,它不占任何数据,编译器把它「压没」,unique_ptr的大小就等于一个裸指针。而函数指针本身是个有值的对象,必须真真实实存一个机器字,于是sizeof变成「被管理指针 + 函数指针」= 16。更糟的是std::function:它是类型擦除的大对象(本机 40 字节),还带来一次间接调用,除非必须运行时更换删除器,否则不要用来当删除器类型。

对比表(64 位平台,1 机器字 = 8 字节):

形式删除器存储sizeof相对裸指针
unique_ptr<int>默认删除器无状态,EBO 吃掉的8相等,零开销
+无状态自定义删除器EBO 吃掉的8相等,零开销
+函数指针删除器内嵌一个指针成员16多 1 个机器字
+std::function删除器类型擦除大对象40多 4 个机器字 + 间接调用
裸指针int*—8基准

官方文档:std::unique_ptr 布局与sizeof— cppreference · Compiler Explorer 可对比默认删除器与函数指针删除器生成的汇编,验证 EBO 是否真的把删除器优化没了。

内存布局画成图,差异:

unique_ptr 内部布局(64 位,被管理指针 p 在前) 无状态删除器(默认 / lambda / 空仿函数) 函数指针删除器 ┌──────────────┐ ┌──────────────┬──────────────┐ │ T* p │ 删除器被 EBO 吃掉了 │ T* p │ void(*)(T*) d │ │ (8 字节) │ → 整个对象只有 8 字节 │ (8 字节) │ (8 字节) │ └──────────────┘ └──────────────┴──────────────┘ sizeof = 8 sizeof = 16

4. shared_ptr 的删除器:存进控制块,不影响大小

shared_ptr的删除器是在构造时传入的,且类型被擦除后存进共享的控制块(control block),所以删除器类型根本不出现在shared_ptr<T>的类型里,也就不影响它的大小:

// deleter_shared.cpp — 编译: g++ -std=c++17 -Wall -O2 deleter_shared.cpp -o dsh#include<cstdio>#include<memory>intmain(){// 删除器在构造时传入;类型被擦除进控制块,shared_ptr<int> 的类型里看不到它autosp=std::shared_ptr<int>(newint(7),[](int*p){std::printf("[shared deleter] 释放 %d\n",*p);deletep;});std::printf("sp 拥有 %d, use_count=%ld\n",*sp,sp.use_count());autosp2=sp;// 拷贝:引用计数 +1,删除器随控制块一起共享std::printf("拷贝后 use_count=%ld\n",sp2.use_count());std::printf("sizeof(shared_ptr<int>) = %zu\n",sizeof(std::shared_ptr<int>));std::printf("sizeof(shared_ptr<double>) = %zu\n",sizeof(std::shared_ptr<double>));std::printf("裸指针 int* = %zu\n",sizeof(int*));}
sp 拥有 7, use_count=1 拷贝后 use_count=2 sizeof(shared_ptr<int>) = 16 sizeof(shared_ptr<double>) = 16 裸指针 int* = 8 [shared deleter] 释放 7

注意两个要点:①shared_ptr<T>永远是「两个指针」的大小(被管理指针 + 控制块指针 = 16),与删除器是什么毫无关系:因为删除器在控制块里;② 删除器只在最后一个引用释放时才真正执行,所以[shared deleter]在最末尾打印,且只打印一次。

shared_ptr 内部布局 shared_ptr 对象(线程 A) 控制块(所有 shared_ptr 共享) ┌──────────┬──────────┐ ┌────────────┬────────────┬──────────┐ │ T* p │ ctrl* │ ----> │ 引用计数 │ 弱引用计数 │ 删除器 D │ └──────────┴──────────┘ └────────────┴────────────┴──────────┘ shared_ptr 对象(线程 B) │ ▲ 删除器随控制块共享,类型被擦除 ┌──────────┬──────────┘ │ 不影响 shared_ptr 自身 sizeof │ T* p │ ctrl* ────────────────┘ └──────────┴──────────┘

官方文档:std::shared_ptr 控制块与删除器 — cppreference · C++ Core Guidelines · R.20 用 unique_ptr 表达独占、shared_ptr 表达共享所有权。

5. 完整示例:统一封装任意资源

把上面要点串起来:一个函数创建FILE*、用无状态 lambda 删除器交出去、调用方完全不用管关闭:

// deleter_full.cpp — 编译: g++ -std=c++17 -Wall -O2 deleter_full.cpp -o dfl#include<cstdio>#include<memory>// 工厂:返回已绑定 fclose 删除器的 unique_ptr,调用方零心智负担autoopen_file(constchar*path){autocloser=[](std::FILE*f)noexcept{if(f)std::fclose(f);};returnstd::unique_ptr<std::FILE,decltype(closer)>(std::fopen(path,"w"),closer);}intmain(){autof=open_file("miao_full_demo.txt");if(f)std::fprintf(f.get(),"managed by custom deleter\n");std::printf("文件句柄类型大小 = %zu 字节(与裸指针相同:零开销)\n",sizeof(f));std::remove("miao_full_demo.txt");}
文件句柄类型大小 = 8 字节(与裸指针相同:零开销)

6. 延伸阅读

  • std::unique_ptr — cppreference —— 删除器模板参数与特化全貌
  • std::shared_ptr — cppreference —— 控制块、删除器与use_count细节
  • C++ Core Guidelines · R.20–R.22 —— 何时用 unique_ptr / shared_ptr
  • Compiler Explorer —— 对比 EBO 前后unique_ptr生成的汇编

本知识库内的相关篇目:

  • 《unique_ptr 完全指南:独占所有权与零开销》 —— 讲透 std::unique_ptr 的独占所有权语义、make_unique/get/release/
  • 《make_unique / make_shared vs 裸 new:三个理由与反直觉权衡》 —— 讲透为什么优先 std::make_unique / std::make_shared 而非裸 new
  • 《shared_ptr 完全指南:引用计数、控制块与开销》 —— 讲透 std::shared_ptr 的共享所有权语义、控制块(control block)里究竟存了什

7. 一句话总结

非new资源用自定义删除器装进 RAII:删除器就是个void operator()(T*)可调用对象;无状态 lambda / 空仿函数被 EBO 吃掉,unique_ptr仍是 1 个指针(零开销),函数指针删除器多 1 个机器字,std::function删除器更大且多一次间接调用、不推荐;shared_ptr的删除器在构造时传入、存进控制块、不影响其固定 16 字节大小。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询