谈谈 shared_ptr 的那些坑

共享所有权

  一个动态分配的对象可以在多个shared_ptr之间共享,这是因为shared_ptr支持 copy 操作:

1
2
shared_ptr<string> ptr1{ new string("hello") };
auto ptr2 = ptr1; // copy constructor

原理介绍

  shared_ptr内部包含两个指针,一个指向对象,另一个指向控制块(control block),控制块中包含一个引用计数和其它一些数据。由于这个控制块需要在多个shared_ptr之间共享,所以它也是存在于 heap 中的。shared_ptr对象本身是线程安全的,也就是说shared_ptr的引用计数增加和减少的操作都是原子的。

  通过unique_ptr来构造shared_ptr是可行的:

1
2
unique_ptr<string> p1{ new string("senlin") };
shared_ptr<string> p2{ std::move(p1) };

shared_ptr 的风险

  你大概觉得使用智能指针就再也高枕无忧了,不再为内存泄露烦恼了。然而梦想总是美好的,使用shared_ptr时,不可避免地会遇到循环引用的情况,这样容易导致内存泄露。循环引用就像下图所示,通过shared_ptr创建的两个对象,同时它们的内部均包含shared_ptr指向对方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 一段内存泄露的代码
struct Son;
struct Father {
shared_ptr<Son> son_;
};
struct Son {
shared_ptr<Father> father_;
};
int main()
{
auto father = make_shared<Father>();
auto son = make_shared<Son>();
father->son_ = son;
son->father_ = father;
return 0;
}

  分析一下main函数是如何退出的,一切就都明了:

  • main函数退出之前,FatherSon对象的引用计数都是2
  • son指针销毁,这时Son对象的引用计数是1
  • father指针销毁,这时Father对象的引用计数是1
  • 由于Father对象和Son对象的引用计数都是1,这两个对象都不会被销毁,从而发生内存泄露。

  为避免循环引用导致的内存泄露,就需要使用weak_ptrweak_ptr并不拥有其指向的对象,也就是说,让weak_ptr指向shared_ptr所指向对象,对象的引用计数并不会增加:

1
2
3
auto ptr = make_shared<string>("senlin");
weak_ptr<string> wp1{ ptr };
cout << "use count: " << ptr.use_count() << endl;// use count: 1

  使用weak_ptr就能解决前面提到的循环引用的问题,方法很简单,只要让Son或者Father包含的shared_ptr改成weak_ptr就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 修复内存泄露的问题
struct Son;
struct Father {
shared_ptr<Son> son_;
};
struct Son {
weak_ptr<Father> father_;
};
int main()
{
auto father = make_shared<Father>();
auto son = make_shared<Son>();
father->son_ = son;
son->father_ = father;
return 0;
}

  同样,分析一下main函数退出时发生了什么:

  • main函数退出前,Son对象的引用计数是2,而Father的引用计数是1
  • son指针销毁,Son对象的引用计数变成1
  • father指针销毁,Father对象的引用计数变成0,导致Father对象析构,Father对象的析构会导致它包含的son_指针被销毁,这时Son对象的引用计数变成0,所以Son对象也会被析构。

  然而,weak_ptr并不是完美的,因为weak_ptr不持有对象,所以不能通过weak_ptr去访问对象的成员,例如:

1
2
3
4
5
6
7
struct Square {
int size = 0;
};
auto sp = make_shared<Square>();
weak_ptr<Square> wp{ sp };
cout << wp->size << endl; // compile-time ERROR

  你可能猜到了,既然shared_ptr可以访问对象成员,那么是否可以通过weak_ptr去构造shared_ptr呢?事实就是这样,实际上weak_ptr只是作为一个转换的桥梁(proxy),通过weak_ptr得到shared_ptr,有两种方式:

  • 调用weak_ptrlock()方法,要是对象已被析构,那么lock()返回一个空的shared_ptr
  • weak_ptr传递给shared_ptr的构造函数,要是对象已被析构,则抛出std::exception异常。

  既然weak_ptr不持有对象,也就是说weak_ptr指向的对象可能析构了,但weak_ptr却不知道。所以需要判断weak_ptr指向的对象是否还存在,有两种方式:

  • weak_ptruse_count()方法,判断引用计数是否为0
  • 调用weak_ptrexpired()方法,若对象已经被析构,则expired()将返回true

  转换过后,就可以通过shared_ptr去访问对象了:

1
2
3
4
5
6
7
8
auto sp = make_shared<Square>();
weak_ptr<Square> wp{ sp };
if (!wp.expired())
{
auto ptr = wp.lock(); // get shared_ptr
cout << ptr->size << endl;
}

销毁操作

  为shared_ptr指定 deleter 的做法很简单:

1
2
3
4
5
6
7
8
9
10
shared_ptr<string> ptr( new string("hello"),
[]( string *p ) {
cout << "delete hello" << endl;
});
ptr = nullptr;
cout << "Before the end of main()" << endl;
/**
delete hello
Before the end of main()
**/

  与unique_ptr不同,标准库并不提供shared_ptr<T[]>,因此,使用shared_ptr处理数组时需要显示指定删除行为,例如:

1
2
3
4
5
6
shared_ptr<string> ptr1( new string[10],
[]( string *p ) {
delete[] p;
});
shared_ptr<string> ptr2( new string[10],
std::default_delete<string[]>() );

  由于不存在shared_ptr<T[]>,我们无法使用[]来访问数组中的元素,实际上无法访问到数组中的元素。也就是说使用shared_ptr来指向数组意义并不大。若想要数组在多个shared_ptr之间共享,可以考虑使用shared_ptr<vector>shared_ptr<array>

更多陷阱

  使用shared_ptr时,注意不能直接通过同一个 raw pointer 指针来构造多个shared_ptr

1
2
3
int *p = new int{10};
shared_ptr<int> ptr1{ p };
shared_ptr<int> ptr2{ p }; // ERROR

  很明显,每次通过 raw pointer 来构造shared_ptr时候就会分配一个控制块,这时存在两个控制块,也就是说存在两个引用计数。这显然是错误的,因为当这两个shared_ptr被销毁时,对象将会被delete两次。


  考虑到this也是 raw pointer,所以一不小心就会用同一个this去构造多个shared_ptr了,就像这样:

1
2
3
4
5
6
7
8
9
10
11
class Student
{
public:
Student( const string &name ) : name_( name ) { }
void addToGroup( vector<shared_ptr<Student>> &group ) {
group.push_back( shared_ptr<Student>(this) ); // ERROR
}
private:
string name_;
};

  每次调用addToGroup()时都会创建一个控制块,所以这个对象会对应多个引用计数,最终这个对象就会被delete多次,导致运行出错。解决这个问题很简单,只要让std::enable_shared_from_this<Student>作为Student的基类:

1
2
3
4
5
6
7
8
9
10
11
class Student : public std::enable_shared_from_this<Student>
{
public:
Student( const string &name ) : name_( name ) { }
void addToGroup( vector<shared_ptr<Student>> &group ) {
group.push_back( shared_from_this() ); // OK
}
private:
string name_;
};

  需要注意,调用shared_from_this()之前需要确保对象被share_ptr所持有,理解这一点是很容易的,因为只有当对象被shared_ptr所持有,使用shared_from_this()所返回的shared_ptr才会指向同一个对象。

1
2
3
4
5
6
7
8
9
10
11
vector<shared_ptr<Student>> group;
// Good: ensure object be owned by shared_ptr
auto goodStudent = make_shared<Student>( "senlin" );
goodStudent->addToGroup( group );
Student badStudent1( "bad1" );
badStudent1.addToGroup( group ); // ERROR
auto badStudent2 = new Student( "bad2" );
badStudent2->addToGroup( group ); // ERROR

  在调用shared_from_this()之前需要确保对象被shared_ptr所持有,要使得对象被shared_ptr所持有,对象首先要初始化(调用构造函数),所以一定不能Student的构造函数中调用shared_from_this()

1
2
3
4
5
6
7
8
9
10
11
12
class Student : public std::enable_shared_from_this<Student>
{
public:
Student( const string &name, vector<shared_ptr<Student>> &group )
: name_( name )
{
// ERROR: shared_from_this() can't be call in object's constructor
group.push_back( shared_from_this() );
}
private:
string name_;
};


  好了,那么问题来了:

  • 要怎样才能防止用户以不正确的方式来创建Student对象呢?
  • 同时也要使得,我们可以使用不同的构造方式来创建Student对象。

  可以将Student的构造函数声明为private的,因此,用户无法直接创建Student对象。另一方面,增加create()成员函数,在这个函数里面,我们使用 C++11 的 variadic templates 特性,调用Student的构造函数来构造对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Student : public std::enable_shared_from_this<Student>
{
private:
Student( const string &name ) : name_( name ) { }
Student( const Student &rhs ) : name_( rhs.name_ ) { }
// can have other constructor
public:
template <typename... Args>
static shared_ptr<Student> create( Args&&... args ) {
return shared_ptr<Student>(new Student(std::forward<Args>(args)...));
}
void addToGroup( vector<shared_ptr<Student>> &group ) {
group.push_back( shared_from_this() );
}
private:
string name_;
};

  通过create()保证了用户创建的对象总是被shared_ptr所持有,可以将create()想象成Student构造函数的别名:

1
2
3
4
5
vector<shared_ptr<Student>> group;
auto student1 = Student::create("senlin");
student1->addToGroup(group);
cout << "student1.use_count() = " << student1.use_count() << endl;
// student1.use_count() = 2

性能考虑

1
shared_ptr<std::string> ptr{ new string("hello") };

  使用这种方式创建shared_ptr时,需要执行两次new操作,一次在 heap 上为string("hello")分配内存,另一次在 heap 上为控制块分配内存。使用make_shared来创建shared_ptr会高效,因为make_shared仅使用new操作一次,它的做法是在 heap 上分配一块连续的内存用来容纳string("hello")和控制块。同样,当shared_ptr的被析构时,也只需一次delete操作。

1
auto ptr = std::make_shared<std::string>("hello");

参考资料