0%

细说智能指针

今天我们来聊聊C++的智能指针。C++从1983年诞生到现在已经有30多年历史了,为什么到现在还能如此流行呢(排名第四,2020年6月HelloGitHub榜单)?因为它具有很多其它语言所不具备的优势,比如说执行速度快,控制力更强等。同样的,有更多的同学会选择用Java(排名第二)等语言,这是因为它相对C++来说,它更简单,易上手,不用担心内存泄漏!

确实,内存泄漏在很长的一段时间里是影响人们学习C++的一个最重要的原因。不过C++也在不断进步中,智能指针的出现就大大降低了内存泄漏发生的风险。

下面我们就来细聊一下C++智能指针的方方面面,通过本文让你真正掌握C++智能指针。

内存泄漏的产生

在C++中内存的分配与释放都是手工操作的(分配内存用new,释放内存用delete),这种方式本身就很容易产生内存泄漏。因为人们在开发过程中需要内存时很自然的就用new分配一块,但这块内存什么时候释放就说不好了,有可能用完马上就释放,也有可能要等待一个周期才能释放等等。而且随着时间的推移,代码越来越大,需要被释放的内存被遗忘的可能性也就更大。

我来看一下具体的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
void myfunc() {

int *pVal = new int();
*pVal = 10;
...
if(*pVal == 10){
return; //这里会产生内存泄漏
}
...
delete pVal;
return;

}

在上面的代码中,使用new在堆空间分配了一个整型大小的空间,在函数结束时通过delete将分配的内存释放。但当pVal==10时,函数没有释放内存就直接退出了,此时就产生了内存泄漏。

有的同学可能会说,谁会写出这么蠢的代码呢?实际上这样的代码在C++项目中经常出现,很多老手有时都犯这样的错误。你之所以可以一眼就看出上面代码的问题,是因为我将代码简化了。在真实的场景中,由于代码量比较大,你就没那么容易一眼看出问题了。

智能指针

上面我们已经看到了,通过new/delete这种方式申请/释放内存存在着很大弊端,有没有什么方法可以在使用时申请内存,在不需要的时自动释放它呢?当然有,这就是智能指针

下面我们来看看智能指针是怎么做到的吧。实际上,智能指针最朴素的想法是利用类的析构函数函数栈的自动释放机制来自动管理指针,即用户只要按需分配堆空间,堆空间的释放由智能指针帮你完成。

在解释这个原理之前,我们先来补充两个基本知识,一是构造函数与析构函数;另一个是堆空间与栈空间。首先来看构造函数与析构函数。

构造函数与析构函数

类对象的构造与析构是C++最基本的概念了,当创建对象时其构造函数会被调用,销毁对象时其析构函数会被调用。我们来举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

class MyClass {
public:
MyClass(){
std::cout << "construct func" << std::endl;
}

~MyClass(){
std::cout << "deconstruct func" << std::endl;
}
};

int main(int argc, char *argv[]){
std::cout << "create MyClass object ..." << std::endl;
MyClass *myclass = new MyClass();
std::cout << "release MyClass object ..." << std::endl;
delete myclass;
}

//编译命令 clang++ -g -o myclass test_class.cpp

我们将上面的代码编译执行后,会得到下面的结果:

1
2
3
4
create MyClass object ...
construct func
release MyClass object ...
deconstruct func

通过其结果就可以证明我们上面的结论了,即创建对象时其构造函数会被调用;销毁对像时其析构函数会被调用

下面我们再来看看堆空间与栈空间。

堆空间与栈空间

我们以Linux为例,在Linux系统上每个进程都有自己的虚似地址空间,如果你的系统是32位的,那它可以访问的内存空间是:2^32,也就是4G大小。

在这么大的空间中,内存被分成了几块:内核块、代码块、BSS块、堆空间,栈空间等。

  • 内核块,由Linux内核使用,应用层不可以访问。
  • 代码块,用户的二进制应用程序,只读。
  • BSS块,全局量,全局常量等。
  • 堆空间,用new分配的动态空间,可以分配大块内存。
  • 栈空间,用于函数调用,分配临时变量等。其空间大小有限,当函数执行完成后其内存会自动回收

其中栈空间有个特点,当函数执行完后,它所用到的栈空间会被自动释放,而这正是智能指针所需要的。当它与构造函数/析构函数结合到一起时就可以实现智能指针了。下面我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>

template <typename T>
class AutoPtr {
public:
explicit AutoPtr(T *ptr = nullptr){
std::cout << "set new object" << ptr << std::endl;
_ptr = ptr;

}

~AutoPtr(){
std::cout << "delete object" << _ptr << std::endl;
if(_ptr != nulptr)
delete _ptr;
}

private:
T * _ptr;
};

class MyClass{
public:
MyClass(){
std::cout << "construct MyClass func" << std::endl;
}

~MyClass(){
std::cout << "deconstruct MyClass func" << std::endl;
}
};

int main(int argc, char *argv[]){
AutoPtr<MyClass> myclass(new MyClass());
}

//编译命令 clang++ -g -o autoptr test_autoptr.cpp

上面例子执行的结果为:

1
2
3
4
construct MyClass func
set new object, 0x7f8e25c028c0
delete object, 0x7f8e25c028c0
deconstruct MyClass func

在上面main函数中创建了一个智能指针AutoPtr<MyClass> myclass,其在堆空间分配了一个MyClass对象交由智能指针管理,即myclass(new MyClass())。当main函数结束时,它会调用智能指针的析构函数,析构函数中执行了delete操作,最终将之前new出来的myclass对象释放掉了。

通过这个例子我们可以知道,有了智能指针我们就不用再担心内存泄漏了。对于C++开发同学来说像不像中了大奖一样高兴?不过上面的AutoPtr还称不上真正的智能指针,因为它只实现了智能指针最基本的一部分功能,我们还需要对它不断完善才行。

AutoPtr智能指针

上面实现的智能指针有什么问题呢?最大的问题就是它不能像真正的指针一样操作,比如说不能执行xxx->xxx()*xxx等操作。下面我们就为AutoPtr重载这两个操作符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//修改AutoPtr,增加 -> 和 * 操作符
class AutoPtr {
...
T* operator -> (){
return this->_ptr;
}
...
T& operator * (){
return &(this->_ptr);
}
...
};

//修改MyClass类,增加print方法
class MyClass {
...
void print(){
std::cout << "Hello world!" << std::endl;
}
...
}

//增加测试例子
int main(int argc, char *argv[]){
AutoPtr<MyClass> myclass(new MyClass());
myclass->print();
(*myclass).print();

return 0;
}

运行该程序,我们可以得到下面的结果:

1
2
3
4
5
6
construct MyClass func
set new object,0x7f94f44028c0
Hello world!
Hello world!
delete object,0x7f94f44028c0
deconstruct MyClass func

通过上面的例子我们可以看到AutoPtr确实像一个真正的指针了,既可以通过->调用MyClass方法,又可以通过*调用MyClass方法。

AutoPtr缺陷

虽然上面的AutoPtr实现看着很不错,不过它有非常致命的问题。当两个AutoPtr指针指向同一块堆空间时,在释放资源时会引起crash。咱们看一个例子:

1
2
3
4
5
6
7
//增加测试例子
int main(int argc, char *argv[]){
AutoPtr<MyClass> myclass(new MyClass());
AutoPtr<MyClass> newPtr = myclass;

return 0;
}

当你在main函数中让两个AutoPtr指向同一块堆空间时就会引起crash。之所以会出现这个问题,是因为堆空间被释放了两次。上面程序的执行结果就可以推出这个结论:

1
2
3
4
5
6
7
8
9
construct MyClass func
set new object,0x7fdecfc028c0

delete object,0x7fdecfc028c0 //释放第二个对象
deconstruct MyClass func

delete object,0x7fdecfc028c0 //释放第一个对象
deconstruct MyClass func
malloc: *** error for object 0x7fdecfc028c0: pointer being freed was not allocated //0x7fdecfc028c0这个空间已经被释放过一次了

通过上面的运行结果我们可以知道,创建myclass智能指针时它指向了new MyClass所分配的空间。紧接着,程序使用默认=运算符将myclass中的全部内容赋值给newPtr。此时newPtr的_ptr成员会与myclass的_ptr成员指向同一块堆空间(由于使用了默认=运算符,所以过程没有显示出来)。

当main函数结束时,它会按次序依次调用newPtr的析构函数和myclass的析构函数,所以我们可以看到有两次”delete object,0x7fdecfc028c0”。在C++中,如果对同一地址释放多次就会引起crash,所以我们在显示结果的最后一行看到了”pointer being freed was not allocated” 这条信息表示的就是重复释放了。

因此我们必须对 AutoPtr 继续改进,防止出现重复释放的情况。如何才能防止重复释放呢?

谁来独享所有权?

我们可以想到的最简单的办法是当有多个智能指针指向同一块堆空间时,只能有一个智能指针拥有所有权。什么意思呢?就是这块堆空间的释放只能由其中的一个来完成。

允许共享,独占所有权

怎么才能让众多智能指针中的一个拥有所有权呢?简单的办法是在AutoPtr上加个owner就好了。我们将上面的代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class AutoPtr {
public:
explicit AutoPtr(T *ptr = nullptr):_ptr(ptr), _owner(true){
}

AutoPtr(AutoPtr<T> & autoptr):_ptr(autoptr._ptr), _owner(false){
}

~AutoPtr(){
if(_owner && _ptr != nullptr){
delete _ptr;
}
}

AutoPtr& operator=(AutoPtr<T> & autoptr){
if(this != &autoptr){
if(_ptr){
delete _ptr;
}
this->_ptr = autoptr._ptr;
_owner = false;
}

return *this;
}

...

private:
T* _ptr;
bool _owner;
};

经上面修改后,new MyClass分配的空间就有了具体的owner,所以再执行之前的测试程序就不会crash了。结果如下:

1
2
3
4
5
6
construct MyClass func
set new object,0x7fb9acc028c0
copy construct, 0x7fb9acc028c0
delete object,0x7fb9acc028c0 // 调用newPtr 析构
delete object,0x7fb9acc028c0 // 调用 myclass 析构
deconstruct MyClass func // 由于myclass是owner,所以才会真正的释放堆空间

通过上面的修改问题似乎已经得到了解决,但实际的情况是后创建的智能指针更应该是owner,所以我们再做下微调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

class AutoPtr {

explicit AutoPtr(T *ptr = nullptr):_ptr(ptr), _owner(true){
}

AutoPtr(AutoPtr<T> & autoptr):_ptr(autoptr._ptr), _owner(true){
autoptr._owner = false;
}

AutoPtr& operator=(AutoPtr<T> & autoptr){
if(this != &autoptr){
if(_ptr){
delete _ptr;
}
this->_ptr = autoptr._ptr;
_owner = true;
autoptr._owner = false;
}

return *this;
}
...

};

经上面修改后,后创建的AutoPtr就取代之前的智能指针成为owner了。我们来看一下结果:

1
2
3
4
5
6
construct MyClass func
set new object,0x7fe67b4028c0
copy construct, 0x7fe67b4028c0
delete object,0x7fe67b4028c0 // 调用newPtr析构函数
deconstruct MyClass func // 堆空间被释放掉了
delete object,0x7fe67b4028c0 // 调用myclass析构函数

通过上面最后三行的输出结果我们可以看出,释放空间的顺序发生了变化,说明owner已经变为最近创建的智能指针newPtr了。

调整后的AutoPtr还有没有问题呢?当然还有,我们再来看一下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class AutoPtr {
...
public:
T* get(){
return this->_ptr;
}
...
};

int main(int argc, char *argv[])
{
AutoPtr<int> oldPtr(new int(100));
{
AutoPtr<int> newPtr(oldPtr);
}

//这里出现了野指针
*(oldPtr.get())= -100;
std::cout << "the value is " << *(oldPtr.get()) << std::endl;
}

我在AutoPtr中增加了一个get方法以便获得智能指针所指的堆空间

在上面的代码中,将newPtr放到一个花括号里,这样它就有了自己的栈空间。当跳出花括号后,newPtr就完成了它的使命,然后它会将持有的资源全部释放掉。由于newPtr从oldPtr获得了new int(100)这块堆空间的控制权,所以当newPtr生命周期结束后,堆空间也被回收了。

但在newPtr被释放掉之后,oldPtr却还能通过get方法访问原来的堆空间,它还能将-100写入了被释放的堆空间。这是非常可怕的事情,因为oldPtr通过get方法拿到的已经是野指针了。

因此,多智能指针共享堆空间并用owner控制最终资源释放的方法并不是特别好的智能指针方案。

不允许共享,独占所有权

既然多智能指针共享堆空间存在着这样或那样的问题,那干脆不让他们共享得了。比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class AutoPtr {
public:
explicit AutoPtr(T * ptr=nullptr): _ptr(ptr){
}

AutoPtr(AutoPtr<T> & autoptr): _ptr(autoptr._ptr){
autoptr._ptr = nullptr;
}

~AutoPtr(){
if(_ptr){
delete _ptr;
}
}

AutoPtr<T> & operator=(AutoPtr<T> & autoptr){
if(this != &autoptr) {
if(_ptr != nullptr){
delete _ptr;
}
_ptr = autoptr._ptr;
autoptr._ptr = nullptr;
}

return *this;
}

...

private:
T *_ptr;
};

int main(int argc, char *argv[]){
AutoPtr<int> oldPtr(new int(100));
AutoPtr<int> newPtr = oldPtr; //oldPtr已经不指向堆空间了
}

在上面的代码中我们不允许多个AutoPtr之间共享同一块堆空间,当将一个AutoPtr赋值给另一个AutoPtr时,让原来的AutoPtr指向空地址(nullptr),新的AutoPtr指向堆空间。上面代码执行结果为:

1
2
3
4
5
set new object,0x7fe905502760
copy construct, 0x7fe905502760 //newPtr 指向堆空间
delete object,0x7fe905502760 //newPtr 析构
really,delete object,0x7fe90550276 //newPtr 指向的堆空间被释放
delete object,0x0 //oldPtr 析构,此时可以看到它指向的地址为nullptr

通过结果可以证明我们上面修改的代码已经阻止了多个AutoPtr共享同一块堆空间的可能。

然而上面的实现打破了我们对传统指针的认知,这会给你带来很多麻烦。尤其是多人合作时,如果大家对AutoPtr没有一致的认识,特别容易出现问题。因为既然是指针,那它就应该允许多个指针指向同一块堆空间。因此,当不有了解AutoPtr的同学使用它时,很可能还会认为多个AutoPtr是指向同一块堆空间的,这样当他通过老的AutoPtr向堆空间写数据时就会产生crash。比如像下面这样:

1
2
3
...
*(oldptr.get()) = 10;
...

其运行结果为:

1
2
3
set new object,0x7fd93bc028c0
copy construct, 0x7fd93bc028c0
[1] 39662 segmentation fault ./autoptr //这里crash了

上面的AutoPtr就是C++98规范中的auto_ptr的实现,由于该实现总是存在这样或那样的问题,因此现在auto_ptr已经被废弃掉了。

scoped_ptr

我们已经看到上面的AutoPtr有各种弊端,引起这些弊端的最主要的原因是AutoPtr具有控制权的传递性,也就是说它允许从一个AutoPtr赋值给另一个AutoPtr。

为了彻底解决AutoPtr上面所述的问题,就出现了scoped_ptr。scoped_ptr最早是在C++的boost库中出现的,其出现的原因是从C++98之后C++标准一直没有更新智能指针的规范。因此C++大牛们纷纷发布了自己私有标准,而scoped_ptr就是其中之一。

scoped_ptr的核心思想是什么呢?既然auto_ptr的所有问题都是因为传递性引起的,所以阻止其传递性就可以解决这个问题了。因此scoped_ptr的实现也特别简单,它将其拷贝构造函数及赋值操作符全部隐藏起来,这样就不会有auto_ptr的问题了。

下面我们来看一下它的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
template<typename T>
class ScopedPtr {
public:
ScopedPtr(T * ptr = nullptr): _ptr(ptr){
}

T* operator->(){
return this->_ptr;
}

T& operator*(){
return *(this->_ptr);
}

T* get(){
return this->_ptr;
}

~ScopedPtr(){
if(_ptr != nullptr){
delete _ptr;
}
}
protected:
ScopedPtr(ScopedPtr<T> & scopedptr){}
ScopedPtr<T> & operator=(ScopedPtr<T> & scopedptr){}

private:
T *_ptr;
};

int main(int argc, char *argv[]){
ScopedPtr<int> myPtr(new int(100));
ScopedPtr<int> newPtr = myPtr;
}

//clang++ -g -o scopedptr test_scopedptr.cpp

当我们编译上面的代码时,会报下面的错误:

1
2
3
4
5
6
7
test_scopedptr.cpp:32:29: error: calling a protected constructor of class 'ScopedPtr<int>'
ScopedPtr<int> newPtr = myPtr;
^
test_scopedptr.cpp:23:9: note: declared protected here
ScopedPtr(ScopedPtr<T> & scopedptr){}
^
1 error generated.

上面的错误正是我们想要的结果。只要你对ScopedPtr进行赋值,在编译时就不让其编译通过,这样就不会再产生AutoPtr的问题了。

unique_ptr

我们上面所讲的scoped_ptr并非是官方的标准,它是C++大牛们自己实现的版本。到了C++11之后官方版本来了,其被命名为unique_ptr。实际上unique_ptr与scoped_ptr功能几乎是一模一样,不过它们之间也有一些细微差别。

差别是什么呢?就是unique_ptr可以对右值进行转移,对右值转移这是啥意思呢?说明白了就是提供了一种特殊方法可以将unique_ptr赋值给另一个unique_ptr,被转移后的unique_ptr也就不能再处理之前管理的指针了。

我们还是来看一个具体的例子你就清楚了,只要给我们之前的ScopedPtr加上一个移动构造函数和移动赋值运算符就实现unique_ptrr的转移功能了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ScopedPtr{
...
ScopedPtr(ScopedPtr<T>&& scopedptr) noexcept : _ptr(scopedptr._ptr){
std::cout << "move construct..." << std::endl;
scopedptr._ptr = nullptr;
}

ScopedPtr& operator=(ScopedPtr<T> && scopedptr) noexcept {
std::cout << "move assignment..." << std::endl;
if(this != &scopedptr){
_ptr = scopedptr._ptr;
scopedptr._ptr = nullptr;
}
return *this;
}
};

int main(int argc, char * argv[]){
ScopedPtr<int> myPtr(new int(100));
//ScopedPtr<int> newPtr = myPtr; //拷贝构造函数已经不能用了
ScopedPtr<int> newPtr = std::move(myPtr); //可以使用移动拷贝构造函数进行转移
}

//clang++ -std=c++11 -g -o scopedptr test_scopedptr.cpp

上面main函数中的第二行调用的是拷贝构造函数,由于该函数是不是public属性,所以调用该行时会失败。而第三行会调用移动构造函数,因为我们已经实现了移动构造函数,所以该行可以编译成功。在运行时,当myPtr移动给newPtr后,myPtr也就失去了对原指针的控制权,这在代码中也有体现就是将 scopedptr的_ptr域设置为nullptr了。

上面就是C++11标准中的unique_ptr的实现,这样一分析下来也是蛮简单的对吧。

另外,对于移动构造函数,std::move这些概念我在另一篇文章《C++高阶知识:深入分析移动构造函数及其原理》中有详细的介绍,对这块知识不了解的同学可以去看一下。

shared_ptr

虽然unique_ptr已经很好用了,但有时候我们还是需要多个智能指针管理同一块堆内存空间。之前在讲AutoPtr时我们已经介绍了多个智能指针管理同一块内存空间会引起很多问题,有没有更好的方式来解决这些问题呢?

其中引用计数法是个不错的解决方案,实现起来也比较简单。其基本原理是当有多个智能指针指对同一块堆空间进行管理时,每增加一个智能指针引用计数就增1,每减少一个智能指针引用计数就减少。当引用计数减为0时,就将管理的堆空间释放掉。

我们还是看一个具体例子吧,其实现是在unique_ptr的基础之上实现的,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class ScopedPtr {

public:
...
ScopedPtr(T *ptr = nullptr): _ptr(ptr), _ref_count(new int(1)){
}

ScopedPtr(ScopedPtr<T> & scopedptr): _ptr(scopedptr._ptr), _ref_count(scopedptr._ref_count){
++(*_ref_count);
}

ScopedPtr & operator=(ScopedPtr<T> & scopedptr){
if(this != &scopedptr){
_release();
_ptr(scopedptr._ptr);
_ref_conut(scopedptr._ptr);
++(*_ref_count);
}

return *this;
}

~ScopedPtr(){
_release();
}

int* getCount(){
return *_ref_count;
}
protected:
void _release() {
std::cout << "deconstruct...: count=" << ((*_ref_count) -1) << std::endl;
if(--(*_ref_count) == 0){
delete _ptr;
delete _ref_count;
}
}

private:
...
int *_ref_count; //引用计数
};

int main(int argc, char *argv[]){
ScopedPtr<int> myPtr(new int(100));
ScopedPtr<int> pT2 = myPtr;
}

//clang++ -std=c++11 -g -o sharedptr test_sharedptr.cpp

通过上面的修改,我们就可以将unique_ptr修改成shared_ptr了,测试结果如下:

1
2
3
4
default construct ...
copy construct ...: count=2
deconstruct...: count=1
deconstruct...: count=0

从结果中我们可以看到创建myPtr时引用计数为 1,将myPtr赋值给pT2时引用计算为2。当main程序结束后首先释放pT2,其引用计数减1。再释放myPtr,引用计数减为0,当引用计数为0时,释放堆空间。

这样的智能指针还是非常棒的,我们再也不怕内存泄漏了!!!

等等,我们好像高兴的太早了,当出现循环指向时还是会出现内存泄漏。我们来看一下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
struct Node
{
ScopeddPtr<Node> _prev;
ScopeddPtr<Node> _next;

~Node()

{
std::cout << "delete :" <<this<< std::endl;
}
};

ScopedPtr<Node> cur(new(Node));
ScopedPtr<Node> next(new(Node));
cur->_next = next;
next->_prev = cur;

上面这段代码就会出现内存泄漏,我们来分析一下。

首先第一行会创建三个Node类型的智能指针,分别是 cur->_prevcur->_nextcur 此时它们各自的引用计数都是 1;第二行也会创建三个Nodet智能指针,分别是next->_prevnext->_nextnext; 第三行完成之后,cur->_nextnext的引用计数都为 2;第四行完成后,next->_prevcur的引用计数也变成了2;

当main程序结束时,next和cur分别调用它们的析构函数,因此nextcur->_next的引用计数变为1,curnext_prev的引用计数也变成了1,但由于没有减至0,所以资源永会不会被释放掉。这就是产生内存泄露的原因。

真是辛辛苦苦好几年,一下回到解放前。使用引用计数还是会产生内存泄漏,我们仿佛又回到了起点。

不过别着急,C++11又给我们提供了新的解决方案,如何解决这个问题呢?

weak_ptr

weak_ptr就是专门为了解决这个问题而出现的。实际上weak_ptr不能单独称为一个智能指针,它必须与shared_ptr一起使用,起到辅助share_ptr的作用。我们来看看它是如何解决上述问题的吧。

首先引入weak_ptr后,weak_ptr也要有自己的引用计数,因此我们需要修改之前的ScopedPtr,将它的计数成员变成一个类型,包括它自己的计数和weak_ptr的计数,它看起来像下面的样子:

1
2
3
4
5
6
7
class Counter
{
public:
Counter():s(0),w(0){};
int s; //存放share_ptr引用计数
int w; //存放weak_ptr引用计数
};

接下来我们来修改一下ScopedPtr,由于这次修改比较大,所以我给它重新起一个名子,叫作SharedPtr吧,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
#include<iostream>

template<typename T> class WeakPtr;
template<typename T>
SharedPtr {
public:
SharedPtr(T * ptr = nullptr):_ptr(ptr){
_cnt = new Counter();
if(_ptr != nullptr){
_cnt->s = 1;
}
}

~SharedPtr(){
_release();
}

SharedPtr(SharedPtr<T> & sharedptr): _ptr(sharedptr._ptr), _cnt(sharedptr._cnt){
_cnt->s++;
}

SharedPtr(WeakPtr<T> & weakptr): _ptr(weakptr._ptr), _cnt(weakptr._cnt){
_cnt->s++;
}

SharedPtr & operator=(SharedPtr<T> & sharedptr){
if(this != & sharedptr){
_release();
_ptr = sharedptr._ptr;
_cnt = sharedptr._cnt;
_cnt->s++;
}
return *this;
}

T& operator *(){
return *_ptr;
}

T* operator ->(){
return _ptr;
}

//friend class WeakPtr<T>;

protected:
void _release(){
_cnt->s--;
std::cout << "release "<<_cnt->s << std::endl;
if(_cnt->s < 1) {
delete _ptr;
if(_cnt->w <1) {
delete _cnt;
_cnt=nullptr;
}
}
}

private:
T * _ptr;
Counter * _cnt;
};

template<typename T>
class WeakPtr{
public:
WeakPtr():_ptr(nullptr), _cnt(nullptr){}

~WeakPtr(){
_release();
}

WeakPtr(WeakPtr<T> & weakptr):_ptr(weakptr._ptr), _cnt(weakptr._cnt){
_cnt->w++;
}

WeakPtr(SharedPtr<T> & sharedptr):_ptr(sharedptr._ptr), _cnt(sharedptr._cnt){
_cnt->w++;
}

WeakPtr & operator=(WeakPtr<T> & weakptr){
if(this != &weakptr){
_release();
_ptr = weakptr._ptr;
_cnt = weakptr._cnt;
_cnt->w++;
}
return *this;
}

WeakPtr & operator=(SharedPtr<T> & sharedptr){
_release();
_ptr = sharedptr._ptr;
_cnt = sharedptr._cnt;
_cnt->w++;
return *this;
}

SharedPtr<T> lock(){
return SharedPtr<T>(*this);
}

bool expired(){
if(_cnt && _cnt->s > 0){
std::cout<<"empty "<<_cnt->s<<std::endl;
return false;
}
return true;
}

//friend class SharedPtr<T>;

protected:
void _release(){
if(_cnt){
_cnt->w--;
std::cout<<"weakptr release"<<_cnt->w<<std::endl;
if(_cnt->w < 1 && _cnt->s <1) {
//delete cnt;
_cnt=nullptr;
}
}
}

private:
T * _ptr;
Counter * _cnt;
};

struct Node
{
WeakPtr<Node> _prev;
WeakPtr<Node> _next;

~Node()
{
std::cout << "delete :" <<this<< std::endl;
}
};

int main(int argc, char * argv[]){
SharedPtr<Node> cur(new(Node));
SharedPtr<Node> next(new(Node));
cur->_next = next;
next->_prev = cur;
}

//clang++ -std=c++11 -g -o weakptr test_weakptr.cpp

以上就是WeakPtr的实现以及SharedPtr的改造,从中我们可以看到,SharedPtr与我们之前的ScopedPtr区别并不是很大,主要做了三点有修改:一、以前只有一个计数器,然在变成了两个,一个是SharedPtr本身的计数,另一个是WeakPtr的计数;二是增加了一个参数为WeakPtr引用的拷贝构造函数;三、_ptr_cnt的释放都是在SharedPtr中完成的,WeakPtr不做具体的释放工作。

WeakPtr是新增加的弱指针,它是配合SharedPtr使用的,自己并不能单独使用。WeakPtr也包含_ptr_cnt两个成员,但它更多是是引用,对它们没有创建和释放权。另外在WeakPtr中会对Counter对象的w字段操作,也就是说多个WeakPtr指向同一个堆空间时,它仅操作Counter中的w字段。

因此,对于我们之前的SharedPtr形成环后导致的内存泄漏可以通过WeakPtr对其进行改造,这样内存泄漏的问题就迎刃而解了。

上面修改后的代码我们再来分析一遍。首先第一行会创建两个WeakPtr指针 cur->_prevcur->_next 和一个SharedPtr智能指针cur。此时它们各自的引用计数都是 1;第二行同样也会创建二个WeakPtr指针next->_prevnext->_next和一个SharedPtr智能指针next; 第三行完成之后,cur->_next的_cnt->w为1,next的_cnt->s为1;第四行完成后,next->_prev的_cnt->w为1,cur的_cnt->s引用计数也为1;

当main程序结束时,next和cur分别调用它们的析构函数,因此next引用计数为0,释入Node对象,在Node中又会释放_prev和_next。next释放完成后开始释放cur,同里cur所持有的资源也一并释放。因此就不会再有内存泄漏了。

小结

本文的篇幅有点长,不过每一部分都是不可或缺的。在本文中向你详细讲解了 auto_ptr、scoped_ptr、unique_ptr、shared_ptr以及与之配套的 weak_ptr的衍化过程。通过这样一个过程让你知道了这几个智能指针的作用是什么,应该用在地方,以及该如何使用。

相信通过本文你会对C++中的智能指针有了深刻的理解。

参考

C++高阶知识:深入分析移动构造函数及其原理
[重学C/C++中的const][https://avdancedu.com/5e7916e3/]
聊聊C++中的类型转换

  • 本文作者: 音视跳动-李超 [avdance@163.com]
  • 本文链接: https://blog.avdancedu.com/9683d88/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道