【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

相关推荐
李游Leo1 小时前
HarmonyOS 7 + Spatial Recon Kit-C++:3DGS 输入帧内参与位姿的版本一致性门禁【鸿蒙心迹】
c++·3d·harmonyos
Brilliantwxx1 小时前
【STM32】 DMA 源码分析与实战(下):DMA 收发状态机与 HAL 分层
android·开发语言·stm32·单片机·嵌入式硬件·mcu
(Charon)1 小时前
【C++面试】手写智能指针(二):从shared_ptr理解引用计数与共享所有权
c++·算法
Ai-_Man1 小时前
请问豆包的智能体聊天记录该怎么弄
开发语言·前端·javascript·人工智能·小程序·ecmascript·电脑
无名猿2 小时前
构造函数详解:默认、委托、转换与 explicit 到底写在哪
c++·现代c++·语法基础
言乐62 小时前
Python根据关联词搜索模型
开发语言·人工智能·python·机器学习·django
纪念 2292 小时前
C++ string(一)
android·开发语言·c++
一木 之林2 小时前
阿里云百炼与通义千问接入
开发语言·php
纪念 2292 小时前
C++算法(二)
开发语言·c++·算法