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 从构造到析构的完整生命周期:
避坑指南
写 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 其实不难。核心就三点:
- 模板化:支持任意类型和自定义删除器
- 移动语义:禁止拷贝,只允许移动,所有权清晰
- 运算符重载:
->和*让智能指针用起来像裸指针
写完之后你会发现,标准库的 std::unique_ptr 也不过如此。当然,标准库版本还支持数组特化、类型擦除删除器、make_unique 工厂函数等高级特性。但核心思想,就是我们今天写的这些。
下次面试如果被问到「如何实现 unique_ptr」,你可以自信地拿出这段代码。嗯,我当年面试时就是这么干的。