裸指针最大的麻烦其实不是「会忘写 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) |
交换两人的所有权 | 否 |
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 完全不用担心体积膨胀。
数组特化: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 为空? 是
延伸阅读
- std::unique_ptr --- cppreference ------ 全部接口与特化说明
- std::make_unique --- cppreference ------ 为什么优先用它不是裸
new - C++ Core Guidelines · R.30--R.34 ------ 所有权传递的规范
- Compiler Explorer ------ 对比
unique_ptr与裸指针生成的汇编,验证零开销
收个尾
接口其实就那几个,真要养成的是一个阅读习惯:看到 unique_ptr 就默认它独占,看到 get() 就默认这只是借用。release() 大多时候不该出现在业务代码里,它把 delete 的责任又塞回了人手。
零开销这个说法在默认用法下确实成立,前提是你没往里面塞函数指针当删除器。那一个机器字花得挺冤。