C++11(四)智能指针

原图

shared_ptr

实现原理

一个 shared_ptr 对象的内存开销要比裸指针和无自定义 deleter 的 unique_ptr 对象略大。shared_ptr 需要维护的信息有两部分:

  • 指向共享资源的指针
  • 引用计数等共享资源的控制信息——实现上是维护一个指向控制信息的指针

所以,shared_ptr 对象需要保存两个指针。shared_ptr 的 deleter 是保存在控制信息中,所以,是否有自定义 deleter 不影响 shared_ptr 对象的大小。当我们创建一个 shared_ptr 时,其实现一般如下:

1
std::shared_ptr<T> sptr1(new T);

复制一个 shared_ptr :

1
std::shared_ptr<T> sptr2 = sptr1;

为什么控制信息和每个 shared_ptr 对象都需要保存指向共享资源的指针?可不可以去掉 shared_ptr 对象中指向共享资源的指针,以节省内存开销?

答案是:不能。 因为 shared_ptr 对象中的指针指向的对象不一定和控制块中的指针指向的对象一样。

来看一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Fruit {
int juice;
};

struct Vegetable {
int fiber;
};

struct Tomato : public Fruit, Vegetable {
int sauce;
};

// 由于继承的存在,shared_ptr 可能指向基类对象
std::shared_ptr<Tomato> tomato = std::make_shared<Tomato>();
std::shared_ptr<Fruit> fruit = tomato;
std::shared_ptr<Vegetable> vegetable = tomato;

另外,std::shared_ptr 支持 aliasing constructor。

1
2
template< class Y >
shared_ptr( const shared_ptr<Y>& r, element_type* ptr ) noexcept;

Aliasing constructor,简单说就是构造出来的 shared_ptr 对象和参数 r 指向同一个控制块(会影响 r 指向的资源的生命周期),但是指向共享资源的指针是参数 ptr。看下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
using Vec = std::vector<int>;
std::shared_ptr<int> GetSPtr() {
auto elts = {0, 1, 2, 3, 4};
std::shared_ptr<Vec> pvec = std::make_shared<Vec>(elts);
return std::shared_ptr<int>(pvec, &(*pvec)[2]);
}

std::shared_ptr<int> sptr = GetSPtr();
for (int i = -2; i < 3; ++i) {
printf("%d\n", sptr.get()[i]);
}

看上面的例子,使用 std::shared_ptr 时,会涉及两次内存分配:一次分配共享资源对象;一次分配控制块。C++ 标准库提供了 std::make_shared 函数来创建一个 shared_ptr 对象,只需要一次内存分配。

这种情况下,不用通过控制块中的指针,我们也能知道共享资源的位置——这个指针也可以省略掉。

析构

shared_ptr 默认调用 delete 释放关联的资源。如果用户采用一个不一样的析构策略时,他可以自由指定构造这个 shared_ptr 的策略。下面的例子是一个由于采用默认析构策略导致的问题:

1
2
3
4
5
class Test {...};
void main( )
{
shared_ptr<Test> sptr1( new Test[5] );
}

在此场景下,shared_ptr 指向一组对象,但是当离开作用域时,默认的析构函数调用 delete 释放资源。实际上,我们应该调用 delete[] 来销毁这个数组。用户可以通过调用一个函数,例如一个 lamda 表达式,来指定一个通用的释放步骤。

1
2
3
4
5
void main( )
{
shared_ptr<Test> sptr1( new Test[5],
[ ](Test* p) { delete[ ] p; } );
}

通过指定 delete[] 来析构,上面的代码可以完美运行。

Issues

所有的 shared_ptrs 拥有相同的引用计数,属于相同的组。上述代码工作良好,让我们看另外一组例子。

1
2
3
4
5
6
void main( )
{
int* p = new int;
shared_ptr<int> sptr1( p);
shared_ptr<int> sptr2( p );
}

上述代码会产生一个错误,因为两个来自不同组的 shared_ptr 指向同一个资源。下表给你关于错误原因的图景:

避免这个问题,尽量不要从一个裸指针 (naked pointer) 创建 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
class B;
class A
{
public:
A() : m_sptrB(nullptr) { };
~A()
{
cout<<" A is destroyed"<<endl;
}
shared_ptr<B> m_sptrB;
};
class B
{
public:
B() : m_sptrA(nullptr) { };
~B()
{
cout<<" B is destroyed"<<endl;
}
shared_ptr<A> m_sptrA;
};

void main( )
{
shared_ptr<B> sptrB( new B );
shared_ptr<A> sptrA( new A );
sptrB->m_sptrA = sptrA;
sptrA->m_sptrB = sptrB;
}

上面的代码产生了一个循环引用,A 对 B 有一个 shared_ptr, B 对 A 也有一个 shared_ptr ,与 sptrA 和 sptrB 关联的资源都没有被释放,参考下表:

当 sptrA 和 sptrB 离开作用域时,它们的引用计数都只减少到 1,所以它们指向的资源并没有释放!!!!!

  • 如果几个 shared_ptrs 指向的内存块属于不同组,将产生错误。
  • 如果从一个普通指针创建一个 shared_ptr 还会引发另外一个问题。在上面的代码中,考虑到只有一个 shared_ptr 是由 p 创建的,代码可以好好工作。万一程序员在智能指针作用域结束之前删除了普通指针 p。天啦噜!!!又是一个 crash。
  • 循环引用:如果共享智能指针卷入了循环引用,资源都不会正常释放。

为了解决循环引用,C++提供了另外一种智能指针:weak_ptr

Weak_Ptr

原理

std::weak_ptr 要与 std::shared_ptr 一起使用。 一个 std::weak_ptr 对象看做是 std::shared_ptr 对象管理的资源的观察者,它不影响共享资源的生命周期:

  • 如果需要使用 weak_ptr 正在观察的资源,可以将 weak_ptr 提升为 shared_ptr
  • 当 shared_ptr 管理的资源被释放时,weak_ptr 会自动变成 nullptr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Observe(std::weak_ptr<int> wptr) {
if (auto sptr = wptr.lock()) {
std::cout << "value: " << *sptr << std::endl;
} else {
std::cout << "wptr lock fail" << std::endl;
}
}

std::weak_ptr<int> wptr;
{
auto sptr = std::make_shared<int>(111);
wptr = sptr;
Observe(wptr); // sptr 指向的资源没被释放,wptr 可以成功提升为 shared_ptr
}
Observe(wptr); // sptr 指向的资源已被释放,wptr 无法提升为 shared_ptr

当 shared_ptr 析构并释放共享资源的时候,只要 weak_ptr 对象还存在,控制块就会保留,weak_ptr 可以通过控制块观察到对象是否存活。

enable_shared_from_this

一个类的成员函数如何获得指向自身(this)的 shared_ptr? 看看下面这个例子有没有问题?

1
2
3
4
5
6
7
8
9
10
11
12
class Foo {
public:
std::shared_ptr<Foo> GetSPtr() {
return std::shared_ptr<Foo>(this);
}
};

auto sptr1 = std::make_shared<Foo>();
assert(sptr1.use_count() == 1);
auto sptr2 = sptr1->GetSPtr();
assert(sptr1.use_count() == 1);
assert(sptr2.use_count() == 1);

上面的代码其实会生成两个独立的 shared_ptr,他们的控制块是独立的,最终导致一个 Foo 对象会被 delete 两次。

成员函数获取 this 的 shared_ptr 的正确的做法是继承 std::enable_shared_from_this。

1
2
3
4
5
6
7
8
9
10
11
12
class Bar : public std::enable_shared_from_this<Bar> {
public:
std::shared_ptr<Bar> GetSPtr() {
return shared_from_this();
}
};

auto sptr1 = std::make_shared<Bar>();
assert(sptr1.use_count() == 1);
auto sptr2 = sptr1->GetSPtr();
assert(sptr1.use_count() == 2);
assert(sptr2.use_count() == 2);

一般情况下,继承了 std::enable_shared_from_this 的子类,成员变量中增加了一个指向 this 的 weak_ptr。这个 weak_ptr 在第一次创建 shared_ptr 的时候会被初始化,指向 this。

似乎继承了 std::enable_shared_from_this 的类都被强制必须通过 shared_ptr 进行管理。

1
2
auto b = new Bar;
auto sptr = b->shared_from_this();

使用

现在让我们见识一下 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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class B;
class A
{
public:
A( ) : m_a(5) { };
~A( )
{
cout<<" A is destroyed"<<endl;
}
void PrintSpB( );
weak_ptr<B> m_sptrB;
int m_a;
};
class B
{
public:
B( ) : m_b(10) { };
~B( )
{
cout<<" B is destroyed"<<endl;
}
weak_ptr<A> m_sptrA;
int m_b;
};

void A::PrintSpB( )
{
if( !m_sptrB.expired() )
{
cout<< m_sptrB.lock( )->m_b<<endl;
}
}

void main( )
{
shared_ptr<B> sptrB( new B );
shared_ptr<A> sptrA( new A );
sptrB->m_sptrA = sptrA;
sptrA->m_sptrB = sptrB;
sptrA->PrintSpB( );
}

unique_ptr

unique_ptr 遵循着独占语义。在任何时间点,资源只能唯一地被一个 unique_ptr 占有。当 unique_ptr 离开作用域,所包含的资源被释放。如果资源被其它资源重写了,之前拥有的资源将被释放。所以它保证了他所关联的资源总是能被释放。

创建

1
unique_ptr<int> uptr( new int );

当创建 unique_ptr 时,这一组对象被视作模板参数的部分。这样,程序员就不需要再提供一个指定的析构方法,如下:

1
unique_ptr<int[ ]> uptr( new int[5] );

当把 unique_ptr 赋给另外一个对象时,资源的所有权就会被转移。记住 unique_ptr 不提供复制语义(拷贝赋值和拷贝构造都不可以),只支持移动语义 (move semantics)。

参考文献

https://www.jianshu.com/p/e4919f1c3a28
https://zhuanlan.zhihu.com/p/150555165
《深入理解 C++11:C++11 新特性解析与应用》