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->返回的是原始指针,编译器会自动处理成员访问。
重置与释放:手动控制资源
有时候我们需要手动释放资源,或者更换管理的对象。这就是reset和release的用武之地:
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本身不再管理它。这两个函数配合使用,可以实现所有权的转移。
布尔转换:用于条件判断
我们经常需要判断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的生命周期和所有权转移过程:
与标准库的差距
我们实现的这个版本和标准库的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时,你就能想象出它内部大概是怎么运作的了。嗯,这就是手写实现的意义——让你不再把它当成黑盒子。