一、unique_ptr到底解决什么问题
普通动态内存通常这样使用:
int *p = new int(10);
使用结束以后:
delete p;
如果忘记:
delete p;
就可能造成:
内存泄漏
例如:
void func() {
int *p = new int(10);
// 中间直接return
return;
delete p;
}
这里:
delete p;
根本不会执行。
如果中间发生异常,同样可能造成资源没有释放。
所以 C++ 中非常重要的一个思想就是:
RAII
也就是:
资源获取即初始化。
简单来说:
对象创建
↓
获取资源
对象销毁
↓
自动释放资源
unique_ptr 就是这种思想的典型应用。
例如:
std::unique_ptr<int> p(new int(10));
当:
p
离开作用域时,不需要手动:
delete
它的析构函数会自动释放:
new int(10)
得到的内存。
所以:
unique_ptr
最核心的特点就是:
独占所有权
也就是说:
一块资源
↓
只能有一个unique_ptr负责管理
不能同时存在:
p1管理资源A
p2也管理资源A
否则两个对象析构时都执行:
delete
就会造成重复释放。
所以我们手写 unique_ptr 时,第一个核心目标就是:
保证一块资源只能由一个对象管理。
二、先设计一个最简单的MyUniquePtr
先实现最基础版本:
template <typename T>
class MyUniquePtr {
private:
T *ptr_;
public:
explicit MyUniquePtr(T *ptr = nullptr) : ptr_(ptr) {}
~MyUniquePtr() {
delete ptr_;
}
};
这里:
T *ptr_;
保存真正需要管理的对象地址。
例如:
MyUniquePtr<int> p(new int(10));
实际关系:
p
│
└── ptr_
↓
10
当:
p
离开作用域时:
~MyUniquePtr()
自动执行:
delete ptr_;
所以:
{
MyUniquePtr<int> p(new int(10));
}
运行流程就是:
new int(10)
↓
ptr_保存地址
↓
使用资源
↓
p离开作用域
↓
调用析构函数
↓
delete ptr_
这样就不需要用户手动:
delete
了。
这里构造函数加:
explicit
主要是防止一些不必要的隐式类型转换。
例如:
MyUniquePtr<int> p = new int(10);
我们更希望明确写成:
MyUniquePtr<int> p(new int(10));
让代码含义更加清楚。
三、为什么unique_ptr不能拷贝
假设允许下面这种代码:
MyUniquePtr<int> p1(new int(10));
MyUniquePtr<int> p2 = p1;
如果使用编译器默认生成的拷贝构造,那么本质上只是:
复制指针地址
于是:
p1.ptr_
↓
10
↑
p2.ptr_
两个智能指针同时指向:
同一块内存
当 p1 析构:
delete ptr_;
资源被释放一次。
接着 p2 析构:
delete ptr_;
同一块内存又被释放一次。
于是产生:
double free
重复释放
所以 unique_ptr 必须禁止拷贝。
可以直接写:
MyUniquePtr(const MyUniquePtr &) = delete;
拷贝赋值同样禁止:
MyUniquePtr &operator=(const MyUniquePtr &) = delete;
这样:
MyUniquePtr<int> p1(new int(10));
MyUniquePtr<int> p2 = p1;
编译器会直接报错。
这也是:
unique
这个名字的真正含义:
只有一个所有者
所以手写 unique_ptr 时一定要记住:
MyUniquePtr(const MyUniquePtr &) = delete;
MyUniquePtr &operator=(const MyUniquePtr &) = delete;
这两句非常关键。
四、不能拷贝,那怎么转移所有权
不能拷贝,并不代表:
资源永远不能交给别人
例如:
MyUniquePtr<int> p1(new int(10));
现在希望让:
p2
接管这块资源。
正确思路不是:
复制所有权
而是:
转移所有权
这就是:
移动语义
移动构造可以写成:
MyUniquePtr(MyUniquePtr &&other) noexcept : ptr_(other.ptr_) {
other.ptr_ = nullptr;
}
例如:
MyUniquePtr<int> p1(new int(10));
MyUniquePtr<int> p2(std::move(p1));
移动之前:
p1
↓
10
p2
↓
nullptr
执行:
ptr_ = other.ptr_;
以后:
p1
↓
10
p2
↓
10
但这样还是两个指针指向同一块内存。
所以必须继续:
other.ptr_ = nullptr;
最终:
p1
↓
nullptr
p2
↓
10
这样才是真正的:
所有权转移
也就是:
资源没复制
只是管理者换了
移动赋值同样需要处理。
可以写成:
MyUniquePtr &operator=(MyUniquePtr &&other) noexcept {
if (this != &other) {
delete ptr_;
ptr_ = other.ptr_;
other.ptr_ = nullptr;
}
return *this;
}
为什么移动赋值一开始要:
delete ptr_;
因为当前对象:
this
可能已经管理了一块资源。
例如:
MyUniquePtr<int> p1(new int(10));
MyUniquePtr<int> p2(new int(20));
现在:
p2 = std::move(p1);
原来:
p1 → 10
p2 → 20
如果直接:
p2.ptr_ = p1.ptr_;
那么原来:
20
这块内存地址就丢失了,造成:
内存泄漏
所以必须先:
delete ptr_;
释放原来管理的资源。
最终:
p1 → nullptr
p2 → 10
所以移动赋值完整流程是:
释放自己原来的资源
↓
接管other的资源
↓
other置空
五、operator*和operator->为什么必须实现
智能指针之所以叫:
智能"指针"
是因为使用起来应该尽量像普通指针。
例如普通指针:
int *p = new int(10);
读取值:
std::cout << *p << std::endl;
如果是类对象:
Student *p = new Student;
调用成员函数:
p->show();
所以我们希望:
MyUniquePtr<int> p(new int(10));
也能这样使用:
std::cout << *p << std::endl;
于是需要重载:
T &operator*() {
return *ptr_;
}
这里返回:
T &
因为:
*ptr_
得到的就是:
ptr_指向的那个对象本身
例如:
MyUniquePtr<int> p(new int(10));
调用:
*p
实际上编译器会理解成:
p.operator*()
然后:
return *ptr_;
得到:
10
还可以:
*p = 20;
所以这里返回的是:
T &
引用,而不是简单返回一个副本。
如果管理的是对象:
class Student {
public:
void show() {
std::cout << "hello" << std::endl;
}
};
创建:
MyUniquePtr<Student> p(new Student);
希望可以:
p->show();
那么需要:
T *operator->() {
return ptr_;
}
为什么返回:
T *
因为:
->
后面还需要继续访问:
T对象的成员
例如:
p->show();
本质上会经过:
p.operator->()
返回:
Student *
然后再调用:
Student * -> show()
所以:
operator*()
让智能指针支持:
*p
而:
operator->()
让智能指针支持:
p->member
它们并不是:
把对象变成指针
而是让:
MyUniquePtr这个类对象
表现得像:
普通指针
完整版本可以写成:
#include <iostream>
#include <utility>
template <typename T>
class MyUniquePtr {
private:
T *ptr_;
public:
explicit MyUniquePtr(T *ptr = nullptr) : ptr_(ptr) {}
~MyUniquePtr() {
delete ptr_;
}
// 禁止拷贝构造
MyUniquePtr(const MyUniquePtr &) = delete;
// 禁止拷贝赋值
MyUniquePtr &operator=(const MyUniquePtr &) = delete;
// 移动构造
MyUniquePtr(MyUniquePtr &&other) noexcept : ptr_(other.ptr_) {
other.ptr_ = nullptr;
}
// 移动赋值
MyUniquePtr &operator=(MyUniquePtr &&other) noexcept {
if (this != &other) {
delete ptr_;
ptr_ = other.ptr_;
other.ptr_ = nullptr;
}
return *this;
}
// 支持 *p
T &operator*() {
return *ptr_;
}
// 支持 p->member
T *operator->() {
return ptr_;
}
// 获取原始指针
T *get() const {
return ptr_;
}
};
class Student {
public:
void show() {
std::cout << "Student::show()" << std::endl;
}
};
int main() {
MyUniquePtr<int> p1(new int(10));
std::cout << *p1 << std::endl;
// 转移所有权
MyUniquePtr<int> p2(std::move(p1));
std::cout << *p2 << std::endl;
MyUniquePtr<Student> student(new Student);
student->show();
return 0;
}
整个流程可以总结成:
new T
↓
MyUniquePtr保存地址
↓
只能有一个所有者
↓
禁止拷贝
↓
允许move转移所有权
↓
operator* / operator->
让它像普通指针一样使用
↓
对象析构
↓
自动delete
如果从面试角度总结 unique_ptr,可以这样回答:
unique_ptr表示独占所有权,同一块资源同一时间只能由一个unique_ptr管理。因此它禁止拷贝构造和拷贝赋值,但支持移动构造和移动赋值,通过移动语义转移资源所有权。内部本质上保存一个裸指针,在析构时自动delete,实现 RAII。同时通过重载operator*和operator->,让智能指针可以像普通指针一样访问对象。
面试官还可能继续追问几个问题。
第一,为什么移动以后必须:
other.ptr_ = nullptr;
因为如果不置空,两个智能指针仍然会管理同一块内存,析构时产生重复释放。
第二,为什么移动函数通常写:
noexcept
因为移动操作本质上只是:
交换或者转移指针
通常不会抛出异常。
而且标准容器在扩容时,如果移动构造是 noexcept,通常更愿意使用移动而不是拷贝。
第三,unique_ptr 和裸指针最大的区别是什么?
裸指针:
只保存地址
资源释放需要自己负责
unique_ptr:
保存地址
+
拥有资源所有权
+
析构自动释放
第四,为什么不能写:
MyUniquePtr<int> p2 = p1;
因为这会复制指针地址,让两个对象同时拥有同一块资源,不符合独占所有权。
而应该写:
MyUniquePtr<int> p2 = std::move(p1);
表示:
把p1的所有权交给p2
理解了 unique_ptr 以后,后面再学习 shared_ptr 就很自然了。
因为 unique_ptr 的规则是:
一个资源
只能有一个所有者
但实际开发中有时候会出现:
多个对象
都需要共同使用同一块资源
这时候就不能再简单使用独占所有权,而需要:
共享所有权
+
引用计数
这就是下一篇要继续手写的:
shared_ptr