【转】C++智能指针的正确使用方式

对象所有权

  首先需要理清楚的概念就是对象所有权的概念。所有权在 rust 语言中非常严格,写 rust 的时候必须要清楚自己创建的每个对象的所有权。

但是 C++ 比较自由,似乎我们不需要明白对象的所有权,写的代码也能正常运行。但是明白了对象所有权,我们才可以正确管理好对象生命周期和内存问题。

C++ 引入了智能指针,也是为了更好的描述对象所有权,简化内存管理,从而大大减少我们 C++ 内存管理方面的犯错机会。

unique_ptr:专属所有权

  我们大多数场景下用到的应该都是unique_ptr。

  unique_ptr代表的是专属所有权,即由unique_ptr管理的内存,只能被一个对象持有。

  所以,unique_ptr不支持复制和赋值,如下:

auto w = std::make_unique<Widget>();
auto w2 = w; // 编译错误

  如果想要把w复制给w2是不可以的。因为复制从语义上来说,两个对象将共享同一块内存。

  因此,unique_ptr只支持移动,即如下:

auto w = std::make_unique<Widget>();
auto w2 = std::move(w); // w2 获得内存所有权,w 此时等于 nullptr

  unique_ptr代表的是专属所有权,如果想要把一个unique_ptr的内存交给另外一个unique_ptr对象管理,只能使用std::move转移当前对象的所有权。转移之后,当前对象不再持有此内存,新的对象将获得专属所有权。如上代码中,将w对象的所有权转移给w2后,w此时等于nullptr,而w2获得了专属所有权。

性能

  因为c++的zero cost abstraction的特点,unique_ptr在默认情况下和裸指针的大小是一样的。

  所以内存上没有任何的额外消耗,性能是最优的。

使用场景1:忘记delete

  unique_ptr的一个最简单的使用场景是用于类属性。代码如下:

 1 class Box{
 2 public:
 3     Box() : w(new Widget())
 4     {}
 5 
 6     ~Box()
 7     {
 8         // 忘记 delete w
 9     }
10 private:
11     Widget* w;
12 };

  因为一些原因,w必须建立在堆上,如果用裸指针管理w,那么需要在析构函数中 delete w;

  这种写法虽然没什么问题,但是容易漏写delete语句,造成内存泄漏。

  如果按照unique_ptr的写法,不用在析构函数手动delete属性,当对象析构时,属性w将会自动释放内存。

使用场景2:异常安全

  假如我们在一段代码中,需要创建一个对象,处理一些事情后返回,返回之前将对象销毁,如下所示:

1 void process()
2 {
3     Widget* w = new Widget();
4     w->do_something(); // 可能会发生异常
5     delete w;
6 }

  在正常流程下,我们会在函数末尾delete创建的对象w,正常调用析构函数,释放内存。

  但是如果w->do_something()发生了异常,那么delete w将不会被执行。此时就会发生内存泄漏。

  我们当然可以使用try...catch捕捉异常,在catch里面执行delete,但是这样代码上并不美观,也容易漏写。

  如果我们用std::unique_ptr,那么这个问题就迎刃而解了。无论代码怎么抛异常,在unique_ptr离开函数作用域的时候,内存就会自动释放。

 

shared_ptr:共享所有权

  在使用shared_ptr之前应该考虑,是否真的需要使用shared_ptr,而非unique_ptr。

  shared_ptr代表的是共享所有权,即多个shared_ptr可以共享同一块内存。

  因此,从语义上来看,shared_ptr是支持复制的。如下:

1 auto w = std::make_shared<Widget>();
2 {
3     auto w2 = w;
4     cout << w.use_count() << endl;  // 2
5 }
6 cout << w.use_count() << endl;  // 1

  shared_ptr内部是利用引用计数来实现内存的自动管理,每当复制一个shared_ptr,引用计数会 +1。当一个shared_ptr离开作用域时,引用计数会-1。当引用计数为0的时候,则delete内存。

  同时,shared_ptr也支持移动。从语义上来看,移动指的是所有权的传递。如下:

1 auto w = std::make_shared<Widget>();
2 auto w2 = std::move(w); // 此时 w 等于 nullptr,w2.use_count() 等于 1

  我们将w对象move给w2,意味着w放弃了对内存的所有权和管理,此时w对象等于nullptr。

  而w2获得了对象所有权,但因为此时w已不再持有对象,因此w2的引用计数为1。

性能

  1、内存占用高

  shared_ptr的内存占用是裸指针的两倍。因为除了要管理一个裸指针外,还要维护一个引用计数。

  因此相比于unique_ptr,shared_ptr的内存占用更高。

  2、原子操作性能低

  考虑到线程安全问题,引用计数的增减必须是原子操作。而原子操作一般情况下都比非原子操作慢。

  3、使用移动优化性能

  shared_ptr在性能上固然是低于unique_ptr。而通常情况下,我们也可以尽量避免shared_ptr复制。

  如果,一个shared_ptr需要将所有权共享给另外一个新的shared_ptr,而我们确定在之后的代码中都不再使用这个shared_ptr,那么这是一个非常鲜明的移动语义。

  对于此种场景,我们尽量使用std::move,将shared_ptr转移给新的对象。因为移动不用增加引用计数,性能比复制更好。

使用场景

  1、shared_ptr通常使用在共享权不明的场景。有可能多个对象同时管理同一个内存时。

  2、对象的延迟销毁。陈硕在《Linux 多线程服务器端编程》中提到,当一个对象的析构非常耗时,甚至影响到了关键线程的速度。可以使用 BlockingQueue<std::shared_ptr<void>> 将对象转移到另外一个线程中释放,从而解放关键线程。

为什么要用shared_from_this

  我们往往需要在类内部使用自身的shared_ptr,例如:

1 class Widget
2 {
3 public:
4     void do_something(A& a)
5     {
6         a.widget = 该对象的 shared_ptr;
7     }
8 }

  我们需要把当前shared_ptr对象同时交由对象a进行管理。这意味着,当前对象的生命周期的结束不能早于对象a。因为对象a在析构之前都有可能会使用到a.widget。

  1)直接使用a.widget = this

  这样并没有增加当前shared_ptr的引用计数。shared_ptr还是有可能早于对象a释放。

  2)直接传递shared_ptr<Widget>(this):  a.widget = shared_ptr<Widget>(this)

  不可以,因为从原生指针(this)构建shared_ptr会开辟一块新的内存用于引用计数,不同于通过赋值构建的是共享引用计数内存的。所以会造成2个非共享的shared_ptr指向同一个对象,未增加引用计数导致对象被析构两次。

  对于这种,需要在对象内部获得该对象自身的shared_ptr,那么该类必须继承std::enable_shared_from_this<T>。代码如下:

1 class Widget : public std::enable_shared_from_this<Widget>
2 {
3 public:
4     void do_something(A& a)
5     {
6         a.widget = shared_from_this();
7     }
8 }

  这样才是合法的。

为何会出现这种使用场合

  因为在异步调用中,存在一个保活机制,异步函数执行的时间点我们是无法确定的,然后异步函数可能会使用异步调用之前就存在的类对象。为了保证该类对象在异步函数执行期间一直有效,我们可以传递一个指向自身的shared_ptr给异步函数,这样在异步函数执行期间shared_ptr所管理的对象就不会析构,所使用的类对象也会一直有效(保活)。

weak_ptr

1、weak_ptr是为了解决shared_ptr双向引用的问题。

  定义两个类,每个类中又包含一个指向对方类型的智能指针作为成员变量,然后创建对象,设置完成后查看引用计数后退出,看一下测试结果:

 1 class CB;
 2 class CA
 3 {
 4 public:
 5     CA() { cout << "CA() called! " << endl; }
 6     ~CA() { cout << "~CA() called! " << endl; }
 7     void set_ptr(shared_ptr<CB>& ptr) { m_ptr_b = ptr; }
 8     void b_use_count() { cout << "b use count : " << m_ptr_b.use_count() << endl; }
 9     void show() { cout << "this is class CA!" << endl; }
10 private:
11     shared_ptr<CB> m_ptr_b;
12 };
13 
14 class CB
15 {
16 public:
17     CB() { cout << "CB() called! " << endl; }
18     ~CB() { cout << "~CB() called! " << endl; }
19     void set_ptr(shared_ptr<CA>& ptr) { m_ptr_a = ptr; }
20     void a_use_count() { cout << "a use count : " << m_ptr_a.use_count() << endl; }
21     void show() { cout << "this is class CB!" << endl; }
22 private:
23     shared_ptr<CA> m_ptr_a;
24 };
25 
26 void test_refer_to_each_other()
27 {
28     shared_ptr<CA> ptr_a(new CA());
29     shared_ptr<CB> ptr_b(new CB());
30 
31     cout << "a use count : " << ptr_a.use_count() << endl;
32     cout << "b use count : " << ptr_b.use_count() << endl;
33 
34     ptr_a->set_ptr(ptr_b);
35     ptr_b->set_ptr(ptr_a);
36 
37     cout << "a use count : " << ptr_a.use_count() << endl;
38     cout << "b use count : " << ptr_b.use_count() << endl;
39 }

  测试结果如下:

CA() called!
CB() called!
a use count : 1
b use count : 1
a use count : 2
b use count : 2

  通过结果可以看到,最后CACB的对象并没有被析构,其中的引用效果如下图所示,起初定义完ptr_aptr_b时,只有①③两条引用,然后调用函数set_ptr后又增加了②④两条引用,当test_refer_to_each_other这个函数返回时,对象ptr_aptr_b被销毁,也就是①③两条引用会被断开,但是②④两条引用依然存在,每一个的引用计数都不为0,结果就导致其指向的内部对象无法析构,造成内存泄漏。

 

 

   解决这种状况的办法就是将两个类中的一个成员变量改为weak_ptr对象,因为weak_ptr不会增加引用计数,使得引用形不成环,最后就可以正常的释放内部的对象,不会造成内存泄漏,比如将CB中的成员变量改为weak_ptr对象,代码如下:

 1 class CB
 2 {
 3 public:
 4     CB() { cout << "CB() called! " << endl; }
 5     ~CB() { cout << "~CB() called! " << endl; }
 6     void set_ptr(shared_ptr<CA>& ptr) { m_ptr_a = ptr; }
 7     void a_use_count() { cout << "a use count : " << m_ptr_a.use_count() << endl; }
 8     void show() { cout << "this is class CB!" << endl; }
 9 private:
10     weak_ptr<CA> m_ptr_a;
11 };

  测试结果如下:

CA() called!
CB() called!
a use count : 1
b use count : 1
a use count : 1
b use count : 2
~CA() called!
~CB() called!

  通过这次结果可以看到,CACB的对象都被正常的析构了,引用关系如下图所示,流程与上一例子相似,但是不同的是④这条引用是通过weak_ptr建立的,并不会增加引用计数,也就是说CA的对象只有一个引用计数,而CB的对象只有2个引用计数,当test_refer_to_each_other这个函数返回时,对象ptr_aptr_b被销毁,也就是①③两条引用会被断开,此时CA对象的引用计数会减为0,对象被销毁,其内部的m_ptr_b成员变量也会被析构,导致CB对象的引用计数会减为0,对象被销毁,进而解决了引用成环的问题。

2. 测试weak_ptr对引用计数的影响

  其实weak_ptr本身设计的很简单,就是为了辅助shared_ptr的,它本身不能直接定义指向原始指针的对象,只能指向shared_ptr对象,同时也不能将weak_ptr对象直接赋值给shared_ptr类型的变量,最重要的一点是赋值给它不会增加引用计数:

 1 void test1()
 2 {
 3     // 编译错误 // error C2665: “std::weak_ptr<CA>::weak_ptr”: 3 个重载中没有一个可以转换所有参数类型
 4     // weak_ptr<CA> ptr_1(new CA());
 5 
 6     shared_ptr<CA> ptr_1(new CA());
 7 
 8     cout << "ptr_1 use count : " << ptr_1.use_count() << endl; // 输出:ptr_1 use count : 1
 9 
10     shared_ptr<CA> ptr_2 = ptr_1;
11 
12     cout << "ptr_1 use count : " << ptr_1.use_count() << endl; // 输出:ptr_1 use count : 2
13     cout << "ptr_2 use count : " << ptr_2.use_count() << endl; // 输出:ptr_1 use count : 2
14 
15     weak_ptr<CA> wk_ptr = ptr_1;
16 
17     cout << "ptr_1 use count : " << ptr_1.use_count() << endl; // 输出:ptr_1 use count : 2
18     cout << "ptr_2 use count : " << ptr_2.use_count() << endl; // 输出:ptr_1 use count : 2
19 
20     // 编译错误
21     // error C2440 : “初始化”: 无法从“std::weak_ptr<CA>”转换为“std::shared_ptr<CA>”
22     // shared_ptr<CA> ptr_3 = wk_ptr;
23 }

3、测试weak_ptr常用函数的用法

  weak_ptr中只有函数lockexpired两个函数比较重要,因为它本身不会增加引用计数,所以它指向的对象可能在它用的时候已经被释放了,所以在用之前需要使用expired函数来检测是否过期,然后使用lock函数来获取其对应的shared_ptr对象,然后进行后续操作:

 1 void test2()
 2 {
 3     shared_ptr<CA> ptr_a(new CA());     // 输出:CA() called!
 4     shared_ptr<CB> ptr_b(new CB());     // 输出:CB() called!
 5 
 6     cout << "ptr_a use count : " << ptr_a.use_count() << endl; // 输出:ptr_a use count : 1
 7     cout << "ptr_b use count : " << ptr_b.use_count() << endl; // 输出:ptr_b use count : 1
 8     
 9     weak_ptr<CA> wk_ptr_a = ptr_a;
10     weak_ptr<CB> wk_ptr_b = ptr_b;
11 
12     if (!wk_ptr_a.expired())
13     {
14         wk_ptr_a.lock()->show();        // 输出:this is class CA!
15     }
16 
17     if (!wk_ptr_b.expired())
18     {
19         wk_ptr_b.lock()->show();        // 输出:this is class CB!
20     }
21 
22     // 编译错误
23     // 编译必须作用于相同的指针类型之间
24     // wk_ptr_a.swap(wk_ptr_b);         // 调用交换函数
25 
26     wk_ptr_b.reset();                   // 将wk_ptr_b的指向清空
27     if (wk_ptr_b.expired())
28     {
29         cout << "wk_ptr_b is invalid" << endl;  // 输出:wk_ptr_b is invalid 说明改指针已经无效
30     }
31 
32     wk_ptr_b = ptr_b;
33     if (!wk_ptr_b.expired())
34     {
35         wk_ptr_b.lock()->show();        // 输出:this is class CB! 调用赋值操作后,wk_ptr_b恢复有效
36     }
37 
38     // 编译错误
39     // 编译必须作用于相同的指针类型之间
40     // wk_ptr_b = wk_ptr_a;
41 
42 
43     // 最后输出的引用计数还是1,说明之前使用weak_ptr类型赋值,不会影响引用计数
44     cout << "ptr_a use count : " << ptr_a.use_count() << endl; // 输出:ptr_a use count : 1
45     cout << "ptr_b use count : " << ptr_b.use_count() << endl; // 输出:ptr_b use count : 1
46 }

  引用计数的出现,解决了对象独占的问题,但是也带来了循环引用的困扰,使用weak_ptr可以打破这种循环,当你理不清引用关系的时候,不妨采用文中画图的方式来理一理头绪,或许就会有眼前一亮的感觉。

  通常做法是 parent 类持有 child 的 shared_ptr, child 持有指向 parent 的 weak_ptr。这样也更符合语义。

选择哪种指针作为函数的参数

  很多时候,函数的参数是个指针。这个时候就会面临选择困难症,这个参数应该怎么传,应该是shared_ptr,还是const shared_ptr&,还是直接raw pointer更合适。

1、只在函数使用指针,但并不保存对象内容

  假如我们只需要在函数中,用这个对象处理一些事情,但不打算涉及其生命周期的管理,也不打算通过函数传参延长 shared_ptr 的生命周期。
对于这种情况,可以使用 raw pointer 或者 const shared_ptr&。
即:

1 void func(Widget*);
2 void func(const shared_ptr<Widget>&)

  实际上第一种裸指针的方式可能更好,从语义上更加清楚,函数也不用关心智能指针的类型。

2、在函数中保存智能指针

  假如我们需要在函数中把这个智能指针保存起来,这个时候建议直接传值。

void func(std::shared_ptr<Widget> ptr);

  这样的话,外部传过来值的时候,可以选择 move 或者赋值。函数内部直接把这个对象通过 move 的方式保存起来。
  这样性能更好,而且外部调用也有多种选择。

总结

  对于智能指针的使用,实际上是对所有权和生命周期的思考,一旦想明白了这两点,那对智能指针的使用也就得心应手了。

  同时理解了每种智能指针背后的性能消耗,使用场景,那智能指针也不再是黑盒子和洪水猛兽。

 

转载自《C++智能指针的正确使用方式

posted @ 2020-10-28 20:27  阿玛尼迪迪  阅读(849)  评论(0编辑  收藏  举报