19. 手写一个简易unique_ptr:深入理解其实现原理

智能指针这东西,说白了就是RAII思想在指针管理上的具体落地。你想想看,我们写C++最头疼的是什么?无非就是new了之后忘了delete,或者异常路径上资源没释放。unique_ptr就是来解决这个问题的——它保证同一时刻只有一个所有者,离开作用域自动释放。

我个人习惯是,在理解一个工具之前,先自己动手实现一个简化版。这样你才能真正明白它为什么这么设计,边界在哪里。今天我们就来手写一个简易的unique_ptr,把它的核心原理彻底搞透。

unique_ptr的核心语义

unique_ptr最核心的特征就两条:

  • 独占所有权:不能拷贝,只能移动
  • 自动释放:析构时自动delete管理的对象

我在项目中遇到过好几次,有人试图把unique_ptr赋值给另一个unique_ptr,编译报错后一脸懵。其实这就是设计者的良苦用心——如果允许拷贝,那析构时两个指针都会去delete同一块内存,double free就来了。

基础框架:模板类与构造函数

我们先搭一个最基础的架子:

template<typename T>
class my_unique_ptr {
public:
    // 默认构造函数
    my_unique_ptr() noexcept : ptr_(nullptr) {}

    // 接受原始指针的构造函数
    explicit my_unique_ptr(T* p) noexcept : ptr_(p) {}

    // 析构函数
    ~my_unique_ptr() noexcept {
        delete ptr_;
    }

    // 禁止拷贝
    my_unique_ptr(const my_unique_ptr&) = delete;
    my_unique_ptr& operator=(const my_unique_ptr&) = delete;

    // 移动构造函数
    my_unique_ptr(my_unique_ptr&& other) noexcept 
        : ptr_(other.ptr_) {
        other.ptr_ = nullptr;
    }

    // 移动赋值运算符
    my_unique_ptr& operator=(my_unique_ptr&& other) noexcept {
        if (this != &other) {
            delete ptr_;          // 释放当前持有的资源
            ptr_ = other.ptr_;    // 接管对方的资源
            other.ptr_ = nullptr; // 对方置空
        }
        return *this;
    }

private:
    T* ptr_;
};

嗯,这里要注意几个细节。第一,构造函数我加了explicit,防止隐式转换。你想想看,如果允许my_unique_ptr<int> p = new int(42);这种写法,代码可读性会变差,而且容易误用。

第二,移动操作中一定要把源对象的指针置为nullptr。我曾经见过一个新手写的移动构造函数,只拷贝了指针没置空,结果两个unique_ptr指向同一块内存,析构时程序直接崩了。

运算符重载:像使用原始指针一样

unique_ptr要能像原始指针一样使用,需要重载*->运算符:

public:
    T& operator*() const noexcept {
        return *ptr_;
    }

    T* operator->() const noexcept {
        return ptr_;
    }

    T* get() const noexcept {
        return ptr_;
    }

这里有个小细节:operator*返回的是引用,这样你就可以修改指针指向的对象了。operator->返回的是原始指针,编译器会自动处理成员访问。

小提示:我建议在调试版本中给这些运算符加上断言,检查ptr_不为空。这样在开发阶段就能尽早发现空指针解引用的问题,而不是等到线上崩溃才追查。

重置与释放:手动控制资源

有时候我们需要手动释放资源,或者更换管理的对象。这就是resetrelease的用武之地:

public:
    void reset(T* p = nullptr) noexcept {
        delete ptr_;
        ptr_ = p;
    }

    T* release() noexcept {
        T* temp = ptr_;
        ptr_ = nullptr;
        return temp;
    }

reset会先释放当前资源,再接管新指针。release则放弃所有权,把原始指针返回给你,但unique_ptr本身不再管理它。这两个函数配合使用,可以实现所有权的转移。

警告:release之后一定要记得手动delete返回的指针,或者交给另一个智能指针管理。否则就内存泄漏了。我曾经在代码审查中看到有人调了release却忘了delete,那个指针就这么悬空了。

布尔转换:用于条件判断

我们经常需要判断unique_ptr是否为空,比如if (p) { ... }。这需要实现布尔转换运算符:

public:
    explicit operator bool() const noexcept {
        return ptr_ != nullptr;
    }

加上explicit是为了防止隐式转换到整数类型,避免一些奇怪的用法。比如int x = p + 1;这种代码,如果没加explicit就能编译通过,但语义完全不对。

完整代码示例

把上面所有部分拼起来,就是一个可用的简易unique_ptr了:

template<typename T>
class my_unique_ptr {
public:
    my_unique_ptr() noexcept : ptr_(nullptr) {}
    explicit my_unique_ptr(T* p) noexcept : ptr_(p) {}
    ~my_unique_ptr() noexcept { delete ptr_; }

    my_unique_ptr(const my_unique_ptr&) = delete;
    my_unique_ptr& operator=(const my_unique_ptr&) = delete;

    my_unique_ptr(my_unique_ptr&& other) noexcept 
        : ptr_(other.ptr_) {
        other.ptr_ = nullptr;
    }

    my_unique_ptr& operator=(my_unique_ptr&& other) noexcept {
        if (this != &other) {
            delete ptr_;
            ptr_ = other.ptr_;
            other.ptr_ = nullptr;
        }
        return *this;
    }

    T& operator*() const noexcept { return *ptr_; }
    T* operator->() const noexcept { return ptr_; }
    T* get() const noexcept { return ptr_; }

    void reset(T* p = nullptr) noexcept {
        delete ptr_;
        ptr_ = p;
    }

    T* release() noexcept {
        T* temp = ptr_;
        ptr_ = nullptr;
        return temp;
    }

    explicit operator bool() const noexcept {
        return ptr_ != nullptr;
    }

private:
    T* ptr_;
};

使用示例

struct Point {
    int x, y;
    Point(int a, int b) : x(a), y(b) {}
};

int main() {
    my_unique_ptr<Point> p1(new Point(3, 4));
    std::cout << p1->x << ", " << p1->y << std::endl; // 3, 4

    // my_unique_ptr<Point> p2 = p1; // 编译错误!不能拷贝

    my_unique_ptr<Point> p2 = std::move(p1); // 移动
    if (p1) {
        std::cout << "p1 not null" << std::endl; // 不会执行
    }
    if (p2) {
        std::cout << p2->x << std::endl; // 3
    }

    p2.reset(new Point(5, 6));
    Point* raw = p2.release();
    delete raw; // 记得手动释放

    return 0;
}

核心逻辑流程图

下面这张图展示了unique_ptr的生命周期和所有权转移过程:

unique_ptr 生命周期与所有权转移 创建 unique_ptr 持有资源 (ptr_ != nullptr) std::move 源对象 (ptr_ = nullptr) 目标对象 (接管资源) reset 释放旧资源 release 返回原始指针 离开作用域 析构函数:delete ptr_

与标准库的差距

我们实现的这个版本和标准库的std::unique_ptr相比,还差哪些东西?我列个表:

特性 我们的版本 std::unique_ptr
自定义删除器 不支持 支持(模板参数)
数组特化 不支持 支持(T[])
类型擦除 不支持 删除器类型是模板参数
异常安全 基本保证 强异常安全保证
constexpr支持 不支持 C++23起部分支持

不过话说回来,我们这个小实现已经抓住了unique_ptr的精髓——独占所有权和自动释放。自定义删除器其实就是在模板参数里加一个Deleter类型,然后析构时调用Deleter()(ptr_)。数组特化则是把delete换成delete[]。这些都是在核心逻辑上的扩展。

核心要点回顾:

  • unique_ptr是独占所有权的智能指针,不能拷贝只能移动
  • 移动操作后源对象必须置空,防止double free
  • 析构函数自动delete管理的对象,实现RAII
  • reset用于释放并更换资源,release用于放弃所有权
  • 布尔转换运算符支持if(p)这种条件判断

写到这里,你应该对unique_ptr的实现原理有了比较清晰的认识。下次用标准库的unique_ptr时,你就能想象出它内部大概是怎么运作的了。嗯,这就是手写实现的意义——让你不再把它当成黑盒子。

公众号:蓝海资料掘金营,微信 deep3321