18. 手写一个简易 unique_ptr:模板、移动构造、移动赋值、-> 和 * 运算符重载

说实话,unique_ptr 是我日常用得最多的智能指针。为什么?因为它零开销、所有权清晰、不会搞出循环引用。说白了,它就是 RAII 思想在指针管理上的完美体现。

今天我们就手写一个简化版 unique_ptr。别怕,核心就三件事:模板化移动语义运算符重载。写完之后你会发现,标准库的实现也不过是这些基础的组合。

为什么 unique_ptr 不能拷贝?

先想一个问题:如果两个指针同时指向同一块内存,谁负责释放?

这就是 unique_ptr 的设计哲学——独占所有权。它禁止拷贝构造和拷贝赋值,只允许移动。移动意味着「转移所有权」,原指针变成空,新指针接管资源。

我在项目中遇到过好几次「误拷贝 unique_ptr」的编译错误。嗯,编译器其实是在救你。如果你真的需要共享所有权,请用 shared_ptr,别跟 unique_ptr 较劲。

第一步:定义模板类

我们先搭个骨架。模板参数 T 表示管理的对象类型,Deleter 是删除器,默认用 std::default_delete

template <typename T, typename Deleter = std::default_delete<T>>
class UniquePtr {
public:
    // 构造函数:接收原始指针
    explicit UniquePtr(T* ptr = nullptr) : ptr_(ptr) {}

    // 析构函数:调用删除器释放资源
    ~UniquePtr() {
        if (ptr_) {
            deleter_(ptr_);
        }
    }

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

    // 移动构造和移动赋值(稍后实现)
    UniquePtr(UniquePtr&& other) noexcept;
    UniquePtr& operator=(UniquePtr&& other) noexcept;

    // 运算符重载(稍后实现)
    T* operator->() const;
    T& operator*() const;

    // 获取原始指针
    T* get() const { return ptr_; }

    // 释放所有权,返回原始指针
    T* release() {
        T* tmp = ptr_;
        ptr_ = nullptr;
        return tmp;
    }

    // 重置指针
    void reset(T* ptr = nullptr) {
        if (ptr_) {
            deleter_(ptr_);
        }
        ptr_ = ptr;
    }

private:
    T* ptr_;
    Deleter deleter_;
};

注意看,我把拷贝构造和拷贝赋值 = delete 了。这是关键——从语法层面杜绝了「两个指针抢所有权」的可能。

第二步:移动构造与移动赋值

移动语义是 C++11 的灵魂。对于 unique_ptr,移动就是「把别人的指针偷过来,再把他置空」。

// 移动构造
template <typename T, typename Deleter>
UniquePtr<T, Deleter>::UniquePtr(UniquePtr&& other) noexcept
    : ptr_(other.ptr_), deleter_(std::move(other.deleter_)) {
    other.ptr_ = nullptr;
}

// 移动赋值
template <typename T, typename Deleter>
UniquePtr<T, Deleter>& UniquePtr<T, Deleter>::operator=(UniquePtr&& other) noexcept {
    if (this != &other) {
        // 先释放当前持有的资源
        if (ptr_) {
            deleter_(ptr_);
        }
        // 转移所有权
        ptr_ = other.ptr_;
        deleter_ = std::move(other.deleter_);
        other.ptr_ = nullptr;
    }
    return *this;
}

这里有个细节:移动赋值时,必须先释放当前资源。我曾经在代码里忘了这一步,结果内存泄漏了三天才查出来。你想想看,如果当前对象已经持有一块内存,直接覆盖指针,那块内存就永远没人释放了。

注意:移动赋值一定要处理自赋值情况。虽然 unique_ptr 自赋值很少见,但写通用代码时不能假设调用者不会犯傻。

第三步:重载 -> 和 * 运算符

这两个运算符是 unique_ptr 能像普通指针一样使用的关键。

// 箭头运算符:返回原始指针
template <typename T, typename Deleter>
T* UniquePtr<T, Deleter>::operator->() const {
    return ptr_;
}

// 解引用运算符:返回引用
template <typename T, typename Deleter>
T& UniquePtr<T, Deleter>::operator*() const {
    return *ptr_;
}

注意 operator-> 返回的是 T*,而不是 T&。这是 C++ 的语法规则决定的——ptr->member 等价于 (ptr.operator->())->member,所以返回指针就对了。

我习惯在 operator* 前面加个断言:

T& operator*() const {
    assert(ptr_ != nullptr && "dereferencing null UniquePtr");
    return *ptr_;
}

嗯,调试阶段能帮你省不少时间。

完整代码示例

把上面所有片段拼起来,就是一个可用的简易 unique_ptr

#include <iostream>
#include <cassert>

template <typename T, typename Deleter = std::default_delete<T>>
class UniquePtr {
public:
    explicit UniquePtr(T* ptr = nullptr) : ptr_(ptr) {}

    ~UniquePtr() {
        if (ptr_) {
            deleter_(ptr_);
        }
    }

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

    UniquePtr(UniquePtr&& other) noexcept
        : ptr_(other.ptr_), deleter_(std::move(other.deleter_)) {
        other.ptr_ = nullptr;
    }

    UniquePtr& operator=(UniquePtr&& other) noexcept {
        if (this != &other) {
            if (ptr_) {
                deleter_(ptr_);
            }
            ptr_ = other.ptr_;
            deleter_ = std::move(other.deleter_);
            other.ptr_ = nullptr;
        }
        return *this;
    }

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

    T& operator*() const {
        assert(ptr_ != nullptr);
        return *ptr_;
    }

    T* get() const { return ptr_; }

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

    void reset(T* ptr = nullptr) {
        if (ptr_) {
            deleter_(ptr_);
        }
        ptr_ = ptr;
    }

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

private:
    T* ptr_;
    Deleter deleter_;
};

// 测试代码
struct Test {
    int value;
    Test(int v) : value(v) { std::cout << "Test(" << v << ")\n"; }
    ~Test() { std::cout << "~Test(" << value << ")\n"; }
};

int main() {
    UniquePtr<Test> p1(new Test(42));
    std::cout << p1->value << "\n";  // 42
    std::cout << (*p1).value << "\n"; // 42

    UniquePtr<Test> p2 = std::move(p1);
    // p1 现在是空
    if (!p1) {
        std::cout << "p1 is empty\n";
    }
    std::cout << p2->value << "\n"; // 42

    return 0;
}

核心逻辑流程图

下面这张图展示了 unique_ptr 从构造到析构的完整生命周期:

UniquePtr 生命周期流程图 构造:接收原始指针 使用:-> 和 * 运算符 移动:转移所有权 析构:调用删除器释放 也可调用 release() 或 reset() 提前释放 禁止拷贝,只允许移动 移动后原指针置空,新指针接管资源

避坑指南

unique_ptr 时,有几个坑我踩过,分享给你:

  • 不要用 auto_ptr:C++17 已经废弃了。它的拷贝语义是「偷偷转移所有权」,容易让人迷惑。用 unique_ptr 替代它。
  • 自定义删除器要注意类型:如果删除器有状态(比如函数对象),移动构造时要记得转移删除器。我见过有人只移动了指针,忘了移动删除器,结果析构时调用了错误的释放逻辑。
  • 数组特化:标准库提供了 unique_ptr<T[]> 版本,重载了 operator[]。如果你要管理动态数组,记得用数组特化,否则析构时只会调用 delete 而不是 delete[]
小技巧:如果你想让 unique_ptr 管理一个文件句柄或 socket,自定义删除器非常有用。比如 auto file = UniquePtr<FILE, decltype(&fclose)>(fopen("test.txt", "r"), &fclose);,这样就不用手动 fclose 了。

总结

手写 unique_ptr 其实不难。核心就三点:

  1. 模板化:支持任意类型和自定义删除器
  2. 移动语义:禁止拷贝,只允许移动,所有权清晰
  3. 运算符重载->* 让智能指针用起来像裸指针

写完之后你会发现,标准库的 std::unique_ptr 也不过如此。当然,标准库版本还支持数组特化、类型擦除删除器、make_unique 工厂函数等高级特性。但核心思想,就是我们今天写的这些。

下次面试如果被问到「如何实现 unique_ptr」,你可以自信地拿出这段代码。嗯,我当年面试时就是这么干的。


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