unique_ptr 完全指南:独占所有权与零开销

裸指针最大的麻烦其实不是「会忘写 delete」,而是所有权含糊不清:这段内存归谁,谁来释放,读代码的人只能猜。std::unique_ptr 把答案钉死在类型里,同一时刻只有一个 unique_ptr 拥有这块内存 ,要转移就必须显式写 std::move。代价几乎为零,既不比裸指针多占内存,也不比它多花时间。下面把常用接口和几个容易踩的点一起讲清。

指针身上的「所有权标签」

看一段老式写法:

cpp 复制代码
void process() {
    int* p = new int(42);
    // ... 中途可能 return、可能抛异常 ...
    delete p;   // 任何一条提前退出的路径忘了删,就是泄漏
}

p 归谁、什么时候释放,全靠人脑盯着。换成 std::unique_ptr<int>,析构时自动释放,提前 return 也好、抛异常也好,从哪条路径退出都安全。这就是 RAII(资源获取即初始化,Resource Acquisition Is Initialization)。

官方文档:std::unique_ptr --- cppreference · RAII --- cppreference

独占所有权:不可拷贝,只能移动

unique_ptr 删除了拷贝构造和拷贝赋值,只保留移动 。试图拷贝会直接编译失败,这正是它「独占」的体现。所有权转移必须用 std::move 显式表达。

cpp 复制代码
// 片段(无 main,展示独占语义)
#include <memory>
#include <utility>

std::unique_ptr<int> a = std::make_unique<int>(1);
// std::unique_ptr<int> b = a;        // 编译错误:拷贝被删除
std::unique_ptr<int> b = std::move(a); // OK:所有权从 a 转移到 b,a 变空

核心接口:make_unique / get / release / reset / swap

std::make_unique(C++14 引入)是现在唯一推荐的创建方式。它把 new 包在内部,既避免裸 new,也消掉了「已经 new 出来、却还没进智能指针」这个危险窗口。其余几个接口分工如下:

接口 作用 是否释放原对象
make_unique<T>(args) 创建并拥有新对象 ---
get() 取裸指针(不转移所有权) 否
release() 放弃所有权,不释放,返回裸指针 否(你要自己管)
reset() 释放原对象,可接手新指针 是
swap(other) 交换两人的所有权 否

官方文档:std::make_unique --- cppreference

release() 与 reset() 的区别:一个天一个地

这两个名字最容易混,语义却正相反。release() 是「我不要了,而且不销毁,把指针还给你」;reset() 是「销毁当前对象,顺便可以接手一个新的」。跑一遍就清楚了:

cpp 复制代码
// up_release_reset.cpp --- 编译: g++ -std=c++17 -Wall -O2 up_release_reset.cpp -o ur
#include <cstdio>
#include <memory>

int main() {
    std::unique_ptr<int> p = std::make_unique<int>(42);
    std::printf("拥有对象, *p=%d\n", *p);

    int* raw = p.release();            // 放弃所有权但不释放,p 变空
    std::printf("release 后: p 为空? %s, raw=%d\n",
                p ? "否" : "是", *raw);
    delete raw;                        // 现在轮到你负责释放

    std::unique_ptr<int> q = std::make_unique<int>(7);
    q.reset();                         // 释放对象,q 变空
    std::printf("reset 后: q 为空? %s\n", q ? "否" : "是");
}
text 复制代码
拥有对象, *p=42
release 后: p 为空? 是, raw=42
reset 后: q 为空? 是

记住一句话就行:release() 交出但不销毁(有泄漏风险),reset() 直接销毁 。日常绝大多数场合该用 reset(),或者干脆什么都不写、交给析构函数,别碰 release()。除非你要把裸指针交给一个不支持智能指针的旧接口,那时候没得选。

自定义删除器对 sizeof 的影响(实测)

unique_ptr 的第二个模板参数用来定制删除器。这里有个值得记住的零开销细节:无状态删除器(empty deleter)会被空基类优化(EBO,Empty Base Optimization)吃掉,一个字节也不多占。换成函数指针做删除器就不一样了,它必须在对象里存一个指针,sizeof 因此多出一个机器字。

cpp 复制代码
// up_sizeof.cpp --- 编译: g++ -std=c++17 -Wall -O2 up_sizeof.cpp -o us
#include <cstdio>
#include <memory>

struct StatelessDeleter {
    void operator()(int* p) const noexcept { delete p; }   // 无状态(空类型)
};

void fn_deleter(int* p) noexcept { delete p; }             // 函数指针删除器

int main() {
    using P1 = std::unique_ptr<int>;                        // 默认删除器(无状态)
    using P2 = std::unique_ptr<int, StatelessDeleter>;      // 自定义无状态删除器
    using P3 = std::unique_ptr<int, void(*)(int*)>;         // 函数指针删除器(携带值)

    std::printf("unique_ptr<int>(默认删除器)     sizeof = %zu\n", sizeof(P1));
    std::printf("+无状态自定义删除器            sizeof = %zu\n", sizeof(P2));
    std::printf("+函数指针删除器(携带值)        sizeof = %zu\n", sizeof(P3));
    std::printf("裸指针 int*                     sizeof = %zu\n", sizeof(int*));

    // 必须真的给删除器赋一个函数指针,它才会作为成员被存储
    std::unique_ptr<int, void(*)(int*)> q(new int(1), fn_deleter);
    std::printf("q 拥有对象, *q=%d\n", *q);
}
text 复制代码
unique_ptr<int>(默认删除器)     sizeof = 8
+无状态自定义删除器            sizeof = 8
+函数指针删除器(携带值)        sizeof = 16
裸指针 int*                     sizeof = 8
q 拥有对象, *q=1

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

形式 删除器存储方式 sizeof 相对裸指针
unique_ptr<int>(默认) 无状态删除器,EBO 优化掉 8 相等,零开销
+ 无状态自定义删除器 EBO 优化掉 8 相等,零开销
+ 函数指针删除器 对象内嵌一个指针成员 16 多 1 个机器字
裸指针 int* --- 8 基准

原因在对象布局上:unique_ptr<T, D> 内部嵌了一个 D 类型的删除器成员。当 D 无状态时(默认的 default_delete,或者上面的 StatelessDeleter),编译器用 EBO 把它压没了,大小自然等于一个裸指针。函数指针不是这么回事,它本身就是个有值的东西,必须实实在在占一个机器字,于是 sizeof 变成「指针 + 函数指针」,也就是 16。

结论也就清楚了:默认删除器和无状态删除器都是零开销 ,这和标准库的承诺一致;只有把函数指针当删除器类型时才多存一个机器字。日常写 make_unique 完全不用担心体积膨胀。

官方文档:std::unique_ptr 析构与删除器 --- cppreference

数组特化:unique_ptr<T\[\]>

管理动态数组用 std::unique_ptr<T[]>(不是裸 new[])。make_unique<int[]>(n) 从 C++14 起可用,会调用 new T[n] 并在析构时 delete[]。

cpp 复制代码
// up_array.cpp --- 编译: g++ -std=c++17 -Wall -O2 up_array.cpp -o ua
#include <cstdio>
#include <memory>

int main() {
    auto arr = std::make_unique<int[]>(4);   // C++14 起支持
    for (int i = 0; i < 4; ++i) arr[i] = i * 10;
    for (int i = 0; i < 4; ++i) std::printf("arr[%d]=%d\n", i, arr[i]);
}
text 复制代码
arr[0]=0
arr[1]=10
arr[2]=20
arr[3]=30

注意 unique_ptr<T[]> 重载了 operator[],但不支持 * 和 ->;普通 unique_ptr<T> 则相反。别混用。

作为函数参数/返回值:用类型表达所有权

unique_ptr 出现在参数或返回值的位置时,是在用类型本身告诉调用方所有权怎么流转:

  • 按值返回 std::unique_ptr:意思是「我把所有权交出去了」;
  • 按值接收 std::unique_ptr 参数:意思是「这个函数接管所有权,用完即释放」;
  • 实参那边必须写 std::move,又一次靠类型系统逼你把转移写明白。
cpp 复制代码
// up_ownership.cpp --- 编译: g++ -std=c++17 -Wall -O2 up_ownership.cpp -o uo
#include <cstdio>
#include <memory>
#include <utility>

std::unique_ptr<int> make() {
    return std::make_unique<int>(99);   // 返回即移交所有权
}

void take(std::unique_ptr<int> p) {     // 参数接管所有权
    std::printf("take 拿到 *p=%d\n", *p);
}                                       // 离开作用域,对象被释放

int main() {
    std::unique_ptr<int> a = make();
    std::printf("make 返回后 *a=%d\n", *a);
    take(std::move(a));                  // 必须显式 move,所有权转移
    std::printf("take 之后 a 为空? %s\n", a ? "否" : "是");
}
text 复制代码
make 返回后 *a=99
take 拿到 *p=99
take 之后 a 为空? 是

所有权流转画成图是一条单向链,同一份资源在任何时刻只有一个人拿着:

text 复制代码
所有权流转(同一时刻只有一个持有者)

  make() ──返回──► a ──std::move──► take(p) ──离开作用域──► 释放
   创建             持有               接管                  delete

  a 转移后变为空(nullptr),不能再解引用

如果只想「借用」而不转移所有权,参数就传 const std::unique_ptr<T>& 或裸指针 T*(明确表达「我不拥有」),这正是 C++ Core Guidelines 推荐的「裸指针只表示不拥有」。

官方文档:C++ Core Guidelines · R.32/R.33/R.34 ------ 借用用引用/裸指针,转移用 unique_ptr

把前面要点串起来

cpp 复制代码
// up_full.cpp --- 编译: g++ -std=c++17 -Wall -O2 up_full.cpp -o uf
#include <cstdio>
#include <memory>
#include <utility>

std::unique_ptr<int> create() {
    return std::make_unique<int>(5);
}

int main() {
    auto p = create();
    std::printf("p 拥有 %d\n", *p);

    int* borrowed = p.get();          // 借用,不转移所有权
    std::printf("借用读到 %d\n", *borrowed);

    auto q = std::move(p);            // 转移所有权
    std::printf("转移后 p 为空? %s, q 拥有 %d\n", p ? "否" : "是", *q);

    q.reset();                        // 释放
    std::printf("reset 后 q 为空? %s\n", q ? "否" : "是");
}
text 复制代码
p 拥有 5
借用读到 5
转移后 p 为空? 是, q 拥有 5
reset 后 q 为空? 是

延伸阅读

收个尾

接口其实就那几个,真要养成的是一个阅读习惯:看到 unique_ptr 就默认它独占,看到 get() 就默认这只是借用。release() 大多时候不该出现在业务代码里,它把 delete 的责任又塞回了人手。

零开销这个说法在默认用法下确实成立,前提是你没往里面塞函数指针当删除器。那一个机器字花得挺冤。

相关推荐
笑鸿的学习笔记1 小时前
C++笔记之大块顺序写
java·c++·笔记
无名猿2 小时前
对象构造与析构顺序全解:声明顺序、继承链与逆序析构
c++·内存管理·调试技巧·现代c++
殷色玫瑰2 小时前
C/C++ 内存管理详解:从内存分布到 new/delete 底层原理
java·linux·c语言·c++
C++ 老炮儿的技术栈2 小时前
不一样的数值交换
数据结构·c++·人工智能·算法·c·csdn开发云
C++ 老炮儿的技术栈3 小时前
main函数之后的调用
c语言·c++·人工智能·qt·mfc·c
知识分享小能手3 小时前
C++ 学习教程,从入门到精通,C++ 友元、异常和其他特性 — 详细知识点(15)
开发语言·c++·学习
ebiobiz3 小时前
极海 APM32 使用 Nimmake 编译指南
c++·python·单片机·嵌入式硬件·mcu
朝朝辞暮i3 小时前
C++ 第 26 课:对象、指针与 ->
java·开发语言·c++
殷色玫瑰5 小时前
C++入门基础复习:从命名空间到引用与nullptr,一篇重新捡回C++基础
java·开发语言·c++