欢迎光临
我们一直在努力

【C++面试】手写智能指针(一):从unique_ptr理解独占所有权与移动语义

一、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

0voice · GitHub

赞(0)
未经允许不得转载:171主机测评 » 【C++面试】手写智能指针(一):从unique_ptr理解独占所有权与移动语义
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址