![d349dacbf8b42d72cb9365ada07d6da7.png](https://i-blog.csdnimg.cn/blog_migrate/5146a98222c370b151c90e5a91e9996a.png)
1 序
1.1 智能指针
为了避免手动在堆中分配出的内存没有释放造成内存泄露的问题,C++11提供了智能指针。
智能指针将指针封装成一个栈对象。栈对象在生命周期结束自动销毁时会调用析构函数。智能指针基本上也是在析构函数中做文章,实现的堆上内存管理。
C++11推荐使用的智能指针有unique_ptr
、shared_ptr
和weak_ptr
三种。
1.2 内容简介
本文为std::unique_ptr
的实现篇,介绍实现一个UniquePtr
类,实现与std::unique_ptr
类似的功能。代码实现参考了std::unique_ptr
的实现,当然仅仅是简单的实现,准确的说是实现了std::unique_ptr
的一种特例。
实现UniquePtr
的目的仅仅是为了更直观理解学习std::unique_ptr
的用法和其实现中的亮点,并不是为了替代或者在工程中使用。代码都是需要时间去修改稳定的。大家公认的轮子,特别STL的轮子,直接学习使用就行了,不要自己搞。
1.3 测试环境
•系统:Windows | 10 ;•编译环境:CLion | 2020.1.1 ;•编译工具:CMake | 3.16.5;•编译器:MinGW GCC | 8.1.0 (x86_64-posix-seh-rev0, Built by MinGW-W64 project) ;
默认情况下gcc编译器会自动优化临时对象构造新对象的行为。为了更好的了解对象的构造流程,需要在CMake中添加如下命令关闭该优化。
SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-elide-constructors")
1.4 示例说明
为了便于查看UniquePtr
所托管指针的申请和释放,示例使用下面的自定义Test
类。
class Test {public: explicit Test(int a = 0) : a_(a), str_(new char[32]) { // explicit禁用隐式类型转换 sprintf(str_, "%d", a_); printf("Test[%p].[%s] constructor\n", this, str_); } ~Test() { printf("~Test[%p].[%s]\n", this, str_); delete[] str_; } // ...public: int a_; char *str_;};
2 std::unique_ptr自定义deleter
std::unique_ptr
支持传入自定义deleter
,在std::unique_ptr
对象释放析构的时候调用会调用该deleter
释放托管指针内存。在不传入deleter
的情况下,默认的deleter
使用delete
释放托管指针内存。
当使用std::unique_ptr
去托管new[]
生成的指针时,需要使用delete[]
去释放托管指针内存。此时,默认的deleter
就不可用,需要传入自定义的deleter
。有了自定义的deleter
,std::unique_ptr
甚至可以托管文件流、套接字(socket)等。
2.1 仿函数方式
托管new[]
的自定义的deleter
可以使用仿函数实现。实现一个仿函数,在其中调用delete[]
。
template<typename T>struct array_deleter { void operator()(T *p) { delete[] p; }};
然后,使用如下方式传入array_deleter
。
std::unique_ptr<Test, array_deleter<Test>> t1(new Test[10]);
在t1
析构的时候会自动调用array_deleter
,释放托管指针内存。
2.2 匿名函数方式
托管new[]
的自定义的deleter
可以使用匿名(lambda)函数实现。实现一个匿名函数,在其中调用delete[]
。为了让代码看起来简洁可以将匿名函数存储在一个lambda_deleter
变量中。
auto lambda_deleter = [](Test *p) -> void { delete[] p;};
然后,使用如下方式传入lambda_deleter
。
std::unique_ptr<Test, decltype(lambda_deleter)> t2(new Test[10], lambda_deleter);
在t2
析构的时候会自动调用lambda_deleter
,释放托管指针内存。
2.3 普通函数方式
下文自定义的deleter
使用普通函数实现,示例分别实现文件流和套接字的托管。注意下,自定义的deleter
传入方式,有些小复杂。
2.3.1 托管文件流
实现一个file_deleter
函数,在其中调用fclose
。
void file_deleter(std::FILE *fp) { std::fclose(fp);}
然后,使用如下方式传入file_deleter
。
std::unique_ptr<std::FILE, decltype(&file_deleter)> fp(std::fopen("test.txt", "wb+"), &file_deleter);
在fp
析构的时候会自动调用file_deleter
,释放托管文件流。
2.3.2 托管套接字(socket)
可以使用类似方式实现套接字的托管。实现一个socket_deleter
函数,在其中调用closesocket
。
void socket_deleter(SOCKET *s) { printf("socket_deleter\n"); closesocket(*s); *s = 0;}
由于套接字创建函数socket()
返回值并不是套接字类型的指针,所以有些许麻烦。可以使用如下方式传入socket_deleter
(第15行)。不过下文这种写法,代码中可以访问到socket()
的返回值s
。有多处访问s
的风险并不完美。也没有查到相关的代码示例,先将就着看吧。
WSADATA wsa;int ret = WSAStartup(MAKEWORD(2, 2), &wsa); // windows环境下需要if (ret != 0 || LOBYTE(wsa.wVersion) != 2 || HIBYTE(wsa.wVersion) != 2) { printf("WSAStartup failed with error:%d!\n", ret); WSACleanup(); return -1;}SOCKET s = socket(AF_INET, SOCK_DGRAM, 0);if (s == INVALID_SOCKET) { printf("create socket failed with error:%d!\n", WSAGetLastError()); return -1;}printf("socket = %d\n", s); // s原值{ // sp作用域 std::unique_ptr<SOCKET, decltype(&socket_deleter)> sp(&s, &socket_deleter); printf("sp socket = %d\n", *sp); // 从sp中获取s的值}printf("socket = %d\n", s);// 释放后s的值
上面代码段的输出结果如下:
socket = 96sp socket = 96socket_deletersocket = 0
2.4 总结
上面介绍的三种向std::unique_ptr
传递自定义deleter
的方法没啥技术难度,就是语法问题。在实际使用的时候可以根据自己的习惯选择使用。不过,实际中一般也用不到,至少我没有用过,也没有在工作代码中看到过,就随便看看知道有这么个知识点就行。
3 UniquePtr实现
本文示例代码为了简洁,取消了自定义deleter
功能,也简化了std::unique_ptr
的实现逻辑,仅实现UniquePtr
对象托管new
生成的指针,领会下std::unique_ptr
托管指针的思想就行。
想了解自定义deleter
实现或std::unique_ptr
完整实现的,直接看STL的代码就行。STL的代码乍一看会让你有种这是不是C++代码的感觉。多看看,习惯就好。
3.1 模板类
定义一个如下的UniquePtr
模板类,使得可以使用UniquePtr
托管各种类型的指针。类中包含一个T *ptr_
用于存储要托管的指针。
template<typename T>class UniquePtr {// ...private: T *ptr_;};
3.2 构造函数
定义如下构造函数可以使得使用形如UniquePtr t1(new Test(111))
和UniquePtr t2
两种方式构造t1
和t2
对象。其中,t1
对象托管了一个Test
类型的指针,t2
对象未托管任何指针。
explicit UniquePtr(T *ptr = nullptr) : ptr_(ptr) { printf("UniquePtr[%p] constructor\n", this);}
3.3 析构函数
当UniquePtr
对象作用域消失,会调用析构函数。定义如下析构函数,释放所托管指针的内存。
~UniquePtr() { printf("~UniquePtr[%p]\n", this); if (ptr_) { delete ptr_; ptr_ = nullptr; }}
3.4 禁用拷贝构造和赋值运算符
由于UniquePtr
对象托管的指针是独享的,拷贝构造和赋值操作会有歧义,因此使用如下方式禁用。
// Disable copy from lvalue.UniquePtr(const UniquePtr &p) = delete;UniquePtr &operator=(const UniquePtr &p) = delete;
当使用如下代码时,编译器就会报错。
UniquePtr<Test> t1(new Test(666));UniquePtr<Test> t2(t1); // 调用拷贝构造函数,报错t2 = t1; // 调用赋值运算符,报错
3.5 移动构造函数
UniquePtr
支持移动构造。使用移动构造函数构造新对象后,原对象所托管指针将被转移到新对象上。代码如下。
// Move constructor.UniquePtr(UniquePtr &&p) noexcept: ptr_(p.ptr_) { printf("UniquePtr[%p] Move[%p] constructor\n", this, &p); p.ptr_ = nullptr;}
从上面代码段可以看出左边的新对象构造以后,所托管的指针将与右边的原对象脱离,右对象的ptr_
置nullptr
。这也是为什么该类智能指针被称为unique_ptr
的原因。因为所托管的指针只能由一个UniquePtr
对象独享。
除了显示调用移动构造函数外,C++11中临时对象构造新对象时会优先调用移动构造函数构造新对象。
UniquePtr<Test> t1 = UniquePtr<Test>(new Test(666)); // 临时对象构造新对象隐式调用UniquePtr<Test> t2 = std::move(t1); // 显示调用
以上代码段t1
和t2
被析构后输出结果如下。
Test[0000000000605c60].[666] constructorUniquePtr[000000000022fde0] constructorUniquePtr[000000000022fb80] Move[000000000022fde0] constructor~UniquePtr[000000000022fde0]UniquePtr[000000000022fb70] Move[000000000022fb80] constructor~UniquePtr[000000000022fb70]~Test[0000000000605c60].[666]~UniquePtr[000000000022fb80]
3.6 类成员访问运算符(->)
为了让使用UniquePtr
对象就像使用所托管指针一样,可以直接使用->
访问托管指针对应类中成员,需要对operator->
进行重写,如下。
// Return the stored pointer.T *operator->() const noexcept { return ptr_; }
如下代码t1
可以直接用->
去访问Test
的成员变量a_
和str_
。
UniquePtr<Test> t1(new Test(666));assert(t1->a_ == 666 && !memcmp(t1->str_, "666", 3)); // 访问
第2行代码等效代码如下,相当于隐藏了t1.ptr_
。ptr_
为UniquePtr
中用于存储托管指针的成员变量。
assert(t1.operator->()->a_ == 666 && !memcmp(t1.operator->()->str_, "666", 3)); // 访问
3.7 解引用运算符(*)
为了让使用UniquePtr
对象就像使用所托管指针一样,可以直接使用*解引用托管指针,需要对operator *
进行重写,如下。
// Dereference the stored pointer.T &operator*() const noexcept { return *ptr_; }
如下代码t1
可以直接用*
解引用Test *
,然后使用.
去访问Test
的成员变量a_
和str_
。
UniquePtr<Test> t1(new Test(666));assert((*t1).a_ == 666 && !memcmp((*t1).str_, "666", 3)); // 访问
第2行代码等效代码如下,相当于隐藏了t1.ptr_
。ptr_
为UniquePtr
中用于存储托管指针的成员变量。
assert(t1.operator*().a_ == 666 && !memcmp(t1.operator*().str_, "666", 3)); // 访问
3.8 常用成员函数
3.8.1 release
std::unique_ptr
的release()
函数会释放托管指针的所有权,但并不会释放托管指针的内存。UniquePtr
也实现一个release()
函数,实现如下。
// Release ownership of any stored pointer.T *release() noexcept { T *res = ptr_; ptr_ = nullptr; return res;}
3.8.2 reset
std::unique_ptr
的reset()
函数会将托管指针重置为传入的指针,同时会将自身的所托管的指针内存释放掉。UniquePtr
也实现一个reset()
函数,实现如下。
// Replace the stored pointer.void reset(T *p = nullptr) noexcept { std::swap(ptr_, p); delete p;}
如下代码中的reset直接使用默认的参数nullptr
,相当于手动释放t所托管指针的内存。
std::unique_ptr<Test> t(new Test(333));t.reset(); // 手动释放t托管的指针
3.8.3 swap
std::unique_ptr
的swap()
函数会将两个对象所托管的指针互换。UniquePtr
也实现一个reset()
函数,实现如下。
// Exchange the pointer with another object.void swap(UniquePtr &p) noexcept { std::swap(ptr_, p.ptr_);}
3.9 移动赋值运算符
移动赋值运算符相当于=右边对象将托管指针移交给=左边的对象。注意,需要将=左边对象原托管指针内存释放若有,否则会造成内存泄漏。之所以最后介绍移动赋值运算符是因为STL中它的实现使用reset()
和release()
函数。实现如下。你品,你细品,一行代码解决了多少逻辑。
// Move assignment operatorUniquePtr &operator=(UniquePtr &&p) noexcept { printf("UniquePtr[%p] Move[%p] assignment operator\n", this, &p); reset(p.release()); return *this;}
你可能会觉得这样写貌似有些复杂,但是若有如下自赋值,上面代码的可以完美兼容,无需做自赋值判断。
UniquePtr<Test> t1(new Test(111));t1 = std::move(t1); // self
STL中代码复用率特别高,有些复用甚至都有些繁琐,甚至觉得这样做会不会拖慢代码执行速度。只要不是高频次调用的代码完全不必要担心,比如上面代码的复用让代码逻辑清晰了好多。
代码中一半以上的逻辑都是为了处理异常而存在的,下面代码段演示如何使用移动赋值运算符。智能指针之unique_ptr简单实现-使用篇。
UniquePtr<Test> t1(new Test(111));UniquePtr<Test> t2(new Test(222));assert(t1->a_ == 111 && (*t2).a_ == 222);// t1.reset(t2.release()); // 与下行等效t1 = std::move(t2);assert(t1->a_ == 222);
以上代码段t1
和t2
被析构后输出结果如下。
Test[0000000000305c60].[111] constructorUniquePtr[000000000022fc70] constructorTest[00000000006da7f0].[222] constructorUniquePtr[000000000022fc60] constructorUniquePtr[000000000022fc70] Move[000000000022fc60] assignment operator~Test[0000000000305c60].[111]~UniquePtr[000000000022fc60]~UniquePtr[000000000022fc70]~Test[00000000006da7f0].[222]
4 总结
写了两篇这么多字,很多的知识明面上都用不到,但那些都成为你能更好使用std::unique_ptr
的内功。如果可以给人打分,满分10分。一个8分的人一般能表现出6分,别人能看到的也许只有2分,所以才有厚积薄发。
虽然记忆的越多,一般来讲忘的也越多,但是剩下的也比什么都不记多吧。那些残存的记忆会帮你慢慢将知识回想起来的。毕竟大脑容量是无限的,所以学习知识的时候可以多找几个记忆点,即使有些丢了,剩下的也会帮你想起来的。
源码下载
在公众号后台回复[cppFollowers
]获取完整源码地址。下载完成后建议直接使用默认的 master
分支。dev
分支用于测试新的代码可能会有瑕疵。
相关推荐
•赋值运算符重载•拷贝构造函数&析构函数•C++对象构造的三种方式:直接构造or拷贝构造or移动构造•智能指针之unique_ptr简单实现-使用篇
END![c4a2ae4857acd12365ec88095d4a911a.png](https://i-blog.csdnimg.cn/blog_migrate/45b87657667e2ea63f54385b69c2b509.jpeg)