本节摘要:unique_ptr 是零额外开销的独占所有权智能指针:一个对象只有一个户主,禁止拷贝、移动即过户、析构自动销账。它用类型系统直接消灭第 1 章的别名类坏账(悬垂、双重释放),是现代 C++ 管理堆对象的默认选择。
本节把 2.1 的 RAII 守则应用到内存账本上,得到标准库给出的标准答案。承接关系很直接:FileGuard 管文件句柄要自己写,管堆对象的标准封装库已经写好——就是 unique_ptr。它解决的正是 1.4 悬垂案例里的死结:一份资源被多个名字记账。独占制度下别名根本造不出来,双重释放自然无从谈起。
用 unique_ptr 重写一遍第 1 章的手工 Ticket,对比两者的流水:
#include <cstdio> #include <memory> #include <utility> struct Ticket { int id; explicit Ticket(int i) : id(i) { std::printf(" 构造 票号 %d\n", id); } ~Ticket() { std::printf(" 析构 票号 %d\n", id); } }; void take_ownership(std::unique_ptr<Ticket> t) { // 按值收账:所有权进函数 std::printf(" 函数持有票号 %d,函数结束自动销账\n", t->id); } // t 析构 → Ticket 析构 → 内存归还 int main() { auto t = std::make_unique<Ticket>(101); // 开户首选:make_unique std::printf("开户完成,当前户主存在:%s\n", t ? "是" : "否"); // std::unique_ptr<Ticket> t2 = t; // 编译错误:独占账户禁止复制 auto t2 = std::move(t); // 过户:必须显式声明 std::printf("过户后原户主存在:%s\n", t ? "是" : "否"); take_ownership(std::move(t2)); // 再次过户给函数 std::printf("函数已返回,账目应已轧平\n"); return 0; }
输出确定:
构造 票号 101 开户完成,当前户主存在:是 过户后原户主存在:否 函数持有票号 101,函数结束自动销账 析构 票号 101 函数已返回,账目应已轧平
三个细节值得盯住。其一,被注释掉的那行拷贝是编译期报错——第 1 章所有靠代码评审才能拦下的坏账,这里由编译器直接拒收。其二,过户必须写 std::move,等于让过户动作在源代码里留下显式签名,事后审计时一眼能找到所有权交接点。其三,过户后原户主立刻变空(t 为 nullptr),旧凭证失效是语言保证,不再依赖程序员记得置空。

开户用 make_unique(C++14 起),别用裸 new。 除了少打字,关键是异常安全:f(std::make_unique<T>(), g()) 这种多参数调用里,各实参的求值顺序若夹杂裸 new,一旦穿插的函数抛异常,已开户的内存可能没主(C++17 收紧了求值顺序规则,但 make_unique 依然是表达"开户与入账一体"的最好写法)。它还让你少写一个类型名:auto p = std::make_unique<Widget>();。
函数接口怎么写,反映所有权设计:
#include <memory> struct Report { int pages; }; // 生产者:开户并交出所有权 std::unique_ptr<Report> generate_report(int pages) { return std::make_unique<Report>(Report{pages}); } // 消费者:只借用,不担责——裸指针或引用表达"我只是看看" void print_report(const Report& r) { std::printf("报告 %d 页\n", r.pages); } // 接收者:明确接手所有权 void archive_report(std::unique_ptr<Report> r) { std::printf("归档 %d 页报告\n", r->pages); } // r 离开作用域,报告销账 int main() { auto r = generate_report(12); print_report(*r); // 借用:解引用传引用 archive_report(std::move(r)); // 过户:显式交出 return 0; }
输出:
报告 12 页 归档 12 页报告
这套接口语言可以直接读:参数是 unique_ptr(按值)= 接手销账责任;参数是引用或裸指针 = 只借用;返回 unique_ptr = 开户并交出所有权。所有权在函数签名里自明,评审时不需要读实现就能审计责任划分。
容器装堆对象是高频场景。 std::vector<std::unique_ptr<Base>> 让多态对象们由容器统一销账,容器析构时逐个调用正确版本的虚析构(记得基类要有虚析构函数)。扩容时容器搬移的是 unique_ptr 本身(移动开销近似为零),被指对象原地不动。
非内存资源同样归它管。 unique_ptr 支持自定义销账器,把第 1 章 new/free 配对铁律之外的特殊账户也纳入制度:
#include <cstdio> #include <memory> struct FileCloser { void operator()(std::FILE* f) const noexcept { if (f) std::fclose(f); } }; using FilePtr = std::unique_ptr<std::FILE, FileCloser>; int main() { FilePtr f{std::fopen("audit.log", "w")}; // 开户即纳入 RAII if (f) { std::fputs("制度管理下的文件账户\n", f.get()); std::printf("文件账户将由析构自动关闭\n"); } return 0; // 无论走到哪,fclose 必达 }
输出:
文件账户将由析构自动关闭
背景:ProfileService 修完泄漏后做技术债清理,代码库里还有三十多处裸 new/delete,评审每次都要人工核对配对。
操作:按"所有权是否唯一"分类迁移。唯一所有的(工厂返回值、成员持有的实现对象、容器内多态对象)全部换成 unique_ptr;确实多处共有的转 2.3 的 shared_ptr;纯借用场景的参数改成引用。迁移用编译器当审计员——凡是删掉裸 delete 后编译仍通过的,说明所有权本就清晰。
结果:三十多处手工销账降到零;顺带消灭了两处静态分析工具都漏报的悬垂风险(一个被 reset 后仍被旧引用访问的路径,在改造为 unique_ptr 后根本无法编译通过)。
解读:unique_ptr 的零开销承诺在这里兑现了——它就是一个带析构的裸指针包装,sizeof 与裸指针相同(主流实现下),移动、解引用无任何额外代价。用编译期的类型约束换掉运行期的事故概率,这笔账怎么算都划算。
变式:数组形态 unique_ptr<T[]> 自带正确的 delete[] 销账,接管 C 遗留的缓冲区很顺手;PIMPL 惯用法(实现类藏进 cpp 文件)用 unique_ptr 作成员,是独占制度在编译防火墙上的经典应用。注意成员为 unique_ptr 的类,其析构函数需要在实现完整处实例化,头文件里只声明析构、实现放源文件,这是 PIMPL 的标准写法。
⚠️ 常见坑:get() 返回的裸指针长期保存。独占制度只约束 unique_ptr 自己,get() 出来的指针是借用凭证,原 unique_ptr 一旦销账或过户,这些凭证全部悬垂。借用凭证只在调用现场使用,绝不落库、不存成员——这条守则违反得越多,制度漏得越快。
下一节处理确实需要多个户主的账:shared_ptr 的引用计数怎么记账、控制块开销花在哪、什么场景才值得从独占升级为共享——以及为什么"默认独占、按需共享"是审计制度的排序。