文章

详解C++三类智能指针基础

通过内存泄漏的危害及其产生方式引出智能指针,详细介绍C++11引入的std::unique_ptr、std::shared_ptr与std::weak_ptr三类智能指针的特性与基本使用

详解C++三类智能指针基础

一、引入智能指针

由于C++没有隔壁C#的垃圾回收机制GC,我们常需手动清理动态分配内存的堆区对象, 而C++11引入的智能指针可以自动完成垃圾清理工作,即自动销毁动态分配内存的对象

1.1 内存泄漏

  • 内存泄漏(Memory Leak)指程序在用newmalloc等方式动态分配内存后未能正确释放,导致这些内存无法被回收和重用,这会逐渐耗尽系统内存资源,从而导致程序性能下降乃至最终崩溃,这在如服务器应用等长期运行的程序中尤其危险

1.2 泄露原因

1.2.1 忘记释放

  • 程序员动态分配了内存却未调用deletefree等释放内存,是常见的内存泄漏原因
1
2
3
4
5
6
void example() 
{
	//开辟内存在堆区
    int* ptr = new int(42);
    //忘记调用delete ptr,导致内存泄漏
}

1.2.2 指针丢失

  • 在开辟了内存到某个指针上后,在释放内存前就将该指针指向了其他方向而未进行备份的话,就会导致丢失原堆区内存地址,从而无法进行内存释放
1
2
3
4
5
6
7
8
9
void Example() 
{
	//开辟内存在堆区
    int* ptr = new int(42);
    //指向原内存的指针被覆盖
    ptr = new int(43);
    //释放新分配的内存,而原来的内存无法被释放,导致内存泄漏
    delete ptr; 
}

1.2.3 循环引用

  • 当两个或多个对象间发生了循环引用时,可能导致它们均无法被正确释放,导致内存泄漏
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>

//前向声明,以便A中使用B类型作为成员
class B;
 
class A
{
public:
    //A对象持有B类型指针
    B* b;

    //析构函数,本应用于释放内存,此处仅进行输出检测是否会被调用
    ~A() { std::cout << "A Destroyed\n"; }
};
 
class B 
{
public:
    //B对象持有A类型指针
    A* a;

    ~B() { std::cout << "B Destroyed\n"; }
};
 
int main() 
{
    //开辟对象在堆区
    A* _a = new A();
    B* _b = new B();

    //_a持有_b
    _a->b = _b;
    //_b持有_a,形成循环引用
    _b->a = _a;

    //没有任何输出信息,说明程序结束时_a和_b都未被释放,导致内存泄漏
}

1.2.4 异常中断

  • 下述例程中,如果我们输入了$0$作为分母则会触发异常中断程序,在这种特殊情况下会导致Func函数末尾的delete操作并未被执行,从而导致内存泄漏
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
#include <iostream>

class Test
{
private:
	int data;

public:
	//默认无参构造函数与有参构造函数
	Test() : data(0) {};
	Test(int _data) : data(_data) {};

	//析构函数
	~Test() { std::cout << "Test Entity " << data << " Destructed\n"; }
};

int Div()
{
	int a, b;
	std::cin >> a >> b;
	if (b == 0)
		throw std::invalid_argument("Invalid Operation: Divide 0");
	return a / b;
}

void Func()
{
	//开辟在堆区的两个数组,分别使用默认构造函数和有参构造函数
	Test* p1 = new Test[2]();
	Test* p2 = new Test[2]{ Test(55), Test(66) };
	//开辟在堆区的一个使用默认构造函数的对象
	Test* p3 = new Test();

	std::cout << Div() << "\n";

	//释放内存
	delete[] p1;
	delete[] p2;
	delete p3;
}

int main()
{
	//捕获异常
	try
	{
		Func();
	}
	catch (std::exception& e)
	{
		std::cout << e.what() << "\n";
	}
}
//100
//25
//4
//Test Entity 0 Destructed
//Test Entity 0 Destructed
//Test Entity 66 Destructed
//Test Entity 55 Destructed
//Test Entity 0 Destructed

//100
//0
//Invalid Operation: Divide 0

1.3 智能指针

  • 除了结合良好习惯和工具检测进行手动管理外,还可用智能指针来预防上述内存泄漏问题,注意需要引入头文件<memory>
    • std::unique_ptr:独占所有权,超出作用域时自动释放内存
    • std::shared_ptr:共享所有权,引用计数为零时自动释放内存
    • std::weak_ptr:解决std::shared_ptr的循环引用问题
  • 以前文的异常中断例程为例,若我们使用std::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
50
51
52
53
54
55
56
57
58
59
#include <iostream>
//支持智能指针
#include <memory>

class Test
{
private:
	int data;

public:
	//默认无参构造函数与有参构造函数
	Test() : data(0) {};
	Test(int _data) : data(_data) {};

	//析构函数
	~Test() { std::cout << "Test Entity " << data << " Destructed\n"; }
};

int Div()
{
	int a, b;
	std::cin >> a >> b;
	if (b == 0)
		throw std::invalid_argument("Invalid Operation: Divide 0");
	return a / b;
}

void Func()
{
	//Test* p1 = new Test[2]();
	std::unique_ptr<Test[]> p1(new Test[2]());
	//Test* p2 = new Test[2]{ Test(55), Test(66) };
	std::unique_ptr<Test[]> p2(new Test[2]{ Test(55), Test(66) });
	//Test* p3 = new Test();
	std::unique_ptr<Test> p3(new Test());

	std::cout << Div() << "\n";
}

int main()
{
	//捕获异常
	try
	{
		Func();
	}
	catch (std::exception& e)
	{
		std::cout << e.what() << "\n";
	}
}
//100
//4
//25
//Test Entity 0 Destructed
//Test Entity 66 Destructed
//Test Entity 55 Destructed
//Test Entity 0 Destructed
//Test Entity 0 Destructed

1.4 RAll思想

  • 资源获取即初始化(RAII, Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(例如内存、文件句柄、互斥量等)的技术
    • 在获取资源后初始化一个对象,将资源托管给该对象,控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源,无需我们显式释放资源
  • 我们基于RAII思想设计一个简易的智能指针,并在前文中的例程上测试
    • 可以看到即便触发了异常,内存依然被正确释放了,这是因为程序终止后栈区的SmartPtr类对象会被释放(继而带动资源的内存释放),而无需依赖函数体内书写的delete语句释放内存,也就不会因为异常终止导致内存泄漏了
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
#include <iostream>

//实现一个最简易的智能指针
template<class T>
class SmartPtr
{
private:
	T* ptr;

public:
	//构造函数,将接收的资源挂载到内部指针上
	SmartPtr(T* _ptr = nullptr) : ptr(_ptr) {}
	
    //析构函数,对象析构时自动释放所管理的资源
	~SmartPtr()
	{
		if (ptr)
		{
			delete ptr;
			ptr = nullptr;
		}

		std::cout << "SmartPtr Deleted" << "\n";
	}
};

class Test
{
private:
	int data;

public:
	//默认无参构造函数与有参构造函数
	Test() : data(0) {};
	Test(int _data) : data(_data) {};

	//析构函数
	~Test() { std::cout << "Test Entity " << data << " Destructed\n"; }
};

int Div()
{
	int a, b;
	std::cin >> a >> b;
	if (b == 0)
		throw std::invalid_argument("Invalid Operation: Divide 0");
	return a / b;
}

void Func()
{
	//Test* p3 = new Test();
	SmartPtr<Test> p(new Test());

	std::cout << Div() << "\n";
}

int main()
{
	//捕获异常
	try
	{
		Func();
	}
	catch (std::exception& e)
	{
		std::cout << e.what() << "\n";
	}
}
//100
//2
//50
//Test Entity 0 Destructed
//SmartPtr Deleted

//100
//0
//Test Entity 0 Destructed
//SmartPtr Deleted
//Invalid Operation: Divide 0
  • 但此处的SmartPtr还不能完全称为智能指针,因其还不具备指针的行为,即可通过->访问所指向空间内容、且可通过*解引用,为此我们还需重载*->运算符
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<class T>
class SmartPtr
{
private:
	T* ptr;

public:
	//构造函数,将接收的资源挂载到内部指针上
	SmartPtr(T* _ptr = nullptr) : ptr(_ptr) {}
	
    //析构函数,对象析构时自动释放所管理的资源
	~SmartPtr()
	{
		if (ptr)
		{
			delete ptr;
			ptr = nullptr;
		}

		std::cout << "SmartPtr Deleted" << "\n";
	}

	//指针解引用
	T& operator*() const
	{
		return *ptr;
	}

	//获取类型成员
	T* operator->() const
	{
		return ptr;
	}
};
  • 除此之外,若对此时的SmartPtr对象使用拷贝操作,由于我们未提供拷贝构造和拷贝赋值运算符,故会使用默认拷贝操作(浅拷贝),导致析构时对同一块内存空间多次释放从而报错
1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
	//堆区指针int
	SmartPtr<int> sp1(new int());
	
	//使用SmartPtr的默认拷贝构造函数(浅拷贝)
	SmartPtr<int> sp2(sp1);
	
	//使用SmartPtr的默认拷贝赋值运算符(浅拷贝)
	SmartPtr<int> sp3();
	sp3
}
  • 所以我们还需要重写SmartPtr类的两个拷贝函数,实现深拷贝

二、智能指针详解

C++11引入的<memory>库中的智能指针封装了原始指针,可自动管理和释放所指向的对象,减少手动内存管理带来的复杂性和错误风险

2.1 std::unique_ptr

2.1.1 类型特性

  • 独占所有权(给定资源只允许存在唯一std::unique_ptr指向它,避免多个指针指向同一块内存而可能导致的多次释放内存),即不允许拷贝构造,但支持移动语义转移所有权
  • 具备RAII特性,当std::unique_ptr智能指针对象的作用域结束时,其自动析构并释放其指向对象的内存

2.1.2 创建方式

  • 可通过std::unique_ptr<T> ptrName(new T(...))创建堆区动态对象,并交由动态指针接管
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
//支持智能指针
#include <memory>
 
int main()
{
	//创建一个指向整数10的std::unique_ptr
	std::unique_ptr<int> ptr(new int(111));
	std::cout << "Value=" << *ptr << "\n";
	//Value=111

	//作用域结束时,ptr对象自动析构,释放整型数据的内存
}
  • 在C++14以后还可使用std::make_unique<T>创建std::unique_ptr<T>,其由于避免了new关键字的使用而更为安全
1
2
3
4
5
6
7
8
9
#include <iostream>
#include <memory>
 
int main() 
{
    std::unique_ptr<int> ptr = std::make_unique<int>(2727);
	std::cout << "Value=" << *ptr << "\n";
	//Value=2727
}
  • 可通过std::unique_ptr<iT[]>创建并管理类型T的动态数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <memory>
 
int main()
{
	//创建一个指向包含5个整数的数组的std::unique_ptr
	std::unique_ptr<int[]> arr(new int[5]);
	//为数组元素逐个赋值
	for (int i = 0; i < 5; ++i)
		arr[i] = i * 10;

	//输出数组元素
	for (int i = 0; i < 5; ++i)
		std::cout << arr[i] << "\n";
	
	//arr超出作用域时自动释放内存
}
//0
//10
//20
//30
//40

2.1.3 所有权独占与转移

  • 试图调用std::unique_ptr的拷贝操作时会报错提示拷贝相关函数已被删除
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <memory>
 
int main() 
{
    std::unique_ptr<int> ptr = std::make_unique<int>(2727);
	std::cout << "Value=" << *ptr << "\n";
	//Value=2727

	//试图调用拷贝构造,运行报错
	//use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = int; _Dp = std::default_delete<int>]'
	std::unique_ptr<int> copy = ptr;
}
  • 智能指针std::unique_ptr不允许被拷贝,但可以通过std::move转移所有权
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <memory>
 
int main() 
{
	//创建一个指向整数111的std::unique_ptr
	auto ptr1 = std::make_unique<int>(111);

	//转移所有权给ptr2
	std::unique_ptr<int> ptr2 = std::move(ptr1);
	//ptr1现在为空
	if (!ptr1)
		std::cout << "Pointer ptr1 Is Empty" << "\n";
	std::cout << "Value=" << *ptr2 << "\n";

	//作用域结束,ptr2自动释放内存
}
//Pointer ptr1 Is Empty
//Value=111

2.2 std::shared_ptr

2.2.1 类型特性

  • 共享所有权,允许多个指针拥有同一个对象
  • 具备RAII特性,且通过检测引用计数释放内存,当指向某对象的最后一个std::shared_ptr销毁时,被指对象的内存被自动释放

2.2.2 创建方式

  • 可以通过构造函数或std::make_shared<T>创建shared_ptr<T>实例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <memory>
 
int main() 
{
    //使用构造函数创建std::shared_ptr
    std::shared_ptr<int> ptr1(new int(111));
    std::cout << "Value=" << *ptr1 << "\n";
	//Value=111

	//使用std::make_shared创建std::shared_ptr
	std::shared_ptr<int> ptr2 = std::make_shared<int>(222);
	std::cout << "Value=" << *ptr2 << "\n";
	//Value=222
}

2.2.3 所有权共享

  • 多个std::shared_ptr<T>可以通过=指向同一个堆区资源,用其中任一指针的use_count()方法可查询其所指资源上挂载的指针数量,即引用计数
  • 当最后一个std::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
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <memory>

class Test
{
public:
	int data;

public:
	//默认无参构造函数与有参构造函数
	Test() : data(0) {};
	Test(int _data) : data(_data) {};

	//析构函数
	~Test() { std::cout << "Test Entity " << data << " Destructed\n"; }
};

int main()
{
	//创建Test*类型的堆区指针,传给智能指针ptr1
	std::shared_ptr<Test> ptr1 = std::make_shared<Test>(111);
 
    //创建ptr2共享ptr1所指向的对象
    std::shared_ptr<Test> ptr2 = ptr1;

	std::cout << "ptr1=" << ptr1->data << "\n";
	//ptr1=30
	std::cout << "ptr2=" << ptr2->data << "\n";
	//ptr2=30

    //查询引用计数
	std::cout << "Use Count=" << ptr1.use_count() << "\n";
	//Use Count=2

	//Test Entity 111 Destructed
}

2.2.4 手动销毁

  • 可通过reset()函数手动销毁std::shared_ptr智能指针对象,使得其所指的对象的引用计数减一(并非直接销毁所指对象,除非被销毁的智能指针是最后一个)
1
2
std::shared_ptr<T> ptr(new T());
ptr.reset();

2.2.5 循环引用

  • std::shared_ptr可能导致循环引用而造成内存泄漏,如下例程就不会输出"~Node"字符串
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
#include <iostream>
#include <memory>

struct Node
{
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;
    
    ~Node()
    {
        std::cout << "~Node\n";
    }
};

int main()
{
	//指向Node类型的智能指针,Node内又嵌套智能指针
	std::shared_ptr<Node> n1(new Node);
    std::shared_ptr<Node> n2(new Node);

	//n1->n2
	n1->next = n2;
	//n1<-n2
	n2->prev = n1;
	
	//n1与n2所指Node对象内部循环引用,导致作用域结束时两指针都无法销毁,两个堆区Node对象的内存也就无法被释放
}
  • 上例中的两个std::shared_ptr<Node>智能指针指向的Node*指针的内部成员(也是同类型智能指针)间产生了循环引用,外部作用域结束后两个智能指针销毁,但内部两个智能指针仍因对方未销毁(引用计数不为$0$)而无法销毁,导致了内存泄漏

shared_ptr循环引用示意.png

  • 可以使用std::weak_ptr打破循环引用,详见后文

2.3 std::weak_ptr

2.3.1 类型特性

  • std::weak_ptr可指向一个由std::shared_ptr指向的对象而不影响其生命周期(即不增加引用计数),用于在某些场合替换std::shared_ptr以解决其循环引用导致的内存泄漏
  • 其诞生目的就是为了辅助解决std::shared_ptr的循环引用问题,其本身不具备RAII特性(即其自身并不直接管理对象,其销毁与否不影响其间接管理的对象的内存释放与否),故其只能从一个std::shared_ptr或另一个已存在的std::weak_ptr对象构造

2.3.2 打破循环引用

  • 前文的std::shared_ptr循环引用例程中,Node结构体内的两个std::shared_ptr<Node>智能指针成员是导致引用计数永远不为$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
#include <iostream>
#include <memory>

struct Node
{
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;
    
    ~Node()
    {
        std::cout << "~Node\n";
    }
};

int main()
{
	//指向Node类型的智能指针,Node内又嵌套智能指针
	std::shared_ptr<Node> n1(new Node);
    std::shared_ptr<Node> n2(new Node);

	//n1->n2
	n1->next = n2;
	//n1<-n2
	n2->prev = n1;
	
	//n1与n2所指Node对象内部循环引用,导致作用域结束时两指针都无法销毁,两个堆区Node对象的内存也就无法被释放
}
  • 解决该问题的方法之一即使用std::weak_ptr代替Node任一方向上的std::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
24
25
26
#include <iostream>
#include <memory>

struct Node
{
    //std::shared_ptr<Node> next;
	//std::shared_ptr<Node> prev;
	std::weak_ptr<Node> next;
    std::weak_ptr<Node> prev;

    ~Node()
    {
        std::cout << "~Node\n";
    }
};

int main()
{
	std::shared_ptr<Node> p1(new Node);
    std::shared_ptr<Node> p2(new Node);

	p1->next = p2;
	p2->prev = p1;
}
//~Node
//~Node

2.3.3 访问引用对象

  • std::weak_ptr不能直接访问其所指对象,必须通过lock()将其转为std::shared_ptr类型
    • 转换时若发现其所指对象已被销毁,则lock()返回空指针
    • 转换过后其所指对象引用计数加一,若此时原std::shared_ptr被销毁,则其所指对象的生命期将被延长至该新转换的std::shared_ptr同样被销毁为止
  • 可以使用std::weak_ptr的成员函数expired()检测其所观察的对象是否已释放,即检测其关联的std::shared_ptr所指对象的引用计数是否为$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
#include <iostream>
#include <memory>
 
int main()
{
	//一个std::shared_ptr智能指针,维护一个堆区int数据,数据引用计数为1
	std::shared_ptr<int> sharedPtr = std::make_shared<int>(100);
	//一个std::weak_ptr智能指针,指向上述数据,数据引用计数此时仍为1
	std::weak_ptr<int> weakPtr = sharedPtr;

	//将std::weak_ptr转换为std::shared_ptr,并检测是否成功
	std::shared_ptr<int> promoted = weakPtr.lock();
	if (promoted)
        std::cout << "Value=" << *promoted << "\n";
	else
		std::cout << "Pointer Is Expired When Trying Convert\n";

	//手动释放sharedPtr,减少引用计数
	sharedPtr.reset();
	//expired函数检查std::weak_ptr观察的对象是否已释放(即关联的std::shared_ptr所指对象的引用计数是否为0)
	if (weakPtr.expired())
		std::cout << "Flag1\n";

	promoted.reset();
	if (weakPtr.expired())
		std::cout << "Flag2\n";

}
//Value=100
//Flag2
本文由作者按照 CC BY-NC-SA 4.0 进行授权