叶落雨清寒的博客

临渊羡鱼,不如退而结网。

资源管理

前言

在熟知了C++智能指针、拷贝与移动、高级面向对象系列知识后,我们也引出了本系列课程最核心的现代C++编程范式,也就是垃圾回收(Garbage Collection)的话题。

说到垃圾回收(GC)的话题,不免让人联想到Java的YoungGC、FullGC,或者Python自动引用计数的内存回收机制。但严格的讲,Java、 Python的内存管理的都是全自动化的,C++所实现RAII并非是此类的内存回收机制,这也是社区对C++较为抨击的一点。但笔者看来,C++的RAII-资源获取即初始化,不得不说,是C++兼容C的生态大体系的下,相对不那么激进的解决方案了。我们知道,即便是在C语言中,局部变量/栈上申请的内存由系统负责回收,C++正式基于这个机制,扩充衍生了我们前期讲到的拷贝与移动、析构与构造的相关机制,从而一方面实现了对C的兼容,另一方面也是在此理论框架下相对较好的内存管理机制了。

下面的课程,将围绕RAII的本质展开,逐步阐述C++的垃圾回收机制,并引出我们现代C++系列课程的最终目标——全面抛弃裸指针。

RAII的本质

不得不说,RAII的全称着实是有点蛋疼,笔者认为“资源获取即初始化”并不能很好的解释,RAII为垃圾回收带来的怎样的便利性。下面将从逆向的角度来阐述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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <iostream>
#include <memory>

class Foo {
public:
Foo() {
std::cout << "Foo construct." << std::endl;
}

Foo(const std::string &data) : m_str(data) {
std::cout << "Foo(" << m_str << ") construct." << std::endl;
}

void DoSomething() {
std::cout << "Foo(" << m_str << ") DoSomething." << std::endl;
}

~Foo() {
std::cout << "Foo(" << m_str << ") desctruct." << std::endl;
}

Foo(const Foo &other) {
std::cout << "Foo construct by lvalue." << std::endl;
m_str = other.m_str;
}

Foo(Foo &&other) {
std::cout << "Foo construct by rvalue." << std::endl;
m_str = std::move(other.m_str);
}

Foo &operator=(const Foo &other) {
std::cout << "Foo copy assign." << std::endl;
if (this != &other) {
m_str = other.m_str;
}
return *this;
}

Foo &operator=(Foo &&other) {
std::cout << "Foo move assign." << std::endl;
if (this != &other) {
m_str = std::move(other.m_str);
}
return *this;
}

std::string m_str = "";
};

在此基础上,我们有如下main函数调用:

1
2
3
4
5
6
int main()
{
Foo foo;
foo.DoSomething();
return 0;
}
1
main();
Foo construct.
Foo() DoSomething.
Foo() desctruct.

结果当然是显而易见的,那么我们再从逆向的角度看看,编译器做了什么。

1
2
3
4
5
6
7
8
9
10
int _main() {
r31 = r31 - 0x30;
saved_fp = r29;
stack[-8] = r30;
r29 = &saved_fp;
Foo::Foo(r29 - 0x5);
Foo::DoSomething(r29 - 0x5);
Foo::~Foo(r29 - 0x5);
return 0x0;
}

结合我们上一轮《高级面向对象》课程的阐述,很显然,这里依次进行了Foo的构造,DoSomething的调用,然后是Foo的析构。系统对Foo的所在内存的生命周期进行了全面接管,并为我们隐式调用了Foo的构造和析构,构造与析构的时机与变量的生命周期/作用域一致。

当然,到此为止,RAII的效果并不太明显,我们看一个相对复杂点的分支。testRAII函数拥有多个return返回点:

1
2
3
4
5
6
7
8
9
10
11
12
int testRAII(int i)
{
Foo foo;
if (i > 0) {
foo.DoSomething();
return i;
}
if (i < -2) {
return -2;
}
return -1;
}

对应逆向代码:

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 __Z8testRAIIi(int arg0) {
r31 = r31 - 0x30;
saved_fp = r29;
stack[-8] = r30;
r29 = &saved_fp;
var_8 = arg0;
Foo::Foo(r29 - 0x9);
r8 = var_8;
if (r8 <= 0x0) {
if (CPU_FLAGS & LE) {
r8 = 0x1;
}
}
if ((r8 & 0x1) == 0x0) {
Foo::DoSomething(r29 - 0x9);
var_4 = var_8;
}
else {
r8 = var_8 - 0xfffffffe;
if (r8 < 0x0) {
var_4 = 0xfffffffe;
}
else {
var_4 = 0xffffffff;
}
}
Foo::~Foo(r29 - 0x9);
r0 = var_4;
return r0;
}

类似的,如下更加更加复杂的案例,编译器总能保证Foo的构造、析构对应其作用域/生命周期的范围进行。

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
// 涉及异常抛出
int testRAII(int i)
{
Foo foo;
try {
if (i > 0) {
foo.DoSomething();
return i;
}
if (i < -2) {
return -2;
}
} catch (std::exception &ex) {
return -3;
}
return -1;
}

// 涉及智能指针
int testRAII(int i)
{
auto foo = std::make_shared<Foo>();
try {
if (i > 0) {
foo->DoSomething();
return i;
}
if (i < -2) {
return -2;
}
} catch (std::exception &ex) {
return -3;
}
return -1;
}

通过上述充分的案例,我们坚信,编译器对变量的作用域和生命周期的强绑定管理是可靠的、完备的,这也是C++编译器对类实现的标准。我们得出以下结论:

  • 编译器总能根据变量作用域对其构造、析构的时间点做完美的控制。

RAII的本质实际上就是针对对象生命周期管理,通过构造、析构函数对资源进行申请、释放,这个申请、释放是相当可靠的。也是C++半自动管理资源的方式。

RAII在涉及跨作用域场景的应用

结合上一节我们得出的结论,我们在实际编码过程中会发现新的问题,上述案例实际上都是对局部变量的生命周期的管理,但实际编码过程中,往往会涉及跨多作用域的对象生命周期管理。

那么这里,需要结合到我们《拷贝与移动》课程的知识,关于所有权流派的理论能很好的解释RAII如何解决跨作用域的场景。这里我们稍微回顾下之前提到的一个简单的场景:

1
2
3
4
void SomeMoveFunction(Foo &&foo) {
Foo movedFoo = foo;
std::cout << "foo move to temperary scope, and it will destruct." << std::endl;
}
1
2
3
4
// 这里foo是全局变量,本不应发生析构
Foo foo;
// 但由于std::move转移所有权
SomeMoveFunction(std::move(foo));
Foo construct.
Foo construct by lvalue.
foo move to temperary scope, and it will destruct.
Foo() desctruct.

全局变量的foo所有权转移给了SomeMoveFunction中的局部变量movedFoo,从而让原本是全局变量的Foo最终还是被析构了。这里根据上次课程的得出的结论:

  • 被move后的对象,所有权转移给了新的对象,生命周期由新的作用域来管理。

上面的例子实际上就是全局作用域转移给局部作用域,生命周期变短的例子。相反,局部作用域转移给全局作用域也是一样的遵循上面的结论。

当然,所有权转移对象时,要求编码者遵循《拷贝与移动》课程中讲到的“君子契约”,很多时候,开发者由于太懒而放弃了此场景。当然STL,也注意到了这个问题,这也是本系列课程第一章所讲到的《智能指针》的话题。

智能指针与RAII

围绕RAII这套资源管理机制,一句话概括,是基于编译器根据变量作用域精准的调用实例的构造、析构,并通过移动语义实现实例的提前或延迟析构。但要实现移动语义,通常开发者又要自行实现移动构造、移动赋值,比较冗余。因此智能指针就是为了解决此类问题。

智能指针的实现原理这里不会展开,它本质上也是基于变量的作用域的管理,对被管理的裸指针进行精准的new/delete。由于被管理的是裸指针,不会用到实例的任何移动方法,因此对对象就没有了约束。

我们看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class FooWithoutMove {
public:
FooWithoutMove() {
std::cout << "FooWithoutMove construct." << std::endl;
}

~FooWithoutMove() {
std::cout << "FooWithoutMove desctruct." << std::endl;
}

FooWithoutMove(FooWithoutMove &&other) = delete;

FooWithoutMove &operator=(FooWithoutMove &&other) = delete;
}
1
2
3
4
void SomeMoveFunction(std::unique_ptr<FooWithoutMove> &&foo) {
auto movedFoo = std::forward<std::unique_ptr<FooWithoutMove>>(foo);
std::cout << "foo move to temperary scope, and it will destruct." << std::endl;
}
1
2
std::unique_ptr<FooWithoutMove> foo(new FooWithoutMove);
SomeMoveFunction(std::move(foo));
FooWithoutMove construct.
foo move to temperary scope, and it will destruct.
FooWithoutMove desctruct.

FooWithoutMove不是一个支持move的对象,但STL的智能指针,默认实现了对裸指针的拷贝、移动相关方法,能够稳妥的实现RAII范式。

RAII在多线程加锁/解锁场景的使用

在涉及对mutex加锁/解锁时,我们的业务代码,特别是以C-Style方式写的时候,会涉及很多代码的退出点。同时每个退出点都要进行解锁,这导致在每个业务分支都有冗余的释放资源的代码。我们应该很熟悉std::lock_guard的使用,我们可以简单看看std::lock_guard是的实现,可以进行理解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
template <class _Mutex>
lock_guard
{
public:
typedef _Mutex mutex_type;

private:
mutex_type& __m_;
public:

_LIBCPP_NODISCARD_EXT _LIBCPP_INLINE_VISIBILITY
explicit lock_guard(mutex_type& __m) _LIBCPP_THREAD_SAFETY_ANNOTATION(acquire_capability(__m))
: __m_(__m)
{
// 加锁
__m_.lock();
}

_LIBCPP_INLINE_VISIBILITY
~lock_guard() _LIBCPP_THREAD_SAFETY_ANNOTATION(release_capability())
{
// 解锁
__m_.unlock();
}

private:
lock_guard(lock_guard const&) _LIBCPP_EQUAL_DELETE;
lock_guard& operator=(lock_guard const&) _LIBCPP_EQUAL_DELETE;
};

可以看到,std::lock_guard本质上也是通过lock_guard本身的作用域,精准而又不遗漏的构造、析构,来控制加锁、解锁,同时保证任何退出点,都能自动释放锁。

回到RAII概念本身

RAII——资源获取即初始化,经过上述的案例,笔者认为字面中文解释并不够完备。这里尝试给出一个较为好理解的定义:

  • RAII是通过变量的作用域控制构造、析构的时机,间接、自动的管理资源的申请和释放。

结合这个定义,我们也注意到,RAII并不是只限定于内存管理的一种范式,而是可以借鉴、复制到有典型的生命周期管理含义的业务场景中。例如,锁的控制、数据库事务的管理等等。

小结

本次课程我们从垃圾回收开始聊起,重点讲述了编译器通过变量作用域进行构造、析构的机制,借由此衍生的RAII资源管理的机制。并结合STL中智能指针、加锁/解锁的模板库,讲述了RAII的典型运用场景,进一步衍生到RAII是一类通用的资源管理机制,具备推广到业务层面的一种可靠范式。

前言

在学习一门新的编程语言时,我们总是会提到面向对象的三大要素:

  • 继承
  • 多态
  • 封装

这自然也是C++作为一门自由范式的面向对象语言,所支持的基本特性。但相较其他的编程语言,C++由于高度自由的编程范式,有了更多的复杂度和独具一格的编程用法。在食用本课程前,有如下注意事项:

  1. 还是回到我们《现代C++》系列课程所建议,请抛弃所有传统C的编程思维,以全新的编程语言认识C++。
  2. 虽说抛弃C,我们可以站在C++的视角去看C,以理解后续的部分逆向代码。
  3. 本文不会赘述,大家常见面向对象的基本概念,主要从应用场景展开。

面向对象的机制

我们常说,什么是C++大佬,通常是人肉编译器,一眼能看到汇编。这里准备通过逆向的角度,给大家讲述C++面向对象的本质。例如我们有如下测试类:

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

class Parent {
public:
Parent()
{
std::cout << "Parent construct." << std::endl;
}

virtual void DoSomething()
{
std::cout << "Parent DoSomething" << std::endl;
}

virtual ~Parent()
{
std::cout << "Parent destruct." << std::endl;
}
};

同时调用方代码:

1
2
3
4
5
6
7
int main()
{
Parent parent;
parent.DoSomething();
return 0;
}
main();
Parent construct.
Parent DoSomething
Parent destruct.

而在逆向视角来看,上述代码实际上生成了Parent构造、析构以及DoSomething这三个函数。(Clang14编译器+禁用优化)

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
// 对应构造函数
int __ZN6ParentC2Ev(int arg0) {
*arg0 = *qword_100004018 + 0x10;
std::__1::basic_ostream<char, std::__1::char_traits<char> >::operator<<(std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::operator<< <std::__1::char_traits<char> >(*0x100004000, "Parent construct."), *std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::endl<char, std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&));
r0 = arg0;
return r0;
}

// 对应DoSomething方法
int __ZN6Parent11DoSomethingEv(int arg0) {
r0 = *0x100004000;
r0 = std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::operator<< <std::__1::char_traits<char> >(r0, "Parent DoSomething");
r0 = std::__1::basic_ostream<char, std::__1::char_traits<char> >::operator<<(r0, *std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::endl<char, std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&));
return r0;
}

// 对应析构函数
int __ZN6ParentD2Ev(int arg0) {
r31 = r31 - 0x30;
saved_fp = r29;
stack[-8] = r30;
*arg0 = *qword_100004018 + 0x10;
var_10 = arg0;
var_18 = std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::operator<< <std::__1::char_traits<char> >(*0x100004000, "Parent destruct.");
std::__1::basic_ostream<char, std::__1::char_traits<char> >::operator<<(var_18, *std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::endl<char, std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&));
r0 = var_10;
return r0;
}

当然,从汇编逆向而来生成的伪代码,具有较差的可读性。但也足够我们理解面向对象的机制。从上述伪代码,我们可以得出如下的猜测或者说结论:

  1. arg0是成员变量结构体指针
  2. 类的构造、构造、成员方法,本质是构造、销毁及关联这个指针的静态方法。

那么这个猜测,我们可以从调用方的逆向代码里得到印证:

1
2
3
4
5
6
7
8
9
10
int _main() {
r31 = r31 - 0x40;
saved_fp = r29;
stack[-8] = r30;
r29 = &saved_fp;
Parent::Parent(r29 - 0x10);
Parent::DoSomething(r29 - 0x10);
Parent::~Parent(r29 - 0x10);
return 0x0;
}

如上是调用方的逆向代码,我们可以合理推定,r29 - 0x10就是我们提到的成员变量结构体指针。

继承的机制

类似的,我们也通过逆向角度,适当了解下C++的继承干了什么事情。假定如下的Child子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Child : public Parent {
public:
Child()
{
std::cout << "Child construct." << std::endl;
}

void DoSomething() override
{
std::cout << "Child DoSomething" << std::endl;
}

void DoSomethingElse()
{
std::cout << "Child DoSomethingElse" << std::endl;
}

virtual ~Child()
{
std::cout << "Child construct." << std::endl;
}
};
1
2
3
4
5
6
7
8
// 调用代码
int main2()
{
Child parent;
parent.DoSomething();
return 0;
}
main2();
Parent construct.
Child construct.
Child DoSomething
Child construct.
Parent destruct.

结果自然也非常显而易见,我们直接快进到Child子类的构造、析构的逆向代码,你会发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int __ZN5ChildC2Ev(int arg0) {
var_28 = arg0;
Parent::Parent(arg0);
*var_28 = var_20;
Parent::Parent(arg0);
// ...下面类似逻辑省略,上面多了对父类构造函数的调用
r0 = var_28;
return r0;
}


int __ZN5ChildD2Ev(int arg0) {
var_20 = arg0;
// ...上面类似逻辑省略,这里多了对父类析构函数的调用
Parent::~Parent(var_20);
r0 = var_20;
return r0;
}

因此,我们可以得出如下推论:

  1. 继承关系的类,实质上只是在父类方法组的基础上,新增了一套管理子类的方法组。构造和析构的调用顺序,刚好与继承相同(构造)和相反(析构)。
  2. 父类和继承类共用一个成员变量结构体指针,父类、继承类各取所需。

当然,C++还有很多其他更为复杂的继承关系,如多继承甚至棱形继承问题,本文不会赘述,大家结合上述从逆向视角得出的推论,大致可以了解到C++继承的本质。

虚函数与虚表

虚函数与虚表的概念,相信大家在卷C++面试的时候,已经滚瓜烂熟。这次我们适当从编译器和逆向视角,看看虚函数、虚表干了什么事情,来协助我们理解后续继承与多态的问题。

首先我们对比一个非虚类和虚类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Nonvirtaul {
public:
Nonvirtaul()
{
}

void DoSomething()
{
}

~Nonvirtaul()
{
}

std::uint64_t a;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Virtaul {
public:
Virtaul()
{
}

virtual void DoSomething()
{
}

virtual ~Virtaul()
{
}

std::uint64_t a;
};
1
2
3
4
5
6
7
int main()
{
std::cout << sizeof(Nonvirtaul) << std::endl;
std::cout << sizeof(Virtaul) << std::endl;
return 0;
}
main();
8
16

这里也自然引出了大家所熟知的虚函数与虚表的概念。拥有虚函数的类(包含析构函数),通常在实例的指针开头(这个取决于编译器),有8个字节的地址,指向本实例的虚表。值的一提的是,通常我们习惯把析构函数定义为虚函数,以避免在继承情况下的内存泄漏问题。因此,我们在涉及面向对象编程的场景,大部分都会使用到虚表。

虽然标准组织并没有约束详细的虚表的内存布局,但通常包含析构函数地址和对应父类方法为virtual的内存地址。纯虚函数的在对应类的虚表中,是没有地址的,这也是对应类无法被实例化的原因。下面举例上述Parent的继承类Child的虚表逆向后的真实案例(Clang14)。

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

__ZTV5Child: // vtable for Child
0000000100004038 db 0x00 ; '.' ; DATA XREF=__ZN5ChildC2Ev+16, __ZN5ChildD2Ev+16, qword_100004018
0000000100004039 db 0x00 ; '.' // 空8字节开通
000000010000403a db 0x00 ; '.'
000000010000403b db 0x00 ; '.'
000000010000403c db 0x00 ; '.'
000000010000403d db 0x00 ; '.'
000000010000403e db 0x00 ; '.'
000000010000403f db 0x00 ; '.'
0000000100004040 db 0x70 ; 'p' // 指向本虚表类的typeinfo
0000000100004041 db 0x40 ; '@'
0000000100004042 db 0x00 ; '.'
0000000100004043 db 0x00 ; '.'
0000000100004044 db 0x01 ; '.'
0000000100004045 db 0x00 ; '.'
0000000100004046 db 0x00 ; '.'
0000000100004047 db 0x00 ; '.'
0000000100004048 db 0x0c ; '.' // 指向Child::DoSomething()
0000000100004049 db 0x2c ; ','
000000010000404a db 0x00 ; '.'
000000010000404b db 0x00 ; '.'
000000010000404c db 0x01 ; '.'
000000010000404d db 0x00 ; '.'
000000010000404e db 0x00 ; '.'
000000010000404f db 0x00 ; '.'
0000000100004050 db 0x84 ; '.' // 指向析构函数方法
0000000100004051 db 0x2c ; ','
0000000100004052 db 0x00 ; '.'
0000000100004053 db 0x00 ; '.'
0000000100004054 db 0x01 ; '.'
0000000100004055 db 0x00 ; '.'
0000000100004056 db 0x00 ; '.'
0000000100004057 db 0x00 ; '.'
0000000100004058 db 0x08 ; '.' // 指向delete方法
0000000100004059 db 0x2f ; '/'
000000010000405a db 0x00 ; '.'
000000010000405b db 0x00 ; '.'
000000010000405c db 0x01 ; '.'
000000010000405d db 0x00 ; '.'
000000010000405e db 0x00 ; '.'
000000010000405f db 0x00 ; '.'

在Parent和Child实例被创建的时候,对应实例的虚表就通过指针的形式关联起来了,这解释了为什么,我们通过基类指针承载的继承类也能正确访问到继承类的方法并确保正常析构。

当然,这里简单阐述了虚表的实现原理,涉及多继承也有类似的结论,本处不再展开。

封装问题

C++的引用include机制,本质上是文本的拷贝,这也是C++想做出高度抽象的封装难度最大的地方。由于文本拷贝,这意味着头文件会出现指数级的传染,在没有做封装方案的代码,越是最上层,依赖的头文件越多,编译耗时越大。同时,对上层组件而言,也不希望下层组件小小的头文件改动,导致整个依赖链大范围受到影响。

这也是为什么封装问题,在C++这门语言最为迫切的地方。

C++作为一门高自由范式的编程语言,也自然支持多种的封装方案,本质上来讲,都是通过接口与实现分离,这里给出几种典型的封装方案,并非全集供大家参考。下面将以如下类的案例阐述几种典型的封装方案。

1
2
3
4
5
6
7
8
9
10
11
12
#include "SomeBigPrivateClass.h"

class Foo {
public:
Foo();
virtual ~Foo();

void DoHeavyLogic();

private:
SomeBigPrivateClass m_bigClass;
};

PImpl前置声明方案

个人认为,PImpl方案,在开源社区里,通常是用的最多的一种方案。对外的接口类非常适合用本方案封装,因为不涉及太多高阶C++特性,足够简单。主要思路是,接口类只引用Impl实现类的指针,并定义同名方法进行转发。

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
// Foo.h
class FooImpl; // 实现类的前置声明

class Foo {
public:
Foo();
virtual ~Foo();

virtual void DoHeavyLogic();

private:
FooImpl *m_impl;
};

// FooImpl.h
#include "SomeBigPrivateClass.h"

class FooImpl {
public:
FooImpl();
virtual ~FooImpl();

void DoHeavyLogic();

private:
SomeBigPrivateClass m_bigClass;
};

// Foo.cpp
#include "FooImpl.h"
Foo::Foo() : m_impl(new FooImpl) {}

void Foo::DoHeavyLogic() {
m_impl->DoHeavyLogic();
}

Foo::~Foo() {
delete m_impl;
}

PImpl封装方案,本质上是将所有内部实现下沉到Impl类中,对外的接口类通过前置声明,并对Impl实现类的方法进行转发。这个方案:

  • 优势:实现简单,给调用方适配提供类较大自由度,同时屏蔽业务细节
  • 劣势:封装方案,需要2份.h和.cpp,代码量比较多; 丢失了函数修饰符const。

这个封装方案,比较适合作为对外接口层面的封装。

纯虚类封装(依赖反转)

我们上面提到虚表和虚函数,刚好也解决了这个问题。对外暴露基类的指针,内部构造时通过实现类构造,实现依赖反转(也有称依赖倒置原则)。实现案例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Foo.h
class Foo {
public:
virtual ~Foo() = default;
virtual void DoHeavyLogic() = 0;
};

// FooImpl.h
#include "Foo.h"
#include "SomeBigPrivateClass.h"

class FooImpl : public Foo {
public:
FooImpl();
~FooImpl() override;
void DoHeavyLogic() override;
private:
SomeBigPrivateClass m_bigClass;
};

由于Foo和FooImpl之间有明确的继承关系,因此相应的指针可以支持隐式转换。构造时使用实现类指针,对外只提供基类的指针。这个方案:

  • 优势:可以不丢失const等修饰符,有接口继承关系。virtual方法容易mock,可测试性强。
  • 劣势:成员函数是virtual的,一定程度上约束了调用方的模板使用。

这个封装方案,相对更适合,模块内部,有明显的层与层之间之间解藕诉求的场景,同时也提供了最强的单元测试适配性。

鸭子类型(编译期多态)

C++由于模板技术,支持鸭子类型作为接口封装。什么叫鸭子类型?通常我们讲,包括C++以外的编程语言,一个类是否是某个基类的子类,是通过判断这个类是否是指定基类的派生类来判断的。

例如,狗狗(Dog)、猫猫(Cat)都继承于动物(Animal),狗狗和猫猫自然都拥有动物所约束的父类方法。相关处理和使用动物作为接口类的业务代码,才能正确处理相应逻辑。

而鸭子类型,有别于上述严格的继承关系,我们如何判断一个类是否是一种鸭子?很简单,这个玩意如果长得像鸭子(嘴巴、羽毛、爪子、翅膀),像鸭子一样动作(嘎嘎叫、会游泳),我们就可以认为它就是一个鸭子。而不是必须得约束,对象必须继承于鸭子。例子如下:

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
class YellowDuck {
public:
YellowDuck();
virtual ~YellowDuck();

void Shout();
void Swim();
};

class BlackDuck {
public:
BlackDuck();
virtual ~BlackDuck();

void Shout();
void Swim();
};

template <typename Duck>
class DuckAdapter {
public:
DuckAdapter(Duck duck) : m_duck(duck) {}
virtual ~DuckAdapter() = default;

void Shout() {
m_duck.Shout();
}
void Swim() {
m_duck.Swim();
}
private:
Duck m_duck;
};

通过上面的例子我们可以看到,无论是黄鸭子、黑鸭子,对于DuckAdapter而言,他认为,只要是实现了Shout和Swim方法的类,它都会认为是鸭子。这种方案:

  • 优势:调用方和被调用方可以压根连头文件都不用依赖,在装载阶段满足编译条件即可。
  • 劣势:发生在编译期,适用面相对少一些;模板问题定位难度偏大;

这个封装方案,比较适合纯编译期的特殊逻辑封装,在涉及复杂模板编程的场景较为常用。STL也广泛使用了本方案进行封装。

小结

本次课程,我们从逆向的角度,重新认识了C++面向对象。简单阐述了继承和多态,在汇编角度做的事情。在继承和多态的基础上,介绍了PImpl、虚函数封装以及鸭子类型封装,以及,这三种封装方式各自的优缺点,大家可以结合业务场景按需使用。

前言

拷贝与移动的知识,是C++有别于C-style编程范式,最核心也是最基础的知识体系。下面将通过大家“闻风丧胆”的左值和右值出发,讲述C++拷贝与移动的知识体系,并结合STL的接口设计说明如何抛弃裸指针,写出更安全,性能更强的代码。

左值与右值

标准组织对C++值类型的分类

我们似乎很难给出最好理解的左值和右值的定义,我们先简单看看标准组织给出的左值、右值分类。
标准组织将值分为以下几种类型:

分为,泛左值(glvalue)和右值(rvalue)两个大类,其中:

泛左值:包含左值(lvalue)和将亡值(xvalue)

右值:包含纯右值(prvalue)和将亡值(xvalue)

你可能已经感到晕头转向,当然本文将不会按照上述的思路来讲述在C++编程中如何处理左值和右值。不过为了对齐概念,本文提到的左值、右值分别对应标准组织定义的左值(lvalue)和纯右值(prvalue),这与大部分文档以及开源社区上的通用习语上是一致的。

我们依旧通过上次大家熟知的Foo对象作为后续课程讲述的案例:

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
#include <iostream>
#include <memory>

class Foo {
public:
Foo() {
std::cout << "Foo construct." << std::endl;
}

Foo(const std::string &data) : m_str(data) {
std::cout << "Foo(" << m_str << ") construct." << std::endl;
}

~Foo() {
std::cout << "Foo(" << m_str << ") desctruct." << std::endl;
}

Foo(const Foo &other) {
std::cout << "Foo construct by lvalue." << std::endl;
m_str = other.m_str;
}

Foo(Foo &&other) {
std::cout << "Foo construct by rvalue." << std::endl;
m_str = std::move(other.m_str);
}

Foo &operator=(const Foo &other) {
std::cout << "Foo copy assign." << std::endl;
if (this != &other) {
m_str = other.m_str;
}
return *this;
}

Foo &operator=(Foo &&other) {
std::cout << "Foo move assign." << std::endl;
if (this != &other) {
m_str = std::move(other.m_str);
}
return *this;
}

std::string m_str = "";
};

我们如何理解左值右值

“左”和“右”之分,通常理解来源于,C++中的赋值操作。例如:

1
2
3
4
int i;
i = 7;
std::string s;
s = "abc";

左边,也就是 i 和 s 所代表的两个变量,通常认为是左值;而右边 7 和 “abc” 所代表的字面量,通常被认为是右值。这合理解释了,“左”和“右”在位置上的区分。
更具体的讲,左值通常表达了一个被赋值的量,右值表达一个可以给别人赋值的量。

我们接着对两句话做进一步诠释:

  1. 左值通常表达一个被赋值的量————这表明左值在内存中有明确的地址
  2. 右值通常表达一个可以给别人赋值的量————这表明右值承担“值”的含义,它可以没有内存地址或者说内存地址并不重要

(虽然这个解释还不够完美,但请先理解到这个程度!)

正如大家所知,我们通常用&修饰左值,&&修饰右值,下面是左值、右值更广泛的用法。

  • 左值
1
2
3
4
5
6
7
8
#include <iostream>

int a = 1;
// 左值初始化(实际上是传递引用,无真实拷贝动作发生)
int &b = a;
// 我们修改a的值,左值b也会跟着发生改变。
a = 999;
std::cout << b << std::endl;
999

涉及类的场景:

1
2
3
void SomeFunctionUseLvalue(const Foo &foo) {
std::cout << "foo is lvalue." << std::endl;
}
1
2
3
void SomeFunctionUseRvalue(Foo &&foo) {
std::cout << "foo is rvalue." << std::endl;
}
1
2
3
4
// 涉及类的场景, foo1也是左值
Foo foo1{};
SomeFunctionUseLvalue(foo1);
SomeFunctionUseRvalue(Foo{});
Foo construct.
foo is lvalue.
Foo construct.
foo is rvalue.
Foo() desctruct.
  • 右值
1
2
3
4
5
6
// 右值引用通过常量整形初始化
int &&c = 1;
int a = 1;
int &b = a;
// 右值引用无法通过左值引用初始化
int &&d = b;
input_line_15:7:7: error: rvalue reference to type 'int' cannot bind to lvalue of type 'int'
int &&d = b;
      ^   ~



Interpreter Error: 
1
2
3
4
5
6
7
8
{
Foo foo1{"foo1"};
// std::move可将左值强制转换为右值,同时原对象失效(涉及编译器告警use-after-move)
Foo movedFoo1 = std::move(foo1);
// 不具名方式构造的对象默认可作为右值
Foo &&rvalueFoo = Foo{"foo2"};
Foo movedFoo2 = rvalueFoo;
}
Foo(foo1) construct.
Foo construct by rvalue.
Foo(foo2) construct.
Foo construct by lvalue.
Foo(foo2) desctruct.
Foo(foo2) desctruct.
Foo(foo1) desctruct.
Foo() desctruct.
  • 编译器支持自动识别左值和右值
1
2
3
void SomeFunction(Foo &foo) {
std::cout << "this is a lvalue." << std::endl;
}
1
2
3
void SomeFunction(Foo &&foo) {
std::cout << "this is a rvalue." << std::endl;
}
1
2
3
Foo lFoo{};
SomeFunction(lFoo);
SomeFunction(Foo());
Foo construct.
this is a lvalue.
Foo construct.
this is a rvalue.
Foo() desctruct.

拷贝和移动的本质

叠个buff防杠:

我们经常赞叹于C++对语法糖控制灵活性,以至于我们常常会被C++这种高自由度的设计语言搞的晕头转向。笔者无法承诺“拷贝与移动”这个“本质”话题能够尽善尽美的诠释清楚(拷贝和移动在开源社区里有不同的流派,各位大佬之间争论较为激烈),但尽可能将相对主流的流派(以STL为代表的所有权流派)作为推荐范式给大家学习。

C++的拷贝和移动只是形式化契约

我们知道,C++的赋值符号(=)是可重载操作符,在开发者未定义的情况下,在大部分情况下,C++的编译器会自动为类生成拷贝赋值(copy assignment)、移动赋值(move assignment)的操作符重载实现。编译器的自动赋值重载实现,有较为复杂的规则,通常不建议交给编译器自己实现(后面有单独课程讲编译器做的事情)。

因此,涉及拷贝、移动时,建议由开发者自行实现拷贝和移动赋值重载。这也导致了一些问题,比如,以Foo为例,正常的拷贝和移动实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 拷贝赋值
Foo &operator=(const Foo &other) {
std::cout << "Foo copy assign." << std::endl;
if (this != &other) {
m_str = other.m_str;
}
return *this;
}
// 移动赋值
Foo &operator=(Foo &&other) {
std::cout << "Foo move assign." << std::endl;
if (this != &other) {
m_str = std::move(other.m_str);
}
return *this;
}

但编译器对各自在拷贝和移动中做什么,完全不理会,比如你甚至可以干这些事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 把拷贝当移动
Foo &operator=(const Foo &other) {
std::cout << "Foo copy assign." << std::endl;
auto &casted = const_cast<Foo &>(other);
if (this != &other) {
m_str = std::move(casted.m_str);
}
return *this;
}
// 把移动当拷贝
Foo &operator=(Foo &&other) {
std::cout << "Foo move assign." << std::endl;
if (this != &other) {
m_str = other.m_str;
}
return *this;
}

这些会让Committer怒摔键盘的代码,编译器完全不会管,没错,就是玩!!!因此,拷贝和移动的设计,通常是在大型开发项目中,要求开发者遵循的君子契约。

所有权流派之拷贝

我们首先观察下拷贝过程发生了什么。

1
2
3
4
5
6
7
8
{
Foo foo1{"foo1"};
Foo foo2{"foo2"};
// 调用拷贝构造,与Foo copied(foo1);没有区别
Foo copied = foo1;
// 调用拷贝赋值
copied = foo2;
}
Foo(foo1) construct.
Foo(foo2) construct.
Foo construct by lvalue.
Foo copy assign.
Foo(foo2) desctruct.
Foo(foo2) desctruct.
Foo(foo1) desctruct.

我们观察调用日志,首先可以得出第一个结论:

  • 在变量初始化时拷贝,本质上调用的是拷贝构造函数。

因此我们永远推荐,拷贝构造、拷贝赋值务必成对出现。

同时,通过拷贝构造,Foo实际上出现了新的副本,这三个副本之间相互独立,不会出现相互影响,是逻辑独立的实体,三个对象拥有对自己独立的所有权。

所有权流派之移动

在讲移动之前,我们重新认识下,什么是std::move

通过观察源码发现,std::move本质上只是一个static_cast:(摘自llvm)

1
2
3
4
5
6
7
8
9
10
template <class _Tp>
inline _LIBCPP_INLINE_VISIBILITY _LIBCPP_CONSTEXPR
typename remove_reference<_Tp>::type&&
move(_Tp&& __t) _NOEXCEPT
{
// 去除引用修饰,提取值类型
typedef _LIBCPP_NODEBUG_TYPE typename remove_reference<_Tp>::type _Up;
// static_cast为右值引用
return static_cast<_Up&&>(__t);
}

这也是社区对此颇为吐槽的地方,因为std::move本质上并没有发生与move这个单词语义有关联的地方,对象的内存地址并没有发生改变。只是通过static_cast将左值引用强转为右值引用,从而欺骗编译器使之推导匹配对应的右值引用函数。

我们来看一个实际的例子:
1
2
3
4
5
{
Foo foo{"foo"};
Foo movedFoo = std::move(foo);
movedFoo.m_str = "movedFoo";
}
Foo(foo) construct.
Foo construct by rvalue.
Foo(movedFoo) desctruct.
Foo() desctruct.

类似的,通过Foo construct by rvalue日志,我们可知,本质调用的是移动构造函数,我们第一个结论:

  • 在变量初始化时移动,本质上调用的是移动构造函数。

因此我们永远推荐,移动构造、移动赋值务必成对出现。

我们观察到,这个过程发生了两次构造,和两次析构。两次析构,moviedFoo对象是正常析构,但我们同时也观察到一个空的Foo发生了析构。这是因为,我们通过std::move将foo转为右值,所有权转移给了movedFoo,空的Foo是被move后没有价值、不可再访问的对象。这个过程,最终只有一个有效对象,对象所有权从foo转移到movedFoo上。

可见,虽然编译器对std::move的语义模凌两可,但我们仍旧实现了所有权转移。

别急,我们再看一个例子
1
2
3
4
void SomeMoveFunction(Foo &&foo) {
Foo movedFoo = foo;
std::cout << "foo move to temperary scope, and it will destruct." << std::endl;
}
1
2
3
Foo foo{"foo"};

SomeMoveFunction(std::move(foo));
Foo(foo) construct.
Foo construct by lvalue.
foo move to temperary scope, and it will destruct.
Foo(foo) desctruct.

在SomeMoveFunction中,foo所有权被movedFoo捕获,因此原foo对象在SomeMoveFunction这个函数的作用域中析构。因此我们还有一个重要的结论:

  • 所有权发生转移后的变量,其生命周期也由新变量管理

(可选)左右值完美转发

在实际编码过程中,情况可能会比我们上面阐述的更为复杂,比如,我们承担了一个公共的依赖模块开发。公共模块设计的对外接口,强行约束调用方使用只使用左值或者右值,是相当流氓的做法。

在阐述完美转发之前,我们看这样一个例子:

1
2
3
void ImplFunction(const Foo &foo) {
std::cout << "Call with lvalue." << std::endl;
}
1
2
3
void ImplFunction(Foo &&foo) {
std::cout << "Call with rvalue." << std::endl;
}
1
2
3
4
template <typename FooType>
void SomeCommonFunction(FooType &&foo) {
ImplFunction(foo);
}

很显然,SomeCommonFunction被设计为对外的公共函数,我们知道,函数接受参数有如下规则:

  • 左值引用入参:只能接受左值引用
  • 左值引用入参(const):左值引用 + 字面量常量
  • 右值引用(万能引用):以上所有 + 右值引用

我们观察下,这几种场景,编译器实际调用的是哪个ImplFunction。

1
2
3
4
5
{
// call with lvalue
Foo foo{"foo"};
SomeCommonFunction(foo);
}
Foo(foo) construct.
Call with lvalue.
Foo(foo) desctruct.
1
2
3
4
5
{
// call with rvalue
Foo foo{"foo"};
SomeCommonFunction(std::move(foo));
}
Foo(foo) construct.
Call with lvalue.
Foo(foo) desctruct.

OK, 我们发现了大问题,无论是左值还是右值,都统一推导到左值入参的副本。这是因为通过“万能引用”传递的右值(具名变量),即便被声明为了右值引用,也不会被当作右值:

  • 任何函数内部,即便入参声明为右值,在函数内也是当作左值处理。

为解决这个问题,完美转发(std::forward)也是这个背景下提出的,实际上,我们常用的STL容器,也大量用到这样的完美转发,来追求极致的性能,如list,map,vector等等。
我们只需对函数做小小的改动:

1
2
3
4
template <typename FooType>
void SomeCommonFunctionWithForward(FooType &&foo) {
ImplFunction(std::forward<FooType>(foo));
}
1
2
3
4
5
{
// call with lvalue
Foo foo{"foo"};
SomeCommonFunctionWithForward(foo);
}
Foo(foo) construct.
Call with lvalue.
Foo(foo) desctruct.
1
2
3
4
5
{
// call with rvalue
Foo foo{"foo"};
SomeCommonFunctionWithForward(std::move(foo));
}
Foo(foo) construct.
Call with rvalue.
Foo(foo) desctruct.

具体std::forward的实现也很简单,涉及编译器的引用折叠规则,限于篇幅,本文不再展开。

小结一下

可以说,C++的拷贝和移动颠覆了传统C-styple编程范式,充分避免了对裸指针的使用,编译器对代码有了更多的编译期检查,提升代码的安全性。

我们从左值、右值的分类入手,阐述了在“所有权”这个框架概念下拷贝和移动的公共契约,并“控诉”了C++编译器对这个公共契约没有形成标准条款(开玩笑)。也说明了,为什么通常拷贝构造、拷贝赋值和移动构造、移动赋值这些函数通常要求开发者对偶的实现。

随后,结合实际编程场景,在涉及公共组件开发时,更为复杂的入参设计,并推出“完美转发”的概念,及此类公共接口的处理方式。

很庆幸,我们在上述整个课程中,从未涉及到任何裸指针的访问和操作,这也是现代C++所尽力避免的。移动和拷贝,赋予了编译器更强的检查能力,通常大家在编译器开启所有告警,并且编译结果为(0 error 0 warning)时,大部分不安全的裸指针操作将会被编译期检查所过滤,纵享老司机般丝滑。

写在前面

在本次系列课程开始之前,请注意以下事项:

  • 重要完全摒弃C-Style编程思维,将现代化C++当作一门全新的编程语言
  • 本次课程所有的实验代码基于C++14,这也是综合实用性兼容性,总体相对均衡的选择

下面正式开始

何为智能指针

相比传统裸指针需开发者手动申请、释放的背景下,智能指针基于C++类的构造、析构函数(RAII范式)机制,管理内存的生命周期,无需开发者介入管理(或者说低介入)指针的申请、释放。

C++14的标准库memory提供了三种推荐使用的智能指针:

 std::shared_ptr: 共享指针,基于引用计数的内存生命周期管理
 std::unique_ptr: 独享指针,禁止拷贝,可通过std::move的方式转移指针所有权
 std::weak_ptr: 与shared_ptr搭配使用,不增加其引用计数

下面就Foo对象,作为代表类,阐述智能指针的使用方式。

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

class Foo {
public:
Foo() {
std::cout << "Foo construct." << std::endl;
}

~Foo() {
std::cout << "Foo desctruct." << std::endl;
}

void DoSomething() {
std::cout << "Foo DoSomething" << std::endl;
}
};

智能指针可以像普通裸指针一样访问。

1
2
3
4
5
6
7
8
9
10
std::shared_ptr<Foo> f(new Foo);
// 判空
if (f != nullptr) {
std::cout << "f is not nullptr" << std::endl;
}
// 访问指针指向对象的成员变量
f->DoSomething();
// 访问原对象。
auto &raw = *f;
raw.DoSomething();
Foo construct.
f is not nullptr
Foo DoSomething
Foo DoSomething

std::shared_ptr

std::shared_ptr指针的构造方式有:

1
2
3
4
5
6
7
// 默认构造,持有nullptr
std::shared_ptr<Foo> ptr0;
// 裸指针构造,持有传入的指针
std::shared_ptr<Foo> ptr1(new Foo());
// 推荐的构造方式,由于std::shared_ptr内部内存管理区需要额外的内存,
// 上述两种构造方式导致管理内存、业务内存不连续
std::shared_ptr<Foo> ptr2 = std::make_shared<Foo>();
Foo construct.
Foo construct.

std::shared_ptr引用计数

std::shared_ptr本质上是在内部维护了一个对该指针指向对象的引用计数(这个引用计数是放在堆内存上申请的,这块内存和被管理的内存一起释放),在发生指针拷贝时,引用计数加一,任意副本发生析构时,则相应引用计数减一。

值的称赞的是,std::shared_ptr的引用计数管理,是线程安全的,你可以在多线程场景安全的使用。

std::shared_ptr在典型场景的引用计数变化如下:

1
2
3
4
{
std::shared_ptr<Foo> foo0 = std::make_shared<Foo>();
std::cout << "构造后,引用计数为" << foo0.use_count() << std::endl;
}
Foo construct.
构造后,引用计数为1
Foo desctruct.
1
2
3
4
5
6
{
std::shared_ptr<Foo> foo0 = std::make_shared<Foo>();
std::shared_ptr<Foo> foo1(foo0);
std::cout << "拷贝构造,新对象引用计数为" << foo1.use_count() << std::endl;
std::cout << "拷贝构造,旧对象引用计数为" << foo0.use_count() << std::endl;
}
Foo construct.
拷贝构造,新对象引用计数为2
拷贝构造,旧对象引用计数为2
Foo desctruct.
1
2
3
4
5
6
7
{
std::shared_ptr<Foo> foo0 = std::make_shared<Foo>();
std::shared_ptr<Foo> foo1(std::move(foo0));
std::cout << "移动构造,新对象引用计数为" << foo1.use_count() << std::endl;
// 被移动对象不可再访问
// std::cout << "移动构造,旧对象引用计数为" << foo0.use_count() << std::endl;
}
Foo construct.
移动构造,新对象引用计数为1
Foo desctruct.
1
2
3
4
5
6
7
{
std::shared_ptr<Foo> foo0 = std::make_shared<Foo>();
std::shared_ptr<Foo> foo1(foo0);
std::cout << "拷贝构造,新对象引用计数为" << foo1.use_count() << std::endl;
foo1 = nullptr;
std::cout << "指针副本重新赋值后,引用计数为" << foo0.use_count() << std::endl;
}
Foo construct.
拷贝构造,新对象引用计数为2
指针副本重新赋值后,引用计数为1
Foo desctruct.

std::shared_ptr指向内存的释放

std::shared_ptr在两种场景下会触发内存的释放:

  1. 引用计数为1且指针对象触发了析构函数。
  2. 引用计数为1且用户对这个指针重新赋值。
    实际上析构、重新赋值都会触发引用减1

std::shared_ptr支持自定义的内存释放器:

1
2
3
4
5
6
{
std::shared_ptr<Foo> foo4(new Foo(), [](Foo *p){
std::cout << "delete by lambda" << std::endl;
delete p;
});
}
Foo construct.
delete by lambda
Foo desctruct.

std::unique_ptr

独享指针的拷贝构造、拷贝赋值是显式删除的。只能通过std::move传递指针的所有权。

1
2
3
// 独享指针禁止拷贝
std::unique_ptr<Foo> foo5(new Foo());
std::unique_ptr<Foo> foo6 = foo5;
[1minput_line_16:4:22: [0m[0;1;31merror: [0m[1mcall to deleted constructor of 'std::unique_ptr<Foo>'[0m
std::unique_ptr<Foo> foo6 = foo5;
[0;1;32m                     ^      ~~~~
[0m[1m/root/miniconda3/envs/cling/bin/../lib/gcc/../../x86_64-conda-linux-gnu/include/c++/10.4.0/bits/unique_ptr.h:468:7: [0m[0;1;30mnote: [0m'unique_ptr' has been explicitly marked deleted here[0m
      unique_ptr(const unique_ptr&) = delete;
[0;1;32m      ^
[0m


Interpreter Error: 

请注意,所有权转移后,std::unique_ptr将在被转移后的作用域中生存,这个作用域结束,则会触发析构。如下面的例子:

1
2
3
4
5
6
7
8
void UniquePtrTobeMovedTo(std::unique_ptr<Foo> &&foo) {
// 右值在涉及栈上传递时,会隐式拷贝,需使用完美转发
std::unique_ptr<Foo> b(std::forward<std::unique_ptr<Foo> &&>(foo));
std::cout << "foo pointer moved to here, and it will be destructed in this scope." << std::endl;
}

std::unique_ptr<Foo> foo7(new Foo());
UniquePtrTobeMovedTo(std::move(foo7));
Foo construct.
foo pointer moved to here, and it will be destructed in this scope.
Foo desctruct.

std::weak_ptr

弱引用指针,与std::shared_ptr搭配使用,可在不增加引用计数的前提下,对std::shared_ptr进行拷贝。

1
2
3
std::shared_ptr<Foo> foo9(new Foo());
std::weak_ptr<Foo> wfoo = foo9;
std::cout << "赋值给std::weak_ptr后,引用计数为" << foo9.use_count();
Foo construct.
赋值给std::weak_ptr后,引用计数为1

涉及引用计数管理的GC机制,都会有相同的场景,A持有B的引用,B持有A的引用,导致引用计数永远无法为0.
当然,这样两个对象之间相互引用,是非常不好的编程实践,我们在开发时应尽量避免。

我用一个简单的例子来讲述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
// 前置声明
class A;
class B;

class A {
public:
A() {
std::cout << "A constructed" << std::endl;
}
~A() {
std::cout << "A desctructed" << std::endl;
}

std::shared_ptr<B> b;
};

class B {
public:
B() {
std::cout << "B constructed" << std::endl;
}
~B() {
std::cout << "B desctructed" << std::endl;
}

std::shared_ptr<A> a;
};
1
2
3
4
5
6
7
8
9
10
11
{
// 构造实例
std::shared_ptr<A> pointerA(new A());
std::shared_ptr<B> pointerB(new B());

pointerA->b = pointerB;
pointerB->a = pointerA;

std::cout << "pointerA引用计数为" << pointerA.use_count() << std::endl;
std::cout << "pointerB引用计数为" << pointerB.use_count() << std::endl;
}
A constructed
B constructed
pointerA引用计数为2
pointerB引用计数为2

可见,由于循环引用的原因,导致错误计数,产生内存泄漏。

解决循环引用也相当简单,将其中一个对象的引用指针改为std::weak_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
// 前置声明
class C;
class D;

class C {
public:
C() {
std::cout << "C constructed" << std::endl;
}
~C() {
std::cout << "C desctructed" << std::endl;
}

std::shared_ptr<D> d;
};

class D {
public:
D() {
std::cout << "D constructed" << std::endl;
}
~D() {
std::cout << "D desctructed" << std::endl;
}

std::weak_ptr<C> c;
};
1
2
3
4
5
6
7
8
9
10
11
{
// 构造实例
std::shared_ptr<C> pointerC(new C());
std::shared_ptr<D> pointerD(new D());

pointerC->d = pointerD;
pointerD->c = pointerC;

std::cout << "pointerC引用计数为" << pointerC.use_count() << std::endl;
std::cout << "pointerD引用计数为" << pointerD.use_count() << std::endl;
}
C constructed
D constructed
pointerC引用计数为1
pointerD引用计数为2
C desctructed
D desctructed

指针设计思路应当是这样的:

  1. 如果指针指向对象不应被多个对象共享,则尽可能使用std::unique_ptr
  2. 如果指针不得不被多个对象共享,则应当尽量避免多个对象之间出现循环引用
  3. 如果循环引用不可避免(如:天杀的祖传代码),则循环引用中的对象,应持有的时对方std::weak_ptr指针,以实现引用计数不增加,从而正常释放。
0%