现代C++之智能指针

写在前面

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

  • 重要完全摒弃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指针,以实现引用计数不增加,从而正常释放。