对象所有权
首先需要理清楚的概念就是对象所有权的概念。所有权在 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
通过结果可以看到,最后CA
和CB
的对象并没有被析构,其中的引用效果如下图所示,起初定义完ptr_a
和ptr_b
时,只有①③两条引用,然后调用函数set_ptr
后又增加了②④两条引用,当test_refer_to_each_other
这个函数返回时,对象ptr_a
和ptr_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!
通过这次结果可以看到,CA
和CB
的对象都被正常的析构了,引用关系如下图所示,流程与上一例子相似,但是不同的是④这条引用是通过weak_ptr
建立的,并不会增加引用计数,也就是说CA
的对象只有一个引用计数,而CB
的对象只有2个引用计数,当test_refer_to_each_other
这个函数返回时,对象ptr_a
和ptr_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
中只有函数lock
和expired
两个函数比较重要,因为它本身不会增加引用计数,所以它指向的对象可能在它用的时候已经被释放了,所以在用之前需要使用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++智能指针的正确使用方式》