目录
学习资源参考:C++ 比特就业课
为什么需要智能指针?
先来看一个例子:
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
|
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("Error: Divident is 0");
return a / b;
}
void func()
{
int* p = new int();
cout << div() << endl;
delete p;
}
int main()
{
// 捕获异常
try
{
func();
}
catch(exception& e)
{
cout << e.what() << endl;
}
return 0;
}
|
由于 div 函数中存在抛异常的行为,程序的正常执行流被打断了, div 没有执行完导致指针p没有被释放。一般异常是在最外层去捕的,但是面对上面的情况,在没有智能指针的情况下,只能在 func 中再次添加捕异常的代码,来避免指针p不被释放的问题:
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
|
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("Error: Divident is 0");
return a / b;
}
void func()
{
int* p = new int();
try // 在func中加捕异常,避免p不被释放
{
cout << div() << endl;
}
catch (exception& e) // 此处也可以写为捕获任意异常:catch (...)
{
delete p;
p = nullptr;
throw e;
}
delete p;
p = nullptr;
}
int main()
{
// 捕获异常
try
{
func();
}
catch(exception& e)
{
cout << e.what() << endl;
}
return 0;
}
|
在涉及多个指针的代码时,为了防止内存泄漏就得层层添加抛异常的代码。于是,大佬们想出了一种更简洁优雅的方式处理:智能指针。
智能指针的基本原理
智能指针的实现原理非常简单:在构造函数的时候把指针保存起来,在析构函数的时候把指针释放。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
if (_ptr)
{
delete _ptr;
_ptr = nullptr;
}
}
private:
T*_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
|
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("Error: Divident is 0");
return a / b;
}
void func()
{
int* p = new int();
// 不用捕异常,改用智能指针
SmartPtr<int> sp(p);
cout << div() << endl;
}
int main()
{
// 捕获异常
try
{
func();
}
catch(exception& e)
{
cout << e.what() << endl;
}
return 0;
}
|
不管最终有没有抛异常,p都被释放了。也就是说,借助了 SmartPtr 对象的 生命周期 解决了抛异常产生的内存泄漏问题(指针未被删除)。简而言之, SmartPtr 帮我们管理了资源的释放。无论函数是正常结束还是抛异常被中断执行,都会导致 sp 对象的生命周期到了以后,调用析构函数,从而释放p指针。我们实际上是 将资源托管给智能指针 管理,智能指针的构造函数保存资源,析构函数释放这个资源。
RAII
RAII(Resource Acquisition Is Initialization)是一种 利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥锁等等)的技术。
也就是说,在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任 托管 给了一个对象。
这种做法有两大好处:
- 不需要显式地释放资源
- 采用这种方式,对象所需的资源在其生命期内始终保持有效
这从某种程度上间接解决了C++没有垃圾回收机制的问题。
- RAII是一种托管资源的思想,除了智能指针外,
unique_lock 和 lock_guard 也是依托这种思想实现的
- 智能指针是基于RAII思想设计的一个类
智能指针的使用
上述的SmartPtr 还不能将其称为智能指针,因为它还不具有指针的行为。所以我们必须再添加一些代码,让它真正可以进行指针的相关操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr = nullptr)
: _ptr(ptr)
{}
~SmartPtr()
{
if(_ptr)
{
delete _ptr;
_ptr = nullptr;
}
}
// 重载操作符&和*,让SmartPtr具备指针的行为
T& operator*() {return *_ptr;}
T* operator->() {return _ptr;}
private:
T* _ptr;
};
|
智能指针的潜在问题
先举例子:
1
2
3
4
5
6
7
|
int main()
{
SmartPtr<int> sp1(new int());
SmartPtr<int> sp2 = sp1;
return 0;
}
|
sp1 和 sp2 指向了同一块空间,调用了两次析构函数,也就是说,同一个资源被释放了两次。
为了解决这个问题,智能指针提供了三个解决方案:
- 管理权转移
auto_ptr
- 防拷贝
unique_ptr
- 引用计数的共享拷贝
shared_ptr
- 会产生 循环引用 的问题,引入
weak_ptr 解决
auto_ptr
总的来说是一种比较失败的设计,背离了“指针”的行为,很多公司明确要求不能使用 auto_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
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
|
// C++98 管理权转移 auto_ptr
namespace my_smart_pointer
{
template<class T>
class auto_ptr
{
public:
auto_ptr(T* ptr)
:_ptr(ptr)
{}
auto_ptr(auto_ptr<T>& sp)
:_ptr(sp._ptr)
{
// 管理权转移
sp._ptr = nullptr;
}
auto_ptr<T>& operator=(auto_ptr<T>& ap)
{
// 是否自己给自己赋值
if (this != &ap)
{
// 释放当前对象中资源
if (_ptr)
{
delete _ptr;
_ptr = nullptr;
}
// 转移ap中资源到当前对象中
_ptr = ap._ptr;
ap._ptr = nullptr;
}
return *this;
}
~auto_ptr()
{
if (_ptr)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
_ptr = nullptr;
}
}
// 像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
}
|
可能会引发空指针错误:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// main.cpp
int main()
{
std::auto_ptr<int> sp1(new int());
std::auto_ptr<int> sp2(sp1); // 管理权转移
// sp1悬空,不能再赋值了
//*sp1 = 10;
*sp2 = 10;
cout << *sp2 << endl;
cout << *sp1 << endl;
return 0;
}
|
unique_ptr
用了简单粗暴的方法:防止拷贝。推荐使用,但是如果存在需要拷贝的场景,那么就没法使用了。
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
|
namespace my_smart_pointer
{
template<class T>
class unique_ptr
{
public:
unique_ptr(T* ptr)
:_ptr(ptr)
{}
~unique_ptr()
{
if (_ptr)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
_ptr = nullptr;
}
}
// 像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
unique_ptr(const unique_ptr<T>& sp) = delete;
unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;
private:
T* _ptr;
};
}
|
shared_ptr
通过引用计数的方式实现多个 shared_ptr 对象之间共享资源。
shared_ptr 的实现原理
四个要点:
shared_ptr 在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享
- 在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减1
- 如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源
- 如果引用计数不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了
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
|
namespace my_smart_pointer
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pRefCount(new int(1))
{}
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pRefCount(sp._pRefCount)
{
++(*_pRefCount);
}
~shared_ptr()
{
Release();
}
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
//if (this != &sp)
if (_ptr != sp._ptr)
{
Release(); // 先释放自己先前在管理着的资源
_ptr = sp._ptr;
_pRefCount = sp._pRefCount;
++(*_pRefCount);
}
return *this;
}
void Release()
{
if (--(*_pRefCount) == 0 && _ptr)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
_ptr = nullptr;
delete _pRefCount;
_pRefCount = nullptr;
}
}
// 像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
int* _pRefCount;
};
}
|
shared_ptr在拷贝赋值时的线程安全问题
因为其引用计数在堆上,如果存在两个线程t1和t2,其中的每个线程中各有一个智能指针同时拷贝赋值了同一个智能指针sp。可能会存在两个线程同时增加/减少引用计数,导致结果不对的情况。也就是下面这样子:
我们从两个方面来考虑 shared_ptr 的线程安全问题:
- 智能指针对象中引用计数是多个智能指针对象共享的,两个线程中智能指针的引用计数同时
++ 或 --,这个操作不是原子的,引用计数原来是1,++ 了两次,可能还是2,这样引用计数就错乱了,会导致资源未释放或者程序崩溃的问题。所以智能指针中引用计数 ++ 、-- 是需要加锁的,也就是说引用计数的操作是线程安全的
- 智能指针管理的对象存放在堆上,两个线程中同时去访问,会导致线程安全问题
改写后的代码是这样的:
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
|
namespace my_smart_pointer
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pRefCount(new int(1))
, _pmtx(new mutex)
{}
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pRefCount(sp._pRefCount)
, _pmtx(sp._pmtx)
{
AddRef();
}
void Release() // 减计数需要锁保护
{
_pmtx->lock();
bool flag = false;
if (--(*_pRefCount) == 0 && _ptr)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
_ptr = nullptr;
delete _pRefCount;
_pRefCount = nullptr;
flag = true;
}
_pmtx->unlock();
if (flag == true)
{
delete _pmtx;
_pmtx = nullptr;
}
}
void AddRef() // 加计数也需要锁保护
{
_pmtx->lock();
++(*_pRefCount);
_pmtx->unlock();
}
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
//if (this != &sp)
if (_ptr != sp._ptr)
{
Release();
_ptr = sp._ptr;
_pRefCount = sp._pRefCount;
_pmtx = sp._pmtx;
AddRef();
}
return *this;
}
int use_count()
{
return *_pRefCount;
}
~shared_ptr()
{
Release();
}
// 像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T* get() const
{
return _ptr;
}
private:
T* _ptr;
int* _pRefCount;
mutex* _pmtx;
};
}
|
C++库里的智能指针实现考虑了相关问题,是线程安全的。
shared_ptr 的缺陷:循环引用
什么是“循环引用”?
先来看一个场景:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
struct ListNode
{
int _data;
shared_ptr<ListNode> _prev;
shared_ptr<ListNode> _next;
~ListNode(){ cout << "~ListNode()" << endl; }
};
int main()
{
shared_ptr<ListNode> node1(new ListNode);
shared_ptr<ListNode> node2(new ListNode);
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
// 循环引用,导致资源无法被释放
node1->_next = node2;
node2->_prev = node1;
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
return 0;
}
|
在这个场景中,出现了这样的情况:
-
node1 和 node2 两个智能指针对象指向两个节点,引用计数变成1,我们不需要手动 delete
-
node1 的 _next 指向 node2,node2 的_prev指向 node1 ,两者的引用计数均变成2
-
当 node1 和 node2 析构时,引用计数均减到1,但是它们的 _next 还指向下一个节点,_prev 还指向上一个节点。也就是说 _next 析构了, node2 就释放了;_prev 析构了,node1 就释放了
-
但是 _next属于 node的成员,node1 释放了,_next 才会析构,而 node1 由 _prev 管理,_prev 属于 node2 成员,这就叫 循环引用,最终导致谁也不会被释放。是一个类似于“鸡生蛋还是蛋生鸡”的问题。
如何解决:weak_ptr
严格来说,weak_ptr 不是智能指针,因为它并没有依托RAII的思想,它只是具有指针的行为。它不负责释放资源,只是为了专门解决 shared_ptr 的循环引用问题而设计的。
结合前面的 shared_ptr 的实现,一个简化版的 weak_ptr 是这样的(C++库里的会复杂很多):
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
|
namespace my_smart_pointer
{
template<class T>
class weak_ptr
{
public:
weak_ptr()
:_ptr(nullptr)
{}
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp.get())
{}
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp.get();
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
}
|
于是,前面的场景例子中的代码可以改为使用 weak_ptr ,这样一来,node1->_next = node2; 和 node2->_prev = node1; 时 weak_ptr 的 _next 和 _prev 不会增加 node1和 node2 的引用计数,就避开了循环引用的问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
struct ListNode
{
int _data;
weak_ptr<ListNode> _prev; // 此处的 shared_ptr 改为 weak_ptr
weak_ptr<ListNode> _next;
~ListNode(){ cout << "~ListNode()" << endl; }
};
int main()
{
shared_ptr<ListNode> node1(new ListNode);
shared_ptr<ListNode> node2(new ListNode);
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
node1->_next = node2;
node2->_prev = node1;
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
return 0;
}
|
智能指针的发展历史
C++没有GC(垃圾回收机制),申请资源需要释放是一个问题,尤其是碰到异常安全的问题时,很难处理,稍不注意就会出现内存泄漏。内存泄漏会导致程序可用的内存越来越少,但是程序中很多的操作都是需要内存的,所以要尽量杜绝内存泄漏问题。
所以基于RAII的思想,引入了智能指针。
阶段一:
在C++98中首次推出 auto_ptr,但它的设计存在重大缺陷,不建议使用。
阶段二:
C++官方在接下来的十几年中没有作为,于是一帮大佬搞了一个非官方社区,写了一个叫做 boost 的库(包含很多内容)。在 boost 库中, 他们重写了智能指针:
scoped_ptr / scoped_array (防拷贝版本)
shared_ptr / shared_array (引用计数版本)
weak_ptr (解决循环引用问题)
阶段三:
C++11中引入了智能指针,参考了 boost 的实现,稍微改了一下。C++11中的“右值引用”、“移动语句”等也都参考了 boost 库。
定制删除器
C++官方库并未参考“boost”库中 scoped_array 和 shared_array 的实现,那么它是怎么处理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
|
// 仿函数的删除器
template<class T>
struct FreeFunc
{
void operator()(T* ptr)
{
cout << "free:" << ptr << endl;
free(ptr);
}
};
template<class T>
struct DeleteArrayFunc
{
void operator()(T* ptr)
{
cout << "delete[]" << ptr << endl;
delete[] ptr;
}
};
int main()
{
FreeFunc<int> freeFunc;
std::shared_ptr<int> sp1((int*)malloc(4), freeFunc);
DeleteArrayFunc<int> deleteArrayFunc;
std::shared_ptr<int> sp2((int*)malloc(4), deleteArrayFunc);
std::shared_ptr<A> sp4(new A[10], [](A* p){delete[] p; });
std::shared_ptr<FILE> sp5(fopen("test.txt", "w"), [](FILE* p)
{
fclose(p);
});
return 0;
}
|
本质上,就是传一个实现释放方式的仿函数对象给智能指针。因为C++库里的智能指针默认的释放资源方式是单个对象的 delete,那么对于不是单个对象delete的情况,我们通过写仿函数的方式都可以分别应对实现。
知识补充一:RAII思想的应用 - 锁守卫
抛异常不仅可能引起内存泄漏的问题,还可能引起死锁问题,比如下面的场景:
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
|
#include <mutex>
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("Error: Divident is 0");
return a / b;
}
void func()
{
mutex mtx;
mtx.lock();
cout << div() << endl; // 如果div抛异常,会导致下面的unlock不执行(死锁)
mtx.unlock();
}
int main()
{
// 捕获异常
try
{
func();
}
catch(exception& e)
{
cout << e.what() << endl;
}
return 0;
}
|
用RAII思想,将加锁和解锁放在一个类的构造函数中,即可解决上面的问题:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
template<class Lock>
class LockGuard
{
public:
LockGuard(Lock& lock)
:_lock(lock)
{
_lock.lock(); // 将加锁放在构造函数中
}
~LockGuard()
{
cout << "解锁" << endl;
_lock.unlock(); // 将解锁放在析构函数中
}
// 不允许拷贝,不允许赋值
LockGuard(LockGuard<Lock>&) = delete;
LockGuard<Lock>& operator=(LockGuard<Lock>&) = delete;
private:
Lock& _lock; // 锁不允许拷贝,这是成员变量设为引用的一个例子
// 这样也可以确保程序所用的是同一把锁
}
|
将 LockGuard 类用于 func 函数中:
1
2
3
4
5
6
7
|
void func()
{
mutex mtx;
LockGuard<mutex> lock_guard(mtx); // 使用LockGuard管理加锁和解锁
cout << div() << endl;
}
|
知识补充二:内存泄漏
什么是内存泄漏?
内存泄漏指因为疏忽、错误或者异常安全等原因,造成程序未能释放已经不再使用的内存/资源的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
如果进程正常结束,那么这个内存也会释放。所以一般程序碰到内存泄漏,重启后就可以解决。
内存泄漏的危害
长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务之类的不能随便重启的程序,出现内存泄漏会导致响应越来越慢、最终卡死,或者很多服务操作失败(如容器存数据、打开文件、创建套接字、发送数据等都需要内存)。
如何避免造成内存泄漏问题
- 写C/ C++代码时小心谨慎
- 不容易处理的地方多使用智能指针去管理(事前预防)
- 如果怀疑已经存在内存泄漏,可以使用内存泄漏工具去检测(事后解决)
- 工具推荐:Valgrind(是Linux下的强大工具)